mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Third and final piece of #1268, alongside the recursive-search + README-panel commit that landed earlier in 0.2.5b1. Folders express hierarchy (one home per file); tags are orthogonal labels — "toy", "kid-safe", "petg-only" — and a single file can carry as many as the user wants. Reporter wanted to find "every toy regardless of which folder it lives in"; folders alone can't do that without forcing the file into one bucket. Design decisions locked with maziggy before code: - file-only (folders already express hierarchy) - multi-tag filter = AND - tag filter IGNORES the selected folder (cross-cutting by design) - bulk-tagging from multi-select toolbar in v1 - no auto-tags from 3MF metadata (user-authored only) - label-only chips, no color/icon Backend - LibraryTag (id, name, name_key UNIQUE = LOWER(TRIM(name))) in backend/app/models/library.py. Case-insensitive UNIQUE collapses "Toys"/"toys"/"TOYS " into one row, so the route returns 409 instead of silently fragmenting the catalog. - LibraryFileTag(file_id, tag_id) association, composite PK, ON DELETE CASCADE both directions. Deleting a tag drops every chip; files survive. Deleting a file drops its tag links; the catalog row survives. - Both tables auto-create via Base.metadata.create_all — no explicit run_migrations step needed for new tables. - New router at backend/app/api/routes/library_tags.py with: GET /library/tags (list + per-tag file_count) POST /library/tags (create, 409 on case-insensitive dup) PATCH /library/tags/{id} (rename, 409 on collision, self-rename OK) DELETE /library/tags/{id} (cascade) POST /library/tags/bulk-assign (add | remove | replace) - Bulk-assign add is idempotent; replace with empty tag_ids clears the file's tag set. Per-file ownership enforced — *_OWN callers can only modify their own files; unknown file_ids quietly skipped (matches library_trash bulk shape). - list_files gains tag_ids: list[int] query param. AND semantics via JOIN + GROUP BY + HAVING COUNT(DISTINCT) — portable across SQLite and Postgres. When tag_ids is non-empty, folder_id / project_id / include_root / recursive are all bypassed so the result is cross-cutting. - FileListResponse gains tags: list[{id, name}] via selectinload(LibraryFile.tags) — N+1-free chip render. - Permissions reuse existing constants: LIBRARY_UPDATE_ALL for catalog mutations (global catalog, ownership-aware update isn't meaningful), LIBRARY_UPDATE_ALL/OWN pair for bulk-assign, LIBRARY_READ_ALL/OWN for list — file_count projection narrows for *_OWN callers so chip counts match what they actually see. Frontend - LibraryTagsModal (catalog CRUD) opens from the toolbar's new Tags button. max-w-4xl so multi-language subtitles don't wrap. Delete-with-warning when file_count > 0 ("removes the chip from all of them; files themselves are untouched"). - BulkTagsPickerModal opens from the multi-select toolbar (new Tag button between Move and Delete). Add/Remove radio, checkbox list, inline "create new tag" disabled on dup. Apply disabled until at least one tag is selected. The replace action is exposed in the API but deliberately NOT in this UI — arbitrary multi-file replace is destructive and confusing. - FileManagerPage integration: * selectedTagIds state, sorted into the useQuery key so the cache hits are stable regardless of toggle order * filter rail above the file list lists EVERY catalog tag as a togglable chip — inactive outlined, active filled green with an X. Clear all when 1+ active. Bar hidden entirely when catalog is empty. * useEffect prunes selectedTagIds when a tag is deleted from the catalog so the filter never strands on a phantom id * dedicated Tags column in list view at minmax(0,200px) between Prints and Actions * grid view chips render below the metadata block * chip clicks stop propagation so they don't toggle file selection - libraryTagsQueryKey extracted to frontend/src/utils/ libraryTagsQuery.ts so component files export only components (Vite react-refresh rule). - LibraryFileListItem.tags is OPTIONAL even though the backend always emits an empty array — legacy msw mocks in pre-existing tests construct partial file shapes without the field. Without the ? the FileCard renderer crashed on .length and broke 49 unrelated tests across FileManagerPage + FileManagerExternalFolder. Read sites use file.tags ?? [].
173 lines
7.5 KiB
Python
173 lines
7.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()
|
|
# Tags (#1268). M2M via library_file_tags. Loaded explicitly via
|
|
# ``selectinload`` in list_files so each row in the listing carries its
|
|
# chip set without N+1 fetches.
|
|
tags: Mapped[list["LibraryTag"]] = relationship(
|
|
secondary="library_file_tags",
|
|
back_populates="files",
|
|
)
|
|
|
|
@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))
|
|
|
|
|
|
class LibraryTag(Base):
|
|
"""User-authored cross-cutting label for library files (#1268).
|
|
|
|
Folders express hierarchy; tags express orthogonal attributes ("toy",
|
|
"kid-safe", "petg-only"). Catalog is global (one tag set per install)
|
|
— the multi-user "private tags" case is not in v1 scope. ``name_key``
|
|
is ``LOWER(TRIM(name))`` so "Toys" / "toys" / " TOYS " all collide
|
|
on the UNIQUE index and the route returns 409 instead of silently
|
|
creating a duplicate.
|
|
"""
|
|
|
|
__tablename__ = "library_tags"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
|
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())
|
|
|
|
files: Mapped[list["LibraryFile"]] = relationship(
|
|
secondary="library_file_tags",
|
|
back_populates="tags",
|
|
)
|
|
|
|
|
|
class LibraryFileTag(Base):
|
|
"""Association between library files and tags (#1268).
|
|
|
|
Composite PK so the same (file, tag) pair can't be inserted twice. Both
|
|
sides ON DELETE CASCADE: deleting a tag drops every association row,
|
|
deleting a file drops its tag links, and the catalog row survives so
|
|
other files keep their chip.
|
|
"""
|
|
|
|
__tablename__ = "library_file_tags"
|
|
|
|
file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
|
|
tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
|
|
|
|
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
|