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

@@ -388,6 +388,29 @@ class Spool(BaseModel):
)
class SpoolAdjustment(BaseModel):
"""Record of a spool weight/length adjustment."""
id: int = Field(description="Unique internal ID of this adjustment.")
spool_id: int = Field(description="The spool this adjustment belongs to.")
timestamp: SpoolmanDateTime = Field(description="When the adjustment was made. UTC Timezone.")
adjustment_type: str = Field(description="Type of adjustment: 'weight' or 'length'.")
value: float = Field(description="Amount adjusted (negative = used, positive = added).")
comment: Optional[str] = Field(None, description="Optional user comment about this adjustment.")
@staticmethod
def from_db(item: models.SpoolAdjustment) -> "SpoolAdjustment":
"""Create a new Pydantic SpoolAdjustment object from a database object."""
return SpoolAdjustment(
id=item.id,
spool_id=item.spool_id,
timestamp=item.timestamp,
adjustment_type=item.adjustment_type,
value=item.value,
comment=item.comment,
)
class Info(BaseModel):
version: str = Field(examples=["0.7.0"])
debug_mode: bool = Field(examples=[False])

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)},
)

View File

@@ -85,6 +85,25 @@ class Spool(Base):
cascade="save-update, merge, delete, delete-orphan",
lazy="joined",
)
adjustments: Mapped[list["SpoolAdjustment"]] = relationship(
back_populates="spool",
cascade="save-update, merge, delete, delete-orphan",
lazy="noload", # Don't load by default, only on explicit request
)
class SpoolAdjustment(Base):
"""Track history of spool weight/length adjustments."""
__tablename__ = "spool_adjustment"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
spool: Mapped["Spool"] = relationship(back_populates="adjustments")
timestamp: Mapped[datetime] = mapped_column(comment="When the adjustment was made.")
adjustment_type: Mapped[str] = mapped_column(String(16), comment="Type: 'weight' or 'length'.")
value: Mapped[float] = mapped_column(comment="Amount adjusted (negative = used, positive = added).")
comment: Mapped[Optional[str]] = mapped_column(String(256), comment="Optional user comment.")
class Setting(Base):

View File

@@ -275,16 +275,24 @@ async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> Non
)
async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
async def use_weight(
db: AsyncSession,
spool_id: int,
weight: float,
*,
comment: Optional[str] = None,
) -> models.Spool:
"""Consume filament from a spool by weight.
Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate.
Records the adjustment in the history.
Args:
db (AsyncSession): Database session
spool_id (int): Spool ID
weight (float): Filament weight to consume, in grams
comment (Optional[str]): Optional comment for this adjustment
Returns:
models.Spool: Updated spool object
@@ -298,21 +306,39 @@ async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.S
spool.first_used = datetime.utcnow().replace(microsecond=0)
spool.last_used = datetime.utcnow().replace(microsecond=0)
# Record the adjustment in history (negative value = filament used)
adjustment = models.SpoolAdjustment(
spool_id=spool_id,
timestamp=datetime.utcnow().replace(microsecond=0),
adjustment_type="weight",
value=-weight, # Negative because we're using filament
comment=comment,
)
db.add(adjustment)
await db.commit()
await spool_changed(spool, EventType.UPDATED)
return spool
async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.Spool:
async def use_length(
db: AsyncSession,
spool_id: int,
length: float,
*,
comment: Optional[str] = None,
) -> models.Spool:
"""Consume filament from a spool by length.
Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate.
Records the adjustment in the history.
Args:
db (AsyncSession): Database session
spool_id (int): Spool ID
length (float): Length of filament to consume, in mm
comment (Optional[str]): Optional comment for this adjustment
Returns:
models.Spool: Updated spool object
@@ -344,21 +370,39 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
spool.first_used = datetime.utcnow().replace(microsecond=0)
spool.last_used = datetime.utcnow().replace(microsecond=0)
# Record the adjustment in history (negative value = filament used)
adjustment = models.SpoolAdjustment(
spool_id=spool_id,
timestamp=datetime.utcnow().replace(microsecond=0),
adjustment_type="length",
value=-length, # Negative because we're using filament
comment=comment,
)
db.add(adjustment)
await db.commit()
await spool_changed(spool, EventType.UPDATED)
return spool
async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
async def measure(
db: AsyncSession,
spool_id: int,
weight: float,
*,
comment: Optional[str] = None,
) -> models.Spool:
"""Record usage based on current gross weight of spool.
Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate.
Records the adjustment in the history.
Args:
db (AsyncSession): Database session
spool_id (int): Spool ID
weight (float): Length of filament to consume, in mm
weight (float): Current gross weight of the spool, in grams
comment (Optional[str]): Optional comment for this adjustment
Returns:
models.Spool: Updated spool object
@@ -414,7 +458,7 @@ async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spoo
if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_use - spool_weight
return await use_weight(db, spool_id, weight_to_use)
return await use_weight(db, spool_id, weight_to_use, comment=comment)
async def find_locations(
@@ -475,3 +519,46 @@ async def rename_location(
await db.execute(
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name),
)
async def get_adjustments(
db: AsyncSession,
spool_id: int,
*,
limit: Optional[int] = None,
offset: int = 0,
) -> tuple[list[models.SpoolAdjustment], int]:
"""Get the adjustment history for a spool.
Args:
db (AsyncSession): Database session
spool_id (int): Spool ID
limit (Optional[int]): Maximum number of items to return
offset (int): Offset for pagination
Returns:
tuple: List of adjustment records and total count
"""
# First verify the spool exists
await get_by_id(db, spool_id)
# Build the query
stmt = (
sqlalchemy.select(models.SpoolAdjustment)
.where(models.SpoolAdjustment.spool_id == spool_id)
.order_by(models.SpoolAdjustment.timestamp.desc())
)
# Get total count
count_stmt = sqlalchemy.select(func.count()).select_from(models.SpoolAdjustment).where(
models.SpoolAdjustment.spool_id == spool_id,
)
total_count = (await db.execute(count_stmt)).scalar() or 0
# Apply pagination
if limit is not None:
stmt = stmt.offset(offset).limit(limit)
rows = await db.execute(stmt)
return list(rows.scalars().all()), total_count