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

@@ -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]