library.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. """Library models for file manager functionality."""
  2. from datetime import datetime
  3. from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Select, String, Text, func, select
  4. from sqlalchemy.orm import Mapped, mapped_column, relationship
  5. from backend.app.core.database import Base
  6. class LibraryFolder(Base):
  7. """Folder for organizing library files."""
  8. __tablename__ = "library_folders"
  9. id: Mapped[int] = mapped_column(primary_key=True)
  10. name: Mapped[str] = mapped_column(String(255))
  11. parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
  12. # External folder flags (for folders that point to external paths)
  13. is_external: Mapped[bool] = mapped_column(Boolean, default=False)
  14. external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)
  15. external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
  16. external_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
  17. # Link to project or archive
  18. project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
  19. archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True)
  20. # Timestamps
  21. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  22. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  23. # Real on-disk modification time of the directory this folder mirrors (#2680).
  24. # For external folders this is captured from ``os.stat().st_mtime`` on scan so
  25. # the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
  26. # the DB row's ``updated_at`` (which is the scan instant, identical for every
  27. # row of a bulk scan). Null for managed (internal) folders, which have no
  28. # meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
  29. fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  30. # Relationships
  31. parent: Mapped["LibraryFolder | None"] = relationship(
  32. "LibraryFolder",
  33. back_populates="children",
  34. remote_side="LibraryFolder.id",
  35. foreign_keys="LibraryFolder.parent_id",
  36. )
  37. children: Mapped[list["LibraryFolder"]] = relationship(
  38. "LibraryFolder",
  39. back_populates="parent",
  40. foreign_keys="LibraryFolder.parent_id",
  41. cascade="all, delete-orphan",
  42. )
  43. files: Mapped[list["LibraryFile"]] = relationship(
  44. back_populates="folder",
  45. cascade="all, delete-orphan",
  46. )
  47. project: Mapped["Project | None"] = relationship()
  48. archive: Mapped["PrintArchive | None"] = relationship()
  49. class LibraryFile(Base):
  50. """File stored in the library."""
  51. __tablename__ = "library_files"
  52. id: Mapped[int] = mapped_column(primary_key=True)
  53. folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
  54. project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
  55. # External file flag
  56. is_external: Mapped[bool] = mapped_column(Boolean, default=False)
  57. # File info
  58. filename: Mapped[str] = mapped_column(String(255)) # Original filename
  59. file_path: Mapped[str] = mapped_column(String(500)) # Storage path
  60. file_type: Mapped[str] = mapped_column(String(10)) # "3mf" or "gcode"
  61. file_size: Mapped[int] = mapped_column(Integer)
  62. file_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 for duplicate detection
  63. thumbnail_path: Mapped[str | None] = mapped_column(String(500))
  64. # Extracted metadata (from 3MF parser)
  65. file_metadata: Mapped[dict | None] = mapped_column(JSON)
  66. # Usage tracking
  67. print_count: Mapped[int] = mapped_column(Integer, default=0)
  68. last_printed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  69. # User notes
  70. notes: Mapped[str | None] = mapped_column(Text, nullable=True)
  71. # Provenance — when the file was imported from an external source (e.g.
  72. # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
  73. # the canonical public URL. Used for "already imported" detection and
  74. # "re-open on MakerWorld" affordances. Index on source_url so the
  75. # dedupe lookup is O(log N).
  76. source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
  77. source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
  78. # User tracking (Issue #206)
  79. created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  80. # Soft-delete / trash bin (Issue #1008). When non-null, the file is in the
  81. # trash and should not appear in normal listings. A background sweeper
  82. # hard-deletes rows whose deleted_at is older than the retention window.
  83. deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
  84. # Timestamps
  85. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  86. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  87. # Real on-disk modification time of the file (#2680). Captured from
  88. # ``os.stat().st_mtime`` for external files on scan so the file pane's date
  89. # sort and the folder tree's recursive "recent activity" bubble reflect the
  90. # actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
  91. # scan instant, identical across a bulk scan). Null for managed uploads —
  92. # callers fall back to ``created_at``.
  93. fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  94. # Relationships
  95. folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
  96. project: Mapped["Project | None"] = relationship()
  97. created_by: Mapped["User | None"] = relationship()
  98. # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
  99. # ``selectinload`` in list_files so each row in the listing carries its
  100. # chip set without N+1 fetches.
  101. tags: Mapped[list["LibraryTag"]] = relationship(
  102. secondary="library_file_tags",
  103. back_populates="files",
  104. )
  105. @classmethod
  106. def active(cls) -> "Select[tuple[LibraryFile]]":
  107. """Select statement that excludes trashed (soft-deleted) files.
  108. Use this in place of ``select(LibraryFile)`` for any user-facing listing
  109. or lookup so trashed files don't leak into normal flows. Endpoints that
  110. specifically operate on trashed rows (trash list, restore, sweeper)
  111. must use ``select(LibraryFile)`` directly.
  112. """
  113. return select(cls).where(cls.deleted_at.is_(None))
  114. class LibraryTag(Base):
  115. """User-authored cross-cutting label for library files (#1268).
  116. Folders express hierarchy; tags express orthogonal attributes ("toy",
  117. "kid-safe", "petg-only"). Catalog is global (one tag set per install)
  118. — the multi-user "private tags" case is not in v1 scope. ``name_key``
  119. is ``LOWER(TRIM(name))`` so "Toys" / "toys" / " TOYS " all collide
  120. on the UNIQUE index and the route returns 409 instead of silently
  121. creating a duplicate.
  122. """
  123. __tablename__ = "library_tags"
  124. id: Mapped[int] = mapped_column(primary_key=True)
  125. name: Mapped[str] = mapped_column(String(64), nullable=False)
  126. name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
  127. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  128. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  129. files: Mapped[list["LibraryFile"]] = relationship(
  130. secondary="library_file_tags",
  131. back_populates="tags",
  132. )
  133. class LibraryFileTag(Base):
  134. """Association between library files and tags (#1268).
  135. Composite PK so the same (file, tag) pair can't be inserted twice. Both
  136. sides ON DELETE CASCADE: deleting a tag drops every association row,
  137. deleting a file drops its tag links, and the catalog row survives so
  138. other files keep their chip.
  139. """
  140. __tablename__ = "library_file_tags"
  141. file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
  142. tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
  143. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  144. from backend.app.models.archive import PrintArchive # noqa: E402, F811
  145. from backend.app.models.project import Project # noqa: E402, F811
  146. from backend.app.models.user import User # noqa: E402, F811