From d804329b770c17600720e9dadf77709ea2956ec2 Mon Sep 17 00:00:00 2001 From: tonym Date: Wed, 14 Jan 2026 20:18:56 -0600 Subject: [PATCH] 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 --- client/public/locales/en/common.json | 10 +- client/src/pages/spools/model.tsx | 9 ++ client/src/pages/spools/show.tsx | 70 ++++++++++++- ...c3d4e5f6g7_add_spool_adjustment_history.py | 43 ++++++++ spoolman/api/v1/models.py | 23 +++++ spoolman/api/v1/spool.py | 49 +++++++++- spoolman/database/models.py | 19 ++++ spoolman/database/spool.py | 97 ++++++++++++++++++- 8 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 migrations/versions/2025_01_15_0300-b2c3d4e5f6g7_add_spool_adjustment_history.py diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 9a9017f..8776863 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -176,7 +176,15 @@ "last_used": "Last Used", "registered": "Registered", "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": { "price": "Price of a full spool. If not set, the price of the filament will be assumed instead.", diff --git a/client/src/pages/spools/model.tsx b/client/src/pages/spools/model.tsx index 626afb2..2ecf46f 100644 --- a/client/src/pages/spools/model.tsx +++ b/client/src/pages/spools/model.tsx @@ -28,3 +28,12 @@ export interface ISpool { // ISpoolParsedExtras is the same as ISpool, but with the extra field parsed into its real types export type ISpoolParsedExtras = Omit & { extra?: { [key: string]: unknown } }; + +export interface ISpoolAdjustment { + id: number; + spool_id: number; + timestamp: string; + adjustment_type: "weight" | "length"; + value: number; + comment?: string; +} diff --git a/client/src/pages/spools/show.tsx b/client/src/pages/spools/show.tsx index c9c2450..a62c80e 100644 --- a/client/src/pages/spools/show.tsx +++ b/client/src/pages/spools/show.tsx @@ -1,7 +1,8 @@ import { InboxOutlined, PrinterOutlined, ToTopOutlined, ToolOutlined } from "@ant-design/icons"; import { DateField, NumberField, Show, TextField } from "@refinedev/antd"; 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 utc from "dayjs/plugin/utc"; import React from "react"; @@ -11,10 +12,10 @@ import SpoolIcon from "../../components/spoolIcon"; import { enrichText } from "../../utils/parsing"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { useCurrencyFormatter } from "../../utils/settings"; -import { getBasePath } from "../../utils/url"; +import { getAPIURL, getBasePath } from "../../utils/url"; import { IFilament } from "../filaments/model"; import { setSpoolArchived, useSpoolAdjustModal } from "./functions"; -import { ISpool } from "./model"; +import { ISpool, ISpoolAdjustment } from "./model"; dayjs.extend(utc); @@ -34,6 +35,16 @@ export const SpoolShow: React.FC = () => { 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 price = item?.price ?? item?.filament.price; if (price === undefined) { @@ -224,6 +235,59 @@ export const SpoolShow: React.FC = () => { {extraFields?.data?.map((field, index) => ( ))} + + {t("spool.fields.adjustment_history")} + + + 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) => ( + + {dayjs.utc(value).local().format("YYYY-MM-DD HH:mm:ss")} + + ), + }, + { + 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", + }, + ]} + /> + ), + }, + ]} + /> ); }; diff --git a/migrations/versions/2025_01_15_0300-b2c3d4e5f6g7_add_spool_adjustment_history.py b/migrations/versions/2025_01_15_0300-b2c3d4e5f6g7_add_spool_adjustment_history.py new file mode 100644 index 0000000..eea5fec --- /dev/null +++ b/migrations/versions/2025_01_15_0300-b2c3d4e5f6g7_add_spool_adjustment_history.py @@ -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") diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 3c02b9f..9675c10 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -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]) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 0467ca8..a451c68 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -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)}, + ) diff --git a/spoolman/database/models.py b/spoolman/database/models.py index 2808547..5a49090 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -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): diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index b09177b..677f7a4 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -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