feat: Add quick wins batch (#42, #43, #25, #35, #20, #36, #39)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- #42: Allow spools heavier than theoretical max (remove weight clamps) - #43: Show filament custom fields on spool detail page - #25: Sort spools by custom fields and remaining weight - #35: Global search box for spool list - #20: Gallery view for spools (color grid with progress bars) - #36: Spool-level color override with ColorPicker - #39: Cost analytics endpoint, total spent & avg cost/kg stats - Fix: Filament select refresh after inline creation - Fix: Parse temperatures from filament comments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -317,6 +317,12 @@ class Spool(BaseModel):
|
||||
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
|
||||
examples=[50],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
default=None,
|
||||
max_length=8,
|
||||
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
|
||||
examples=["FF0000"],
|
||||
)
|
||||
used_weight: float = Field(
|
||||
ge=0,
|
||||
description="Consumed weight of filament from the spool in grams.",
|
||||
@@ -381,14 +387,14 @@ class Spool(BaseModel):
|
||||
remaining_length: Optional[float] = None
|
||||
|
||||
if item.initial_weight is not None:
|
||||
remaining_weight = max(item.initial_weight - item.used_weight, 0)
|
||||
remaining_weight = item.initial_weight - item.used_weight
|
||||
remaining_length = length_from_weight(
|
||||
weight=remaining_weight,
|
||||
density=filament.density,
|
||||
diameter=filament.diameter,
|
||||
)
|
||||
elif filament.weight is not None:
|
||||
remaining_weight = max(filament.weight - item.used_weight, 0)
|
||||
remaining_weight = filament.weight - item.used_weight
|
||||
remaining_length = length_from_weight(
|
||||
weight=remaining_weight,
|
||||
density=filament.density,
|
||||
|
||||
@@ -291,3 +291,83 @@ async def get_usage_by_material(
|
||||
rows = result.all()
|
||||
|
||||
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]
|
||||
|
||||
|
||||
class CostByMaterial(BaseModel):
|
||||
"""Cost breakdown by material."""
|
||||
|
||||
material: str = Field(description="Material name.")
|
||||
total_cost: float = Field(description="Total cost spent on this material.")
|
||||
total_weight: float = Field(description="Total initial weight purchased in grams.")
|
||||
|
||||
|
||||
class CostStats(BaseModel):
|
||||
"""Overall cost statistics."""
|
||||
|
||||
total_spent: float = Field(description="Sum of all spool purchase prices.")
|
||||
total_weight_purchased: float = Field(description="Total initial weight of all spools in grams.")
|
||||
avg_cost_per_kg: Optional[float] = Field(description="Average cost per kilogram of filament.")
|
||||
cost_by_material: list[CostByMaterial] = Field(description="Cost breakdown by material type.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/analytics/cost",
|
||||
name="Get cost statistics",
|
||||
description="Get overall cost tracking statistics across all spools.",
|
||||
response_model=CostStats,
|
||||
)
|
||||
async def get_cost_stats(
|
||||
*,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
) -> CostStats:
|
||||
"""Get cost statistics including total spent and cost per kg."""
|
||||
# Use COALESCE(spool.price, filament.price) for effective price
|
||||
effective_price = func.coalesce(models.Spool.price, models.Filament.price)
|
||||
effective_weight = func.coalesce(models.Spool.initial_weight, models.Filament.weight)
|
||||
|
||||
# Total spent and total weight across all spools (including archived)
|
||||
totals_stmt = (
|
||||
sqlalchemy.select(
|
||||
func.sum(effective_price).label("total_spent"),
|
||||
func.sum(effective_weight).label("total_weight"),
|
||||
)
|
||||
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
||||
.where(effective_price.isnot(None))
|
||||
)
|
||||
totals_result = await db.execute(totals_stmt)
|
||||
totals_row = totals_result.one()
|
||||
|
||||
total_spent = totals_row.total_spent or 0.0
|
||||
total_weight_purchased = totals_row.total_weight or 0.0
|
||||
avg_cost_per_kg = (total_spent / total_weight_purchased * 1000) if total_weight_purchased > 0 else None
|
||||
|
||||
# Cost by material
|
||||
material_stmt = (
|
||||
sqlalchemy.select(
|
||||
func.coalesce(models.Filament.material, "Unknown").label("material"),
|
||||
func.sum(effective_price).label("total_cost"),
|
||||
func.sum(effective_weight).label("total_weight"),
|
||||
)
|
||||
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
||||
.where(effective_price.isnot(None))
|
||||
.group_by(models.Filament.material)
|
||||
.order_by(func.sum(effective_price).desc())
|
||||
)
|
||||
material_result = await db.execute(material_stmt)
|
||||
material_rows = material_result.all()
|
||||
|
||||
cost_by_material = [
|
||||
CostByMaterial(
|
||||
material=row.material or "Unknown",
|
||||
total_cost=row.total_cost or 0.0,
|
||||
total_weight=row.total_weight or 0.0,
|
||||
)
|
||||
for row in material_rows
|
||||
]
|
||||
|
||||
return CostStats(
|
||||
total_spent=total_spent,
|
||||
total_weight_purchased=total_weight_purchased,
|
||||
avg_cost_per_kg=avg_cost_per_kg,
|
||||
cost_by_material=cost_by_material,
|
||||
)
|
||||
|
||||
@@ -57,6 +57,12 @@ class SpoolParameters(BaseModel):
|
||||
description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.",
|
||||
examples=[50],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
None,
|
||||
max_length=8,
|
||||
description="Spool-level color override (hex code without #). Overrides the filament color if set.",
|
||||
examples=["FF0000"],
|
||||
)
|
||||
remaining_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
@@ -299,6 +305,15 @@ async def find(
|
||||
description="Filter by spools with remaining weight (in grams) greater than this value.",
|
||||
),
|
||||
] = None,
|
||||
q: Annotated[
|
||||
Optional[str],
|
||||
Query(
|
||||
title="Search",
|
||||
description=(
|
||||
"Global search term. Searches across filament name, vendor name, material, and location."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
sort: Annotated[
|
||||
Optional[str],
|
||||
Query(
|
||||
@@ -335,6 +350,7 @@ async def find(
|
||||
|
||||
db_items, total_count = await spool.find(
|
||||
db=db,
|
||||
q=q,
|
||||
filament_name=filament_name if filament_name is not None else filament_name_old,
|
||||
filament_id=filament_ids,
|
||||
filament_material=filament_material if filament_material is not None else filament_material_old,
|
||||
@@ -456,6 +472,7 @@ async def create( # noqa: ANN201
|
||||
initial_weight=body.initial_weight,
|
||||
spool_weight=body.spool_weight,
|
||||
extra_weight=body.extra_weight,
|
||||
color_hex=body.color_hex,
|
||||
remaining_weight=body.remaining_weight,
|
||||
used_weight=body.used_weight,
|
||||
first_used=body.first_used,
|
||||
|
||||
Reference in New Issue
Block a user