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

@@ -176,7 +176,15 @@
"last_used": "Last Used", "last_used": "Last Used",
"registered": "Registered", "registered": "Registered",
"comment": "Comment", "comment": "Comment",
"archived": "Archived" "archived": "Archived",
"adjustment_history": "Adjustment History",
"show_history": "Show History",
"adjustment_timestamp": "Date/Time",
"adjustment_type": "Type",
"adjustment_type_weight": "Weight",
"adjustment_type_length": "Length",
"adjustment_value": "Amount",
"adjustment_comment": "Comment"
}, },
"fields_help": { "fields_help": {
"price": "Price of a full spool. If not set, the price of the filament will be assumed instead.", "price": "Price of a full spool. If not set, the price of the filament will be assumed instead.",

View File

@@ -28,3 +28,12 @@ export interface ISpool {
// ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types // ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types
export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } }; export type ISpoolParsedExtras = Omit<ISpool, "extra"> & { extra?: { [key: string]: unknown } };
export interface ISpoolAdjustment {
id: number;
spool_id: number;
timestamp: string;
adjustment_type: "weight" | "length";
value: number;
comment?: string;
}

View File

@@ -1,7 +1,8 @@
import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons";
import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core"; import { IResourceComponentsProps, useInvalidate, useShow, useTranslate } from "@refinedev/core";
import { Button, Modal, Typography } from "antd"; import { useQuery } from "@tanstack/react-query";
import { Button, Collapse, Modal, Table, Typography } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import React from "react"; import React from "react";
@@ -11,10 +12,10 @@ import SpoolIcon from "../../components/spoolIcon";
import { enrichText } from "../../utils/parsing"; import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields"; import { EntityType, useGetFields } from "../../utils/queryFields";
import { useCurrencyFormatter } from "../../utils/settings"; import { useCurrencyFormatter } from "../../utils/settings";
import { getBasePath } from "../../utils/url"; import { getAPIURL, getBasePath } from "../../utils/url";
import { IFilament } from "../filaments/model"; import { IFilament } from "../filaments/model";
import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions";
import { ISpool } from "./model"; import { ISpool, ISpoolAdjustment } from "./model";
dayjs.extend(utc); dayjs.extend(utc);
@@ -34,6 +35,16 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
const record = data?.data; const record = data?.data;
// Fetch adjustment history
const { data: adjustmentsData, isLoading: adjustmentsLoading } = useQuery({
queryKey: ["spool-adjustments", record?.id],
queryFn: async () => {
const res = await fetch(`${getAPIURL()}/spool/${record?.id}/adjustments`);
return (await res.json()) as ISpoolAdjustment[];
},
enabled: !!record?.id,
});
const spoolPrice = (item?: ISpool) => { const spoolPrice = (item?: ISpool) => {
const price = item?.price ?? item?.filament.price; const price = item?.price ?? item?.filament.price;
if (price === undefined) { if (price === undefined) {
@@ -224,6 +235,59 @@ export const SpoolShow: React.FC<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => ( {extraFields?.data?.map((field, index) => (
<ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} /> <ExtraFieldDisplay key={index} field={field} value={record?.extra[field.key]} />
))} ))}
<Title level={4} style={{ marginTop: 24 }}>
{t("spool.fields.adjustment_history")}
</Title>
<Collapse
items={[
{
key: "1",
label: t("spool.fields.show_history"),
children: (
<Table<ISpoolAdjustment>
dataSource={adjustmentsData}
loading={adjustmentsLoading}
rowKey="id"
size="small"
pagination={{ pageSize: 10 }}
columns={[
{
title: t("spool.fields.adjustment_timestamp"),
dataIndex: "timestamp",
key: "timestamp",
render: (value: string) => (
<span title={dayjs.utc(value).local().format()}>
{dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")}
</span>
),
},
{
title: t("spool.fields.adjustment_type"),
dataIndex: "adjustment_type",
key: "adjustment_type",
render: (value: string) => t(`spool.fields.adjustment_type_${value}`),
},
{
title: t("spool.fields.adjustment_value"),
dataIndex: "value",
key: "value",
render: (value: number, record: ISpoolAdjustment) => {
const unit = record.adjustment_type === "weight" ? "g" : "mm";
const formatted = value.toFixed(2);
return `${value > 0 ? "+" : ""}${formatted} ${unit}`;
},
},
{
title: t("spool.fields.adjustment_comment"),
dataIndex: "comment",
key: "comment",
},
]}
/>
),
},
]}
/>
</Show> </Show>
); );
}; };

View File

@@ -0,0 +1,43 @@
"""add_spool_adjustment_history.
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-01-15 03:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b2c3d4e5f6g7"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create spool_adjustment table for tracking history."""
op.create_table(
"spool_adjustment",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("spool_id", sa.Integer(), nullable=False),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("adjustment_type", sa.String(length=16), nullable=False),
sa.Column("value", sa.Float(), nullable=False),
sa.Column("comment", sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(
["spool_id"],
["spool.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_spool_adjustment_id"), "spool_adjustment", ["id"], unique=False)
op.create_index(op.f("ix_spool_adjustment_spool_id"), "spool_adjustment", ["spool_id"], unique=False)
def downgrade() -> None:
"""Drop spool_adjustment table."""
op.drop_index(op.f("ix_spool_adjustment_spool_id"), table_name="spool_adjustment")
op.drop_index(op.f("ix_spool_adjustment_id"), table_name="spool_adjustment")
op.drop_table("spool_adjustment")

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): class Info(BaseModel):
version: str = Field(examples=["0.7.0"]) version: str = Field(examples=["0.7.0"])
debug_mode: bool = Field(examples=[False]) 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 pydantic import BaseModel, Field, field_validator
from sqlalchemy.ext.asyncio import AsyncSession 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 import spool
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
@@ -105,10 +105,22 @@ class SpoolUpdateParameters(SpoolParameters):
class SpoolUseParameters(BaseModel): class SpoolUseParameters(BaseModel):
use_length: Optional[float] = Field(None, description="Length of filament to reduce by, in mm.", examples=[2.2]) 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]) 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): class SpoolMeasureParameters(BaseModel):
weight: float = Field(description="Current gross weight of the spool, in g.", examples=[200]) 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( @router.get(
@@ -513,11 +525,11 @@ async def use( # noqa: ANN201
) )
if body.use_weight is not None: 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) return Spool.from_db(db_item)
if body.use_length is not None: 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 Spool.from_db(db_item)
return JSONResponse( return JSONResponse(
@@ -543,7 +555,7 @@ async def measure( # noqa: ANN201
body: SpoolMeasureParameters, body: SpoolMeasureParameters,
): ):
try: 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) return Spool.from_db(db_item)
except SpoolMeasureError as e: except SpoolMeasureError as e:
logger.exception("Failed to update spool measurement.") logger.exception("Failed to update spool measurement.")
@@ -551,3 +563,32 @@ async def measure( # noqa: ANN201
status_code=400, status_code=400,
content={"message": e.args[0]}, 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", cascade="save-update, merge, delete, delete-orphan",
lazy="joined", 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): 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. """Consume filament from a spool by weight.
Increases the used_weight attribute of the spool. Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate. Updates the first_used and last_used attributes where appropriate.
Records the adjustment in the history.
Args: Args:
db (AsyncSession): Database session db (AsyncSession): Database session
spool_id (int): Spool ID spool_id (int): Spool ID
weight (float): Filament weight to consume, in grams weight (float): Filament weight to consume, in grams
comment (Optional[str]): Optional comment for this adjustment
Returns: Returns:
models.Spool: Updated spool object 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.first_used = datetime.utcnow().replace(microsecond=0)
spool.last_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 db.commit()
await spool_changed(spool, EventType.UPDATED) await spool_changed(spool, EventType.UPDATED)
return spool 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. """Consume filament from a spool by length.
Increases the used_weight attribute of the spool. Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate. Updates the first_used and last_used attributes where appropriate.
Records the adjustment in the history.
Args: Args:
db (AsyncSession): Database session db (AsyncSession): Database session
spool_id (int): Spool ID spool_id (int): Spool ID
length (float): Length of filament to consume, in mm length (float): Length of filament to consume, in mm
comment (Optional[str]): Optional comment for this adjustment
Returns: Returns:
models.Spool: Updated spool object 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.first_used = datetime.utcnow().replace(microsecond=0)
spool.last_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 db.commit()
await spool_changed(spool, EventType.UPDATED) await spool_changed(spool, EventType.UPDATED)
return spool 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. """Record usage based on current gross weight of spool.
Increases the used_weight attribute of the spool. Increases the used_weight attribute of the spool.
Updates the first_used and last_used attributes where appropriate. Updates the first_used and last_used attributes where appropriate.
Records the adjustment in the history.
Args: Args:
db (AsyncSession): Database session db (AsyncSession): Database session
spool_id (int): Spool ID 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: Returns:
models.Spool: Updated spool object 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: if (initial_gross_weight - weight_to_use) < spool_weight:
weight_to_use = current_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( async def find_locations(
@@ -475,3 +519,46 @@ async def rename_location(
await db.execute( await db.execute(
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name), 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