Files
spoolman2/spoolman/database/models.py
tonym 3613e7739a
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(auth): Add optional authentication system (#22)
Backend:
- Add User model with username, hashed password, role, is_active
- Add passlib[bcrypt] and python-jose[cryptography] dependencies
- Create auth.py with JWT utilities, password hashing, role checking
- Add /auth endpoints: status, setup, login, me, users CRUD
- Add role-based permission system (viewer/operator/editor/admin)
- Environment: SPOOLMAN_AUTH_ENABLED, SPOOLMAN_AUTH_SECRET_KEY
- Migration for user table

Frontend:
- Add AuthProvider context with login/logout/setup
- Add LoginPage component with setup mode for first user
- Add ProtectedRoute wrapper for auth-required pages
- Add axios interceptor to attach JWT token to requests
- Add User Management tab in Settings (admin only)
- Add translation keys for auth UI

When SPOOLMAN_AUTH_ENABLED=true:
- Users must login to access the app
- First visit shows setup page to create admin account
- Role-based access control for future endpoint protection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:15:36 -06:00

183 lines
8.1 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 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()
location: Mapped[Optional[str]] = mapped_column(String(64))
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()