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",
"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.",

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
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 { 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<IResourceComponentsProps> = () => {
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<IResourceComponentsProps> = () => {
{extraFields?.data?.map((field, index) => (
<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>
);
};

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):
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