library.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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 FileVariantGroup(Base):
  50. """A set of library files that are the same job sliced for different printers.
  51. Members are peers, not a source/output hierarchy. The group answers one
  52. question — "which of these files goes to an H2S, and which to an H2C" — and
  53. both open features need that answer from opposite ends: the print queue
  54. picks the printer and needs the matching file (#671), the File Manager's
  55. print action has the printer already and needs the same match (#2570).
  56. The group deliberately stores no model information of its own. Each
  57. member's target model comes from its own ``file_metadata['sliced_for_model']``,
  58. parsed out of the 3MF, so a group can never disagree with the files it
  59. contains. It also carries no pointer to an unsliced source file: that is a
  60. display concern for the grouped File Manager listing, which is not built.
  61. Deleting a group ungroups its files rather than deleting them (the member
  62. side is ON DELETE SET NULL) — every member is independently printable.
  63. """
  64. __tablename__ = "file_variant_groups"
  65. id: Mapped[int] = mapped_column(primary_key=True)
  66. name: Mapped[str] = mapped_column(String(255))
  67. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  68. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  69. created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  70. files: Mapped[list["LibraryFile"]] = relationship(
  71. back_populates="variant_group",
  72. order_by="LibraryFile.variant_position",
  73. )
  74. created_by: Mapped["User | None"] = relationship()
  75. class LibraryFile(Base):
  76. """File stored in the library."""
  77. __tablename__ = "library_files"
  78. id: Mapped[int] = mapped_column(primary_key=True)
  79. folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
  80. project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
  81. # External file flag
  82. is_external: Mapped[bool] = mapped_column(Boolean, default=False)
  83. # File info
  84. filename: Mapped[str] = mapped_column(String(255)) # Original filename
  85. file_path: Mapped[str] = mapped_column(String(500)) # Storage path
  86. file_type: Mapped[str] = mapped_column(String(10)) # "3mf" or "gcode"
  87. file_size: Mapped[int] = mapped_column(Integer)
  88. file_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 for duplicate detection
  89. thumbnail_path: Mapped[str | None] = mapped_column(String(500))
  90. # Extracted metadata (from 3MF parser)
  91. file_metadata: Mapped[dict | None] = mapped_column(JSON)
  92. # Usage tracking
  93. print_count: Mapped[int] = mapped_column(Integer, default=0)
  94. last_printed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  95. # User notes
  96. notes: Mapped[str | None] = mapped_column(Text, nullable=True)
  97. # Provenance — when the file was imported from an external source (e.g.
  98. # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
  99. # the canonical public URL. Used for "already imported" detection and
  100. # "re-open on MakerWorld" affordances. Index on source_url so the
  101. # dedupe lookup is O(log N).
  102. source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
  103. source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
  104. # Variant grouping (#671 / #2570). A file belongs to at most one group of
  105. # "same job, sliced for a different printer" siblings. SET NULL on group
  106. # delete: ungrouping must never take the files with it. ``variant_position``
  107. # is the user's priority order within the group — when two printers are idle
  108. # at the same scheduler tick, the lowest position wins, so the pick is
  109. # reproducible instead of depending on which match the scheduler found first.
  110. variant_group_id: Mapped[int | None] = mapped_column(
  111. ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
  112. )
  113. variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
  114. # User's answer to "which printer is this for", for a file that does not say.
  115. # Files imported before Bambuddy parsed ``sliced_for_model`` — and raw .gcode —
  116. # declare nothing, and without this they could never be grouped. Deliberately
  117. # NOT written into ``file_metadata``: that holds what was parsed out of the
  118. # file, and a user's assertion must not become indistinguishable from it.
  119. variant_target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
  120. # User tracking (Issue #206)
  121. created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  122. # Soft-delete / trash bin (Issue #1008). When non-null, the file is in the
  123. # trash and should not appear in normal listings. A background sweeper
  124. # hard-deletes rows whose deleted_at is older than the retention window.
  125. deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
  126. # Timestamps
  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. # Real on-disk modification time of the file (#2680). Captured from
  130. # ``os.stat().st_mtime`` for external files on scan so the file pane's date
  131. # sort and the folder tree's recursive "recent activity" bubble reflect the
  132. # actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
  133. # scan instant, identical across a bulk scan). Null for managed uploads —
  134. # callers fall back to ``created_at``.
  135. fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  136. # Relationships
  137. folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
  138. project: Mapped["Project | None"] = relationship()
  139. created_by: Mapped["User | None"] = relationship()
  140. variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
  141. # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
  142. # ``selectinload`` in list_files so each row in the listing carries its
  143. # chip set without N+1 fetches.
  144. tags: Mapped[list["LibraryTag"]] = relationship(
  145. secondary="library_file_tags",
  146. back_populates="files",
  147. )
  148. @classmethod
  149. def active(cls) -> "Select[tuple[LibraryFile]]":
  150. """Select statement that excludes trashed (soft-deleted) files.
  151. Use this in place of ``select(LibraryFile)`` for any user-facing listing
  152. or lookup so trashed files don't leak into normal flows. Endpoints that
  153. specifically operate on trashed rows (trash list, restore, sweeper)
  154. must use ``select(LibraryFile)`` directly.
  155. """
  156. return select(cls).where(cls.deleted_at.is_(None))
  157. class LibraryTag(Base):
  158. """User-authored cross-cutting label for library files (#1268).
  159. Folders express hierarchy; tags express orthogonal attributes ("toy",
  160. "kid-safe", "petg-only"). Catalog is global (one tag set per install)
  161. — the multi-user "private tags" case is not in v1 scope. ``name_key``
  162. is ``LOWER(TRIM(name))`` so "Toys" / "toys" / " TOYS " all collide
  163. on the UNIQUE index and the route returns 409 instead of silently
  164. creating a duplicate.
  165. """
  166. __tablename__ = "library_tags"
  167. id: Mapped[int] = mapped_column(primary_key=True)
  168. name: Mapped[str] = mapped_column(String(64), nullable=False)
  169. name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
  170. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  171. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  172. files: Mapped[list["LibraryFile"]] = relationship(
  173. secondary="library_file_tags",
  174. back_populates="tags",
  175. )
  176. class LibraryFileTag(Base):
  177. """Association between library files and tags (#1268).
  178. Composite PK so the same (file, tag) pair can't be inserted twice. Both
  179. sides ON DELETE CASCADE: deleting a tag drops every association row,
  180. deleting a file drops its tag links, and the catalog row survives so
  181. other files keep their chip.
  182. """
  183. __tablename__ = "library_file_tags"
  184. file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
  185. tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
  186. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  187. from backend.app.models.archive import PrintArchive # noqa: E402, F811
  188. from backend.app.models.project import Project # noqa: E402, F811
  189. from backend.app.models.user import User # noqa: E402, F811