feat: Add extra weight, price tracking, print history, usage analytics
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

- Extra Weight Field (#14): Track DryPods, custom holders in spool weight
  calculations. New extra_weight field on spool with DB migration.

- Price/Cost Tracking: Compute remaining_value based on remaining weight
  and price. Added column to spool list, inventory value on dashboard.

- Print History: Show print job history on spool detail page with
  collapsible table showing filename, filament used, status, dates.

- Usage Analytics: New dashboard component with time-series chart
  showing daily consumption, period selector (7/30/90 days), and
  material breakdown. New API endpoints for analytics data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 22:38:14 -06:00
parent 0556be9e3b
commit 18cafc4361
15 changed files with 514 additions and 18 deletions

View File

@@ -311,6 +311,12 @@ class Spool(BaseModel):
description=("Weight of an empty spool (tare weight)."),
examples=[246],
)
extra_weight: Optional[float] = Field(
default=None,
ge=0,
description=("Extra weight to account for, such as DryPods, custom spool holders, etc."),
examples=[50],
)
used_weight: float = Field(
ge=0,
description="Consumed weight of filament from the spool in grams.",
@@ -325,6 +331,12 @@ class Spool(BaseModel):
),
examples=[5612.4],
)
remaining_value: Optional[float] = Field(
default=None,
ge=0,
description="Estimated remaining value of filament on the spool based on remaining weight and price.",
examples=[12.50],
)
used_length: float = Field(
ge=0,
description="Consumed length of filament from the spool in millimeters.",
@@ -389,6 +401,13 @@ class Spool(BaseModel):
diameter=filament.diameter,
)
# Calculate remaining value based on remaining weight and price
remaining_value: Optional[float] = None
price = item.price if item.price is not None else filament.price
initial_weight = item.initial_weight if item.initial_weight is not None else filament.weight
if remaining_weight is not None and price is not None and initial_weight is not None and initial_weight > 0:
remaining_value = round((remaining_weight / initial_weight) * price, 2)
return Spool(
id=item.id,
registered=item.registered,
@@ -398,10 +417,12 @@ class Spool(BaseModel):
price=item.price,
initial_weight=item.initial_weight,
spool_weight=item.spool_weight,
extra_weight=item.extra_weight,
used_weight=item.used_weight,
used_length=used_length,
remaining_weight=remaining_weight,
remaining_length=remaining_length,
remaining_value=remaining_value,
location=item.location,
lot_nr=item.lot_nr,
comment=item.comment,

View File

@@ -1,13 +1,16 @@
"""Filament related endpoints."""
import logging
from typing import Annotated
from datetime import datetime, timedelta
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
import sqlalchemy
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field, RootModel
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
from spoolman.database import filament, models, spool
from spoolman.database.database import get_db_session
logger = logging.getLogger(__name__)
@@ -173,3 +176,118 @@ async def rename_location(
logger.info("Renaming location %s to %s", location, body.name)
await spool.rename_location(db=db, current_name=location, new_name=body.name)
return body.name
class UsageDataPoint(BaseModel):
"""A single data point for usage analytics."""
date: str = Field(description="Date in YYYY-MM-DD format.")
weight_used: float = Field(description="Total weight used in grams (positive value).")
@router.get(
"/analytics/usage",
name="Get usage analytics",
description="Get filament usage data aggregated by day for the specified period.",
response_model=list[UsageDataPoint],
)
async def get_usage_analytics(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
days: Annotated[
int,
Query(description="Number of days to look back.", ge=1, le=365),
] = 30,
) -> list[UsageDataPoint]:
"""Get usage analytics aggregated by day."""
# Calculate the start date
start_date = datetime.utcnow() - timedelta(days=days)
# Query adjustments grouped by date
# Only include negative values (filament used, not added)
stmt = (
sqlalchemy.select(
func.date(models.SpoolAdjustment.timestamp).label("date"),
func.sum(
sqlalchemy.case(
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
else_=0,
)
).label("weight_used"),
)
.where(models.SpoolAdjustment.timestamp >= start_date)
.where(models.SpoolAdjustment.adjustment_type == "weight")
.group_by(func.date(models.SpoolAdjustment.timestamp))
.order_by(func.date(models.SpoolAdjustment.timestamp))
)
result = await db.execute(stmt)
rows = result.all()
# Fill in missing dates with 0
data_by_date = {str(row.date): row.weight_used for row in rows}
all_dates = []
current_date = start_date.date()
end_date = datetime.utcnow().date()
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
all_dates.append(
UsageDataPoint(
date=date_str,
weight_used=data_by_date.get(date_str, 0),
)
)
current_date += timedelta(days=1)
return all_dates
class MaterialUsage(BaseModel):
"""Usage breakdown by material."""
material: str = Field(description="Material name.")
weight_used: float = Field(description="Total weight used in grams.")
@router.get(
"/analytics/by-material",
name="Get usage by material",
description="Get filament usage breakdown by material type.",
response_model=list[MaterialUsage],
)
async def get_usage_by_material(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
days: Annotated[
Optional[int],
Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365),
] = None,
) -> list[MaterialUsage]:
"""Get usage breakdown by material."""
# Join adjustments with spools and filaments to get material
stmt = (
sqlalchemy.select(
func.coalesce(models.Filament.material, "Unknown").label("material"),
func.sum(
sqlalchemy.case(
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
else_=0,
)
).label("weight_used"),
)
.join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id)
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
.where(models.SpoolAdjustment.adjustment_type == "weight")
)
if days is not None:
start_date = datetime.utcnow() - timedelta(days=days)
stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date)
stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc())
result = await db.execute(stmt)
rows = result.all()
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]

View File

@@ -51,6 +51,12 @@ class SpoolParameters(BaseModel):
description="The weight of an empty spool, in grams. (tare weight)",
examples=[200],
)
extra_weight: Optional[float] = Field(
None,
ge=0,
description="Extra weight to account for, such as DryPods, custom spool holders, etc., in grams.",
examples=[50],
)
remaining_weight: Optional[float] = Field(
None,
ge=0,
@@ -449,6 +455,7 @@ async def create( # noqa: ANN201
price=body.price,
initial_weight=body.initial_weight,
spool_weight=body.spool_weight,
extra_weight=body.extra_weight,
remaining_weight=body.remaining_weight,
used_weight=body.used_weight,
first_used=body.first_used,

View File

@@ -100,6 +100,10 @@ class Spool(Base):
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
extra_weight: Mapped[Optional[float]] = mapped_column(
default=None,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
)
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"),

View File

@@ -41,6 +41,7 @@ async def create(
remaining_weight: Optional[float] = None,
initial_weight: Optional[float] = None,
spool_weight: Optional[float] = None,
extra_weight: Optional[float] = None,
used_weight: Optional[float] = None,
first_used: Optional[datetime] = None,
last_used: Optional[datetime] = None,
@@ -84,6 +85,7 @@ async def create(
registered=datetime.utcnow().replace(microsecond=0),
initial_weight=initial_weight,
spool_weight=spool_weight,
extra_weight=extra_weight,
used_weight=used_weight,
price=price,
first_used=first_used,
@@ -440,7 +442,12 @@ async def measure(
"""
spool_result = await db.execute(
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
sqlalchemy.select(
models.Spool.initial_weight,
models.Spool.used_weight,
models.Spool.spool_weight,
models.Spool.extra_weight,
).where(
models.Spool.id == spool_id,
),
)
@@ -452,6 +459,7 @@ async def measure(
initial_weight = spool_info[0]
spool_weight = spool_info[2]
extra_weight = spool_info[3] or 0 # Default to 0 if not set
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
# Get filament weight and spool_weight
result = await db.execute(
@@ -473,7 +481,7 @@ async def measure(
if initial_weight is None or initial_weight == 0:
raise SpoolMeasureError("Initial weight is not set.")
initial_gross_weight = initial_weight + spool_weight
initial_gross_weight = initial_weight + spool_weight + extra_weight
# if the measurement is greater than the initial weight, set the initial weight to the measurement
if weight > initial_gross_weight:
@@ -485,9 +493,10 @@ async def measure(
# Calculate the weight used since last measure
weight_to_use = current_use - weight
# If the measured weight is less than the empty weight, use the rest of the spool
if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_use - spool_weight
# If the measured weight is less than the empty weight (+ extra), use the rest of the spool
empty_gross_weight = spool_weight + extra_weight
if (initial_gross_weight - weight_to_use) < empty_gross_weight:
weight_to_use = current_use - empty_gross_weight
return await use_weight(db, spool_id, weight_to_use, comment=comment)