feat: Add spool adjustment history tracking (#6)

Track history of all spool weight/length adjustments with timestamps and comments:

Backend:
- Add SpoolAdjustment database model with spool_id, timestamp, type, value, comment
- Add Alembic migration for spool_adjustment table
- Add SpoolAdjustment Pydantic model for API responses
- Update use_weight, use_length, and measure functions to record adjustments
- Add GET /api/v1/spool/{id}/adjustments endpoint for history retrieval
- Add optional comment parameter to use/measure endpoints

Frontend:
- Add ISpoolAdjustment interface to spool model
- Add collapsible adjustment history table to spool show page
- Display timestamp, type (weight/length), amount, and comment
- Add translation keys for history table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 20:18:56 -06:00
parent 388669d0c6
commit d804329b77
8 changed files with 307 additions and 13 deletions

View File

@@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, Spool, SpoolEvent
from spoolman.api.v1.models import Message, Spool, SpoolAdjustment, SpoolEvent
from spoolman.database import spool
from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder
@@ -105,10 +105,22 @@ class SpoolUpdateParameters(SpoolParameters):
class SpoolUseParameters(BaseModel):
use_length: Optional[float] = Field(None, description="Length of filament to reduce by, in mm.", examples=[2.2])
use_weight: Optional[float] = Field(None, description="Filament weight to reduce by, in g.", examples=[5.3])
comment: Optional[str] = Field(
None,
max_length=256,
description="Optional comment about this adjustment (e.g., 'printed cover piece').",
examples=["Test print"],
)
class SpoolMeasureParameters(BaseModel):
weight: float = Field(description="Current gross weight of the spool, in g.", examples=[200])
comment: Optional[str] = Field(
None,
max_length=256,
description="Optional comment about this adjustment (e.g., 'weighed after print').",
examples=["After printing benchy"],
)
@router.get(
@@ -513,11 +525,11 @@ async def use( # noqa: ANN201
)
if body.use_weight is not None:
db_item = await spool.use_weight(db, spool_id, body.use_weight)
db_item = await spool.use_weight(db, spool_id, body.use_weight, comment=body.comment)
return Spool.from_db(db_item)
if body.use_length is not None:
db_item = await spool.use_length(db, spool_id, body.use_length)
db_item = await spool.use_length(db, spool_id, body.use_length, comment=body.comment)
return Spool.from_db(db_item)
return JSONResponse(
@@ -543,7 +555,7 @@ async def measure( # noqa: ANN201
body: SpoolMeasureParameters,
):
try:
db_item = await spool.measure(db, spool_id, body.weight)
db_item = await spool.measure(db, spool_id, body.weight, comment=body.comment)
return Spool.from_db(db_item)
except SpoolMeasureError as e:
logger.exception("Failed to update spool measurement.")
@@ -551,3 +563,32 @@ async def measure( # noqa: ANN201
status_code=400,
content={"message": e.args[0]},
)
@router.get(
"/{spool_id}/adjustments",
name="Get spool adjustment history",
description="Get the history of all weight/length adjustments for a spool.",
response_model_exclude_none=True,
responses={
200: {"model": list[SpoolAdjustment]},
404: {"model": Message},
},
)
async def get_adjustments(
db: Annotated[AsyncSession, Depends(get_db_session)],
spool_id: int,
limit: Annotated[
Optional[int],
Query(title="Limit", description="Maximum number of items in the response."),
] = None,
offset: Annotated[int, Query(title="Offset", description="Offset in the full result set if a limit is set.")] = 0,
) -> JSONResponse:
db_items, total_count = await spool.get_adjustments(db, spool_id, limit=limit, offset=offset)
return JSONResponse(
content=jsonable_encoder(
[SpoolAdjustment.from_db(db_item) for db_item in db_items],
exclude_none=True,
),
headers={"x-total-count": str(total_count)},
)