library.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. """Pydantic schemas for library (File Manager) functionality."""
  2. from datetime import datetime
  3. from pydantic import BaseModel, Field, field_validator
  4. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  5. # ============ Folder Schemas ============
  6. class FolderCreate(BaseModel):
  7. """Schema for creating a new folder."""
  8. name: str = Field(..., min_length=1, max_length=255)
  9. parent_id: int | None = None
  10. project_id: int | None = None
  11. archive_id: int | None = None
  12. class ExternalFolderCreate(BaseModel):
  13. """Schema for linking an external folder."""
  14. name: str = Field(..., min_length=1, max_length=255)
  15. external_path: str = Field(..., min_length=1, max_length=500)
  16. readonly: bool = True
  17. show_hidden: bool = False
  18. parent_id: int | None = None
  19. class FolderUpdate(IgnoresNullForRequiredColumns):
  20. """Schema for updating a folder."""
  21. __orm_table__ = "library_folders"
  22. name: str | None = Field(None, min_length=1, max_length=255)
  23. parent_id: int | None = None
  24. project_id: int | None = None # 0 to unlink
  25. archive_id: int | None = None # 0 to unlink
  26. # Visible to (and writable by) every library:read_own user (#3201).
  27. # Only library:update_all may change it.
  28. shared: bool | None = None
  29. class FolderResponse(BaseModel):
  30. """Schema for folder response."""
  31. id: int
  32. name: str
  33. parent_id: int | None
  34. project_id: int | None = None
  35. archive_id: int | None = None
  36. project_name: str | None = None
  37. archive_name: str | None = None
  38. is_external: bool = False
  39. external_path: str | None = None
  40. external_readonly: bool = False
  41. external_show_hidden: bool = False
  42. file_count: int = 0 # Computed field
  43. # max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
  44. # File Manager folder tree's "sort by recent activity" mode (#1770) so that
  45. # adding a file inside a folder bubbles it up — folder.updated_at alone only
  46. # tracks rename/move events. Recursion across subfolders is intentionally
  47. # left out to keep the route a single GROUP BY rather than a recursive CTE.
  48. latest_activity_at: datetime | None = None
  49. # Ownership (#3201). ``can_*`` are for the user asking: what the File
  50. # Manager may offer on this folder. The routes enforce the same rules.
  51. created_by_id: int | None = None
  52. shared: bool = False
  53. can_write: bool = True
  54. can_rename: bool = True
  55. can_delete: bool = True
  56. created_at: datetime
  57. updated_at: datetime
  58. class Config:
  59. from_attributes = True
  60. class FolderReadmeResponse(BaseModel):
  61. """Markdown sidebar payload for a folder (#1268).
  62. ``filename`` is the on-disk name (so the UI can show "README.md") and
  63. ``content`` is the raw markdown — the FE renders it. ``truncated`` is
  64. True when the source file was clipped at the size cap.
  65. """
  66. filename: str
  67. content: str
  68. truncated: bool
  69. class FolderTreeItem(BaseModel):
  70. """Schema for folder tree item (includes children)."""
  71. id: int
  72. name: str
  73. parent_id: int | None
  74. project_id: int | None = None
  75. archive_id: int | None = None
  76. project_name: str | None = None
  77. archive_name: str | None = None
  78. is_external: bool = False
  79. external_path: str | None = None
  80. external_readonly: bool = False
  81. file_count: int = 0
  82. # See FolderResponse.latest_activity_at — #1770 folder sort source.
  83. latest_activity_at: datetime | None = None
  84. # Ownership (#3201). ``can_*`` are for the user asking: what the File
  85. # Manager may offer on this folder. The routes enforce the same rules.
  86. created_by_id: int | None = None
  87. shared: bool = False
  88. can_write: bool = True
  89. can_rename: bool = True
  90. can_delete: bool = True
  91. children: list["FolderTreeItem"] = []
  92. class Config:
  93. from_attributes = True
  94. # ============ File Schemas ============
  95. class FileUpdate(IgnoresNullForRequiredColumns):
  96. """Schema for updating a file."""
  97. __orm_table__ = "library_files"
  98. filename: str | None = Field(None, min_length=1, max_length=255)
  99. folder_id: int | None = None
  100. project_id: int | None = None
  101. notes: str | None = None
  102. # Empty string clears the link, like ``notes`` (#3077).
  103. external_url: str | None = Field(None, max_length=500)
  104. @field_validator("external_url")
  105. @classmethod
  106. def validate_external_url(cls, v: str | None) -> str | None:
  107. # The link is rendered as an href for every reader of the library, so
  108. # only web URLs are accepted (no javascript:/data: schemes).
  109. if v is None:
  110. return None
  111. v = v.strip()
  112. if v and not v.lower().startswith(("http://", "https://")):
  113. raise ValueError("external_url must start with http:// or https://")
  114. return v
  115. class FileDuplicate(BaseModel):
  116. """Reference to a duplicate file."""
  117. id: int
  118. filename: str
  119. folder_id: int | None
  120. folder_name: str | None
  121. created_at: datetime
  122. class FileResponse(BaseModel):
  123. """Schema for file response."""
  124. id: int
  125. folder_id: int | None
  126. folder_name: str | None = None
  127. project_id: int | None
  128. project_name: str | None = None
  129. is_external: bool = False
  130. filename: str
  131. file_path: str
  132. file_type: str
  133. file_size: int
  134. file_hash: str | None
  135. thumbnail_path: str | None
  136. metadata: dict | None
  137. print_count: int
  138. last_printed_at: datetime | None
  139. notes: str | None
  140. # User link + photos of the printed result (#3077); ``source_url`` is the
  141. # read-only import provenance (MakerWorld) shown next to it.
  142. external_url: str | None = None
  143. photos: list[str] = []
  144. source_url: str | None = None
  145. # Duplicate detection
  146. duplicates: list[FileDuplicate] | None = None
  147. duplicate_count: int = 0
  148. # User tracking (Issue #206)
  149. created_by_id: int | None = None
  150. created_by_username: str | None = None
  151. created_at: datetime
  152. updated_at: datetime
  153. # Metadata fields
  154. print_name: str | None = None
  155. print_time_seconds: int | None = None
  156. filament_used_grams: float | None = None
  157. sliced_for_model: str | None = None
  158. class Config:
  159. from_attributes = True
  160. class TagSummary(BaseModel):
  161. """Compact tag projection — embedded in file listings (#1268)."""
  162. id: int
  163. name: str
  164. class Config:
  165. from_attributes = True
  166. class FileListResponse(BaseModel):
  167. """Schema for file list item (lighter than full response)."""
  168. id: int
  169. folder_id: int | None
  170. is_external: bool = False
  171. filename: str
  172. file_type: str
  173. file_size: int
  174. thumbnail_path: str | None
  175. print_count: int
  176. duplicate_count: int = 0
  177. # User tracking (Issue #206)
  178. created_by_id: int | None = None
  179. created_by_username: str | None = None
  180. created_at: datetime
  181. # Real on-disk modification time (#2680). Populated for external files from
  182. # their filesystem mtime; null for managed uploads. The file pane's date sort
  183. # and the "Modified" column use ``fs_modified_at ?? created_at``.
  184. fs_modified_at: datetime | None = None
  185. # Key metadata fields for display
  186. print_name: str | None = None
  187. print_time_seconds: int | None = None
  188. filament_used_grams: float | None = None
  189. sliced_for_model: str | None = None
  190. # Tags assigned to this file (#1268). Empty list when the file has none —
  191. # never null, so the FE can iterate without a guard.
  192. tags: list[TagSummary] = []
  193. # Variant grouping (#671 / #2570). ``variant_count`` is the size of the whole
  194. # group, not of the current listing — members can live in different folders,
  195. # so counting the rows on screen would under-report. Projected in the list
  196. # query so the badge and the smart-print decision cost no extra request.
  197. variant_group_id: int | None = None
  198. variant_count: int = 0
  199. # Metadata indicators (#3077). The list never ships the notes text itself —
  200. # ``has_notes`` is enough for the card badge; the details modal loads the rest.
  201. external_url: str | None = None
  202. has_notes: bool = False
  203. photo_count: int = 0
  204. class Config:
  205. from_attributes = True
  206. # ============ Tag Schemas (#1268) ============
  207. class TagResponse(BaseModel):
  208. """Tag with the count of files currently using it."""
  209. id: int
  210. name: str
  211. file_count: int
  212. created_at: datetime
  213. updated_at: datetime
  214. class Config:
  215. from_attributes = True
  216. class TagCreate(BaseModel):
  217. """Create a new tag (catalog row)."""
  218. name: str = Field(..., min_length=1, max_length=64)
  219. class TagUpdate(IgnoresNullForRequiredColumns):
  220. """Rename a tag. ``name`` is required — there's nothing else to update."""
  221. __orm_table__ = "library_tags"
  222. name: str = Field(..., min_length=1, max_length=64)
  223. class TagBulkAssignRequest(BaseModel):
  224. """Bulk tag assignment payload.
  225. ``action='add'`` → append tags to every listed file (idempotent on dup).
  226. ``action='remove'`` → strip the listed tags from every listed file.
  227. ``action='replace'`` → REPLACE the tag set on every listed file with the
  228. exact set in ``tag_ids`` (omitting tag_ids clears
  229. them all).
  230. """
  231. file_ids: list[int] = Field(..., min_length=1)
  232. tag_ids: list[int] = Field(default_factory=list)
  233. action: str = Field("add", pattern="^(add|remove|replace)$")
  234. class TagBulkAssignResponse(BaseModel):
  235. """Result of a bulk-assign call."""
  236. files_updated: int
  237. associations_added: int
  238. associations_removed: int
  239. class FileMoveRequest(BaseModel):
  240. """Schema for moving files to a folder."""
  241. file_ids: list[int]
  242. folder_id: int | None = None # None = move to root
  243. class FileUploadResponse(BaseModel):
  244. """Schema for file upload response."""
  245. id: int
  246. filename: str
  247. file_type: str
  248. file_size: int
  249. thumbnail_path: str | None
  250. duplicate_of: int | None = None # ID of existing file with same hash
  251. metadata: dict | None = None
  252. # ============ Combine ============
  253. class CombineItem(BaseModel):
  254. """One source model in a combine request."""
  255. file_id: int
  256. copies: int = Field(default=1, ge=1, le=100)
  257. class CombineFilesRequest(BaseModel):
  258. """Combine STL library files into one multi-object 3MF on a single plate."""
  259. items: list[CombineItem] = Field(..., min_length=1, max_length=100)
  260. # Name of the new library file; ``.3mf`` is appended when missing.
  261. filename: str = Field(..., min_length=1, max_length=255)
  262. # Destination folder; None = library root.
  263. folder_id: int | None = None
  264. # ============ Bulk Operations ============
  265. class BulkDeleteRequest(BaseModel):
  266. """Schema for bulk delete operations."""
  267. file_ids: list[int] = []
  268. folder_ids: list[int] = []
  269. class BulkDeleteResponse(BaseModel):
  270. """Schema for bulk delete response."""
  271. deleted_files: int
  272. deleted_folders: int
  273. # ============ Queue Operations ============
  274. class AddToQueueRequest(BaseModel):
  275. """Schema for adding library files to the print queue."""
  276. file_ids: list[int] = Field(..., min_length=1)
  277. # Where the items should go. Mutually exclusive, both optional. With
  278. # neither, each file's own declared model is used when a printer of that
  279. # model is active: an item carrying no printer and no target model matches
  280. # neither branch of the scheduler's dispatch, so it is one nothing can ever
  281. # pick up (#3112).
  282. printer_id: int | None = None
  283. target_model: str | None = None
  284. class AddToQueueResult(BaseModel):
  285. """Result for a single file added to queue."""
  286. file_id: int
  287. filename: str
  288. queue_item_id: int
  289. class AddToQueueError(BaseModel):
  290. """Error for a file that couldn't be added to queue."""
  291. file_id: int
  292. filename: str
  293. error: str
  294. class AddToQueueResponse(BaseModel):
  295. """Schema for add-to-queue response."""
  296. added: list[AddToQueueResult]
  297. errors: list[AddToQueueError]
  298. # ============ ZIP Extraction ============
  299. class ZipExtractResult(BaseModel):
  300. """Result for a single file extracted from ZIP."""
  301. filename: str
  302. file_id: int
  303. folder_id: int | None = None
  304. class ZipExtractError(BaseModel):
  305. """Error for a file that couldn't be extracted."""
  306. filename: str
  307. error: str
  308. class ZipExtractResponse(BaseModel):
  309. """Schema for ZIP extraction response."""
  310. extracted: int
  311. folders_created: int
  312. files: list[ZipExtractResult]
  313. errors: list[ZipExtractError]
  314. # ============ STL Thumbnail Generation ============
  315. class BatchThumbnailRequest(BaseModel):
  316. """Schema for batch STL thumbnail generation request."""
  317. file_ids: list[int] | None = None
  318. folder_id: int | None = None
  319. all_missing: bool = False
  320. class BatchThumbnailResult(BaseModel):
  321. """Result for a single file thumbnail generation."""
  322. file_id: int
  323. filename: str
  324. success: bool
  325. error: str | None = None
  326. class BatchThumbnailResponse(BaseModel):
  327. """Schema for batch thumbnail generation response."""
  328. processed: int
  329. succeeded: int
  330. failed: int
  331. results: list[BatchThumbnailResult]
  332. class ClientThumbnailResponse(BaseModel):
  333. """Schema for the client-rendered preview thumbnail upload response (#2976).
  334. ``updated`` is false when the file already had a thumbnail — the upload is
  335. skipped so a stored thumbnail is never silently replaced.
  336. """
  337. updated: bool
  338. # ============ Variant Group Schemas (#671 / #2570) ============
  339. class VariantGroupMemberRequest(BaseModel):
  340. """One file joining a variant group.
  341. ``target_model`` is optional and normally omitted — it is read from the
  342. file's own ``sliced_for_model``. Supply it only for a legacy 3MF that
  343. declares no model, where there is nothing else to go on.
  344. """
  345. library_file_id: int
  346. target_model: str | None = Field(None, max_length=50)
  347. class VariantGroupCreate(BaseModel):
  348. """Declare that these files are the same job sliced for different printers.
  349. Order is significant: it is the priority used when more than one printer is
  350. idle at the same moment. Two members minimum — a group of one expresses no
  351. choice.
  352. """
  353. members: list[VariantGroupMemberRequest] = Field(..., min_length=2)
  354. name: str | None = Field(None, max_length=255)
  355. class VariantGroupUpdate(IgnoresNullForRequiredColumns):
  356. """Rename a group and/or re-order its members.
  357. ``member_file_ids`` must list exactly the group's current members; a partial
  358. list is rejected rather than guessing where the omitted ones belong.
  359. """
  360. __orm_table__ = "file_variant_groups"
  361. name: str | None = Field(None, max_length=255)
  362. member_file_ids: list[int] | None = None
  363. class VariantGroupMemberResponse(BaseModel):
  364. """A file within a group, with the model it will be dispatched to."""
  365. library_file_id: int
  366. filename: str
  367. target_model: str
  368. position: int
  369. class VariantGroupResponse(BaseModel):
  370. """A variant group and its members, in priority order."""
  371. id: int
  372. name: str
  373. members: list[VariantGroupMemberResponse]