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:
@@ -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.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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])
|
||||||
|
|||||||
@@ -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)},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user