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

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