library.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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. # Relationships
  24. parent: Mapped["LibraryFolder | None"] = relationship(
  25. "LibraryFolder",
  26. back_populates="children",
  27. remote_side="LibraryFolder.id",
  28. foreign_keys="LibraryFolder.parent_id",
  29. )
  30. children: Mapped[list["LibraryFolder"]] = relationship(
  31. "LibraryFolder",
  32. back_populates="parent",
  33. foreign_keys="LibraryFolder.parent_id",
  34. cascade="all, delete-orphan",
  35. )
  36. files: Mapped[list["LibraryFile"]] = relationship(
  37. back_populates="folder",
  38. cascade="all, delete-orphan",
  39. )
  40. project: Mapped["Project | None"] = relationship()
  41. archive: Mapped["PrintArchive | None"] = relationship()
  42. class LibraryFile(Base):
  43. """File stored in the library."""
  44. __tablename__ = "library_files"
  45. id: Mapped[int] = mapped_column(primary_key=True)
  46. folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
  47. project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
  48. # External file flag
  49. is_external: Mapped[bool] = mapped_column(Boolean, default=False)
  50. # File info
  51. filename: Mapped[str] = mapped_column(String(255)) # Original filename
  52. file_path: Mapped[str] = mapped_column(String(500)) # Storage path
  53. file_type: Mapped[str] = mapped_column(String(10)) # "3mf" or "gcode"
  54. file_size: Mapped[int] = mapped_column(Integer)
  55. file_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 for duplicate detection
  56. thumbnail_path: Mapped[str | None] = mapped_column(String(500))
  57. # Extracted metadata (from 3MF parser)
  58. file_metadata: Mapped[dict | None] = mapped_column(JSON)
  59. # Usage tracking
  60. print_count: Mapped[int] = mapped_column(Integer, default=0)
  61. last_printed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  62. # User notes
  63. notes: Mapped[str | None] = mapped_column(Text, nullable=True)
  64. # Provenance — when the file was imported from an external source (e.g.
  65. # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
  66. # the canonical public URL. Used for "already imported" detection and
  67. # "re-open on MakerWorld" affordances. Index on source_url so the
  68. # dedupe lookup is O(log N).
  69. source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
  70. source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
  71. # User tracking (Issue #206)
  72. created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  73. # Soft-delete / trash bin (Issue #1008). When non-null, the file is in the
  74. # trash and should not appear in normal listings. A background sweeper
  75. # hard-deletes rows whose deleted_at is older than the retention window.
  76. deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
  77. # Timestamps
  78. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  79. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  80. # Relationships
  81. folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
  82. project: Mapped["Project | None"] = relationship()
  83. created_by: Mapped["User | None"] = relationship()
  84. # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
  85. # ``selectinload`` in list_files so each row in the listing carries its
  86. # chip set without N+1 fetches.
  87. tags: Mapped[list["LibraryTag"]] = relationship(
  88. secondary="library_file_tags",
  89. back_populates="files",
  90. )
  91. @classmethod
  92. def active(cls) -> "Select[tuple[LibraryFile]]":
  93. """Select statement that excludes trashed (soft-deleted) files.
  94. Use this in place of ``select(LibraryFile)`` for any user-facing listing
  95. or lookup so trashed files don't leak into normal flows. Endpoints that
  96. specifically operate on trashed rows (trash list, restore, sweeper)
  97. must use ``select(LibraryFile)`` directly.
  98. """
  99. return select(cls).where(cls.deleted_at.is_(None))
  100. class LibraryTag(Base):
  101. """User-authored cross-cutting label for library files (#1268).
  102. Folders express hierarchy; tags express orthogonal attributes ("toy",
  103. "kid-safe", "petg-only"). Catalog is global (one tag set per install)
  104. — the multi-user "private tags" case is not in v1 scope. ``name_key``
  105. is ``LOWER(TRIM(name))`` so "Toys" / "toys" / " TOYS " all collide
  106. on the UNIQUE index and the route returns 409 instead of silently
  107. creating a duplicate.
  108. """
  109. __tablename__ = "library_tags"
  110. id: Mapped[int] = mapped_column(primary_key=True)
  111. name: Mapped[str] = mapped_column(String(64), nullable=False)
  112. name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
  113. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  114. updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
  115. files: Mapped[list["LibraryFile"]] = relationship(
  116. secondary="library_file_tags",
  117. back_populates="tags",
  118. )
  119. class LibraryFileTag(Base):
  120. """Association between library files and tags (#1268).
  121. Composite PK so the same (file, tag) pair can't be inserted twice. Both
  122. sides ON DELETE CASCADE: deleting a tag drops every association row,
  123. deleting a file drops its tag links, and the catalog row survives so
  124. other files keep their chip.
  125. """
  126. __tablename__ = "library_file_tags"
  127. file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
  128. tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
  129. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  130. from backend.app.models.archive import PrintArchive # noqa: E402, F811
  131. from backend.app.models.project import Project # noqa: E402, F811
  132. from backend.app.models.user import User # noqa: E402, F811