Files
spoolman2/spoolman/database/models.py
tonym 18cafc4361
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
feat: Add extra weight, price tracking, print history, usage analytics
- Extra Weight Field (#14): Track DryPods, custom holders in spool weight
  calculations. New extra_weight field on spool with DB migration.

- Price/Cost Tracking: Compute remaining_value based on remaining weight
  and price. Added column to spool list, inventory value on dashboard.

- Print History: Show print job history on spool detail page with
  collapsible table showing filename, filament used, status, dates.

- Usage Analytics: New dashboard component with time-series chart
  showing daily consumption, period selector (7/30/90 days), and
  material breakdown. New API endpoints for analytics data.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:38:14 -06:00

217 lines
9.5 KiB
Python

"""SQLAlchemy data models."""
from datetime import datetime
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(AsyncAttrs, DeclarativeBase):
pass
class Location(Base):
"""Hierarchical storage location (room, shelf, bin, etc.)."""
__tablename__ = "location"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(64))
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("location.id", ondelete="SET NULL"), index=True)
location_type: Mapped[Optional[str]] = mapped_column(String(32), comment="Type: room, shelf, bin, etc.")
description: Mapped[Optional[str]] = mapped_column(String(256))
# Self-referential relationships
parent: Mapped[Optional["Location"]] = relationship(
"Location",
remote_side="Location.id",
back_populates="children",
)
children: Mapped[list["Location"]] = relationship(
"Location",
back_populates="parent",
)
spools: Mapped[list["Spool"]] = relationship(back_populates="location_obj")
class Vendor(Base):
__tablename__ = "vendor"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
name: Mapped[str] = mapped_column(String(64))
empty_spool_weight: Mapped[Optional[float]] = mapped_column(comment="The weight of an empty spool.")
comment: Mapped[Optional[str]] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
external_id: Mapped[Optional[str]] = mapped_column(String(256))
extra: Mapped[list["VendorField"]] = relationship(
back_populates="vendor",
cascade="save-update, merge, delete, delete-orphan",
lazy="joined",
)
class Filament(Base):
__tablename__ = "filament"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
name: Mapped[Optional[str]] = mapped_column(String(64))
vendor_id: Mapped[Optional[int]] = mapped_column(ForeignKey("vendor.id"))
vendor: Mapped[Optional["Vendor"]] = relationship(back_populates="filaments")
spools: Mapped[list["Spool"]] = relationship(back_populates="filament")
material: Mapped[Optional[str]] = mapped_column(String(64))
price: Mapped[Optional[float]] = mapped_column()
density: Mapped[float] = mapped_column()
diameter: Mapped[float] = mapped_column()
weight: Mapped[Optional[float]] = mapped_column(comment="The filament weight of a full spool (net weight).")
spool_weight: Mapped[Optional[float]] = mapped_column(comment="The weight of an empty spool.")
article_number: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature (deprecated, use min/max).")
settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature (deprecated, use min/max).")
settings_extruder_temp_min: Mapped[Optional[int]] = mapped_column(comment="Minimum extruder temperature.")
settings_extruder_temp_max: Mapped[Optional[int]] = mapped_column(comment="Maximum extruder temperature.")
settings_bed_temp_min: Mapped[Optional[int]] = mapped_column(comment="Minimum bed temperature.")
settings_bed_temp_max: Mapped[Optional[int]] = mapped_column(comment="Maximum bed temperature.")
color_hex: Mapped[Optional[str]] = mapped_column(String(8))
multi_color_hexes: Mapped[Optional[str]] = mapped_column(String(128))
multi_color_direction: Mapped[Optional[str]] = mapped_column(String(16))
external_id: Mapped[Optional[str]] = mapped_column(String(256))
extra: Mapped[list["FilamentField"]] = relationship(
back_populates="filament",
cascade="save-update, merge, delete, delete-orphan",
lazy="joined",
)
class Spool(Base):
__tablename__ = "spool"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
registered: Mapped[datetime] = mapped_column()
first_used: Mapped[Optional[datetime]] = mapped_column()
last_used: Mapped[Optional[datetime]] = mapped_column()
price: Mapped[Optional[float]] = mapped_column()
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"))
filament: Mapped["Filament"] = relationship(back_populates="spools")
initial_weight: Mapped[Optional[float]] = mapped_column()
spool_weight: Mapped[Optional[float]] = mapped_column()
used_weight: Mapped[float] = mapped_column()
extra_weight: Mapped[Optional[float]] = mapped_column(
default=None,
comment="Extra weight to account for (DryPods, custom holders, etc.).",
)
location: Mapped[Optional[str]] = mapped_column(String(64), comment="Legacy flat location string.")
location_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("location.id", ondelete="SET NULL"),
index=True,
comment="FK to hierarchical location.",
)
location_obj: Mapped[Optional["Location"]] = relationship(back_populates="spools")
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column()
needs_weighing: Mapped[Optional[bool]] = mapped_column(
default=False,
comment="Flag indicating spool needs manual weigh-in (e.g., after cancelled print).",
)
extra: Mapped[list["SpoolField"]] = relationship(
back_populates="spool",
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 PrintJob(Base):
"""Track pending/completed print jobs from slicer."""
__tablename__ = "print_job"
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()
created: Mapped[datetime] = mapped_column(comment="When the job was created (slice time).")
finished: Mapped[Optional[datetime]] = mapped_column(comment="When the job was completed/cancelled.")
filename: Mapped[str] = mapped_column(String(256), comment="G-code filename.")
filament_used_g: Mapped[float] = mapped_column(comment="Estimated filament usage in grams.")
filament_used_mm: Mapped[Optional[float]] = mapped_column(comment="Estimated filament usage in mm.")
status: Mapped[str] = mapped_column(
String(16),
default="pending",
comment="Status: 'pending', 'completed', 'cancelled'.",
)
class Setting(Base):
__tablename__ = "setting"
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
last_updated: Mapped[datetime] = mapped_column()
class VendorField(Base):
__tablename__ = "vendor_field"
vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True)
vendor: Mapped["Vendor"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class FilamentField(Base):
__tablename__ = "filament_field"
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True)
filament: Mapped["Filament"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class SpoolField(Base):
__tablename__ = "spool_field"
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True)
spool: Mapped["Spool"] = relationship(back_populates="extra")
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text())
class User(Base):
"""User account for authentication."""
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(
String(16),
default="viewer",
comment="Role: 'admin', 'editor', 'operator', 'viewer'.",
)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column()