Files
bambuddy/backend/app/models/library.py
T
maziggy e0e597271e ● feat(#1008): library trash bin, admin bulk purge, auto-purge setting
Library files now move to a configurable-retention trash bin on delete
  instead of being hard-deleted from disk (default 30 days). Admins get a
  "Purge old" bulk action on the File Manager with a live preview, plus an
  optional auto-purge setting in Settings → File Manager that runs the same
  operation once per 24h when enabled (default off). Regular users see and
  manage their own trashed files; admins see everyone's. External (linked)
  files bypass trash since their bytes aren't under Bambuddy's control.

  - New `library:purge` permission (admin-only by default)
  - Nullable indexed `deleted_at` column on library_files; dialect-aware
    ALTER TABLE so the column actually gets added on PostgreSQL (raw
    DATETIME is SQLite-only syntax)
  - New `LibraryFile.active()` classmethod; every query site routed through
    it so trashed rows don't leak into listings, print dispatch, MakerWorld
    dedupe, or stats
  - Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
    layout so datetime columns don't clip
  - Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
    the 15-minute sweeper cadence still runs the purge at most once per day
  - Save toast wired into every trash/auto-purge setting change
  - 17 new backend integration tests (service + routes + auto-purge throttle),
    8 new frontend tests, localised across all 8 UI languages
  - Wiki + website feature entries updated
2026-04-23 15:54:59 +02:00

125 lines
5.5 KiB
Python

"""Library models for file manager functionality."""
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Select, String, Text, func, select
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class LibraryFolder(Base):
"""Folder for organizing library files."""
__tablename__ = "library_folders"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255))
parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
# External folder flags (for folders that point to external paths)
is_external: Mapped[bool] = mapped_column(Boolean, default=False)
external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)
external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Link to project or archive
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
parent: Mapped["LibraryFolder | None"] = relationship(
"LibraryFolder",
back_populates="children",
remote_side="LibraryFolder.id",
foreign_keys="LibraryFolder.parent_id",
)
children: Mapped[list["LibraryFolder"]] = relationship(
"LibraryFolder",
back_populates="parent",
foreign_keys="LibraryFolder.parent_id",
cascade="all, delete-orphan",
)
files: Mapped[list["LibraryFile"]] = relationship(
back_populates="folder",
cascade="all, delete-orphan",
)
project: Mapped["Project | None"] = relationship()
archive: Mapped["PrintArchive | None"] = relationship()
class LibraryFile(Base):
"""File stored in the library."""
__tablename__ = "library_files"
id: Mapped[int] = mapped_column(primary_key=True)
folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
# External file flag
is_external: Mapped[bool] = mapped_column(Boolean, default=False)
# File info
filename: Mapped[str] = mapped_column(String(255)) # Original filename
file_path: Mapped[str] = mapped_column(String(500)) # Storage path
file_type: Mapped[str] = mapped_column(String(10)) # "3mf" or "gcode"
file_size: Mapped[int] = mapped_column(Integer)
file_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 for duplicate detection
thumbnail_path: Mapped[str | None] = mapped_column(String(500))
# Extracted metadata (from 3MF parser)
file_metadata: Mapped[dict | None] = mapped_column(JSON)
# Usage tracking
print_count: Mapped[int] = mapped_column(Integer, default=0)
last_printed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# User notes
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Provenance — when the file was imported from an external source (e.g.
# MakerWorld), ``source_type`` identifies the source and ``source_url`` is
# the canonical public URL. Used for "already imported" detection and
# "re-open on MakerWorld" affordances. Index on source_url so the
# dedupe lookup is O(log N).
source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
# User tracking (Issue #206)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Soft-delete / trash bin (Issue #1008). When non-null, the file is in the
# trash and should not appear in normal listings. A background sweeper
# hard-deletes rows whose deleted_at is older than the retention window.
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationships
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
project: Mapped["Project | None"] = relationship()
created_by: Mapped["User | None"] = relationship()
@classmethod
def active(cls) -> "Select[tuple[LibraryFile]]":
"""Select statement that excludes trashed (soft-deleted) files.
Use this in place of ``select(LibraryFile)`` for any user-facing listing
or lookup so trashed files don't leak into normal flows. Endpoints that
specifically operate on trashed rows (trash list, restore, sweeper)
must use ``select(LibraryFile)`` directly.
"""
return select(cls).where(cls.deleted_at.is_(None))
from backend.app.models.archive import PrintArchive # noqa: E402, F811
from backend.app.models.project import Project # noqa: E402, F811
from backend.app.models.user import User # noqa: E402, F811