library.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  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 FileCreate(BaseModel):
  96. """Schema for creating a file entry (internal use after upload)."""
  97. filename: str
  98. file_path: str
  99. file_type: str
  100. file_size: int
  101. file_hash: str | None = None
  102. thumbnail_path: str | None = None
  103. metadata: dict | None = None
  104. folder_id: int | None = None
  105. project_id: int | None = None
  106. class FileUpdate(IgnoresNullForRequiredColumns):
  107. """Schema for updating a file."""
  108. __orm_table__ = "library_files"
  109. filename: str | None = Field(None, min_length=1, max_length=255)
  110. folder_id: int | None = None
  111. project_id: int | None = None
  112. notes: str | None = None
  113. # Empty string clears the link, like ``notes`` (#3077).
  114. external_url: str | None = Field(None, max_length=500)
  115. @field_validator("external_url")
  116. @classmethod
  117. def validate_external_url(cls, v: str | None) -> str | None:
  118. # The link is rendered as an href for every reader of the library, so
  119. # only web URLs are accepted (no javascript:/data: schemes).
  120. if v is None:
  121. return None
  122. v = v.strip()
  123. if v and not v.lower().startswith(("http://", "https://")):
  124. raise ValueError("external_url must start with http:// or https://")
  125. return v
  126. class FileDuplicate(BaseModel):
  127. """Reference to a duplicate file."""
  128. id: int
  129. filename: str
  130. folder_id: int | None
  131. folder_name: str | None
  132. created_at: datetime
  133. class FileResponse(BaseModel):
  134. """Schema for file response."""
  135. id: int
  136. folder_id: int | None
  137. folder_name: str | None = None
  138. project_id: int | None
  139. project_name: str | None = None
  140. is_external: bool = False
  141. filename: str
  142. file_path: str
  143. file_type: str
  144. file_size: int
  145. file_hash: str | None
  146. thumbnail_path: str | None
  147. metadata: dict | None
  148. print_count: int
  149. last_printed_at: datetime | None
  150. notes: str | None
  151. # User link + photos of the printed result (#3077); ``source_url`` is the
  152. # read-only import provenance (MakerWorld) shown next to it.
  153. external_url: str | None = None
  154. photos: list[str] = []
  155. source_url: str | None = None
  156. # Duplicate detection
  157. duplicates: list[FileDuplicate] | None = None
  158. duplicate_count: int = 0
  159. # User tracking (Issue #206)
  160. created_by_id: int | None = None
  161. created_by_username: str | None = None
  162. created_at: datetime
  163. updated_at: datetime
  164. # Metadata fields
  165. print_name: str | None = None
  166. print_time_seconds: int | None = None
  167. filament_used_grams: float | None = None
  168. sliced_for_model: str | None = None
  169. class Config:
  170. from_attributes = True
  171. class TagSummary(BaseModel):
  172. """Compact tag projection — embedded in file listings (#1268)."""
  173. id: int
  174. name: str
  175. class Config:
  176. from_attributes = True
  177. class FileListResponse(BaseModel):
  178. """Schema for file list item (lighter than full response)."""
  179. id: int
  180. folder_id: int | None
  181. is_external: bool = False
  182. filename: str
  183. file_type: str
  184. file_size: int
  185. thumbnail_path: str | None
  186. print_count: int
  187. duplicate_count: int = 0
  188. # User tracking (Issue #206)
  189. created_by_id: int | None = None
  190. created_by_username: str | None = None
  191. created_at: datetime
  192. # Real on-disk modification time (#2680). Populated for external files from
  193. # their filesystem mtime; null for managed uploads. The file pane's date sort
  194. # and the "Modified" column use ``fs_modified_at ?? created_at``.
  195. fs_modified_at: datetime | None = None
  196. # Key metadata fields for display
  197. print_name: str | None = None
  198. print_time_seconds: int | None = None
  199. filament_used_grams: float | None = None
  200. sliced_for_model: str | None = None
  201. # Tags assigned to this file (#1268). Empty list when the file has none —
  202. # never null, so the FE can iterate without a guard.
  203. tags: list[TagSummary] = []
  204. # Variant grouping (#671 / #2570). ``variant_count`` is the size of the whole
  205. # group, not of the current listing — members can live in different folders,
  206. # so counting the rows on screen would under-report. Projected in the list
  207. # query so the badge and the smart-print decision cost no extra request.
  208. variant_group_id: int | None = None
  209. variant_count: int = 0
  210. # Metadata indicators (#3077). The list never ships the notes text itself —
  211. # ``has_notes`` is enough for the card badge; the details modal loads the rest.
  212. external_url: str | None = None
  213. has_notes: bool = False
  214. photo_count: int = 0
  215. class Config:
  216. from_attributes = True
  217. # ============ Tag Schemas (#1268) ============
  218. class TagResponse(BaseModel):
  219. """Tag with the count of files currently using it."""
  220. id: int
  221. name: str
  222. file_count: int
  223. created_at: datetime
  224. updated_at: datetime
  225. class Config:
  226. from_attributes = True
  227. class TagCreate(BaseModel):
  228. """Create a new tag (catalog row)."""
  229. name: str = Field(..., min_length=1, max_length=64)
  230. class TagUpdate(IgnoresNullForRequiredColumns):
  231. """Rename a tag. ``name`` is required — there's nothing else to update."""
  232. __orm_table__ = "library_tags"
  233. name: str = Field(..., min_length=1, max_length=64)
  234. class TagBulkAssignRequest(BaseModel):
  235. """Bulk tag assignment payload.
  236. ``action='add'`` → append tags to every listed file (idempotent on dup).
  237. ``action='remove'`` → strip the listed tags from every listed file.
  238. ``action='replace'`` → REPLACE the tag set on every listed file with the
  239. exact set in ``tag_ids`` (omitting tag_ids clears
  240. them all).
  241. """
  242. file_ids: list[int] = Field(..., min_length=1)
  243. tag_ids: list[int] = Field(default_factory=list)
  244. action: str = Field("add", pattern="^(add|remove|replace)$")
  245. class TagBulkAssignResponse(BaseModel):
  246. """Result of a bulk-assign call."""
  247. files_updated: int
  248. associations_added: int
  249. associations_removed: int
  250. class FileMoveRequest(BaseModel):
  251. """Schema for moving files to a folder."""
  252. file_ids: list[int]
  253. folder_id: int | None = None # None = move to root
  254. class FileUploadResponse(BaseModel):
  255. """Schema for file upload response."""
  256. id: int
  257. filename: str
  258. file_type: str
  259. file_size: int
  260. thumbnail_path: str | None
  261. duplicate_of: int | None = None # ID of existing file with same hash
  262. metadata: dict | None = None
  263. # ============ Combine ============
  264. class CombineItem(BaseModel):
  265. """One source model in a combine request."""
  266. file_id: int
  267. copies: int = Field(default=1, ge=1, le=100)
  268. class CombineFilesRequest(BaseModel):
  269. """Combine STL library files into one multi-object 3MF on a single plate."""
  270. items: list[CombineItem] = Field(..., min_length=1, max_length=100)
  271. # Name of the new library file; ``.3mf`` is appended when missing.
  272. filename: str = Field(..., min_length=1, max_length=255)
  273. # Destination folder; None = library root.
  274. folder_id: int | None = None
  275. # ============ Bulk Operations ============
  276. class BulkDeleteRequest(BaseModel):
  277. """Schema for bulk delete operations."""
  278. file_ids: list[int] = []
  279. folder_ids: list[int] = []
  280. class BulkDeleteResponse(BaseModel):
  281. """Schema for bulk delete response."""
  282. deleted_files: int
  283. deleted_folders: int
  284. # ============ Queue Operations ============
  285. class AddToQueueRequest(BaseModel):
  286. """Schema for adding library files to the print queue."""
  287. file_ids: list[int] = Field(..., min_length=1)
  288. # Where the items should go. Mutually exclusive, both optional. With
  289. # neither, each file's own declared model is used when a printer of that
  290. # model is active: an item carrying no printer and no target model matches
  291. # neither branch of the scheduler's dispatch, so it is one nothing can ever
  292. # pick up (#3112).
  293. printer_id: int | None = None
  294. target_model: str | None = None
  295. class AddToQueueResult(BaseModel):
  296. """Result for a single file added to queue."""
  297. file_id: int
  298. filename: str
  299. queue_item_id: int
  300. class AddToQueueError(BaseModel):
  301. """Error for a file that couldn't be added to queue."""
  302. file_id: int
  303. filename: str
  304. error: str
  305. class AddToQueueResponse(BaseModel):
  306. """Schema for add-to-queue response."""
  307. added: list[AddToQueueResult]
  308. errors: list[AddToQueueError]
  309. # ============ ZIP Extraction ============
  310. class ZipExtractResult(BaseModel):
  311. """Result for a single file extracted from ZIP."""
  312. filename: str
  313. file_id: int
  314. folder_id: int | None = None
  315. class ZipExtractError(BaseModel):
  316. """Error for a file that couldn't be extracted."""
  317. filename: str
  318. error: str
  319. class ZipExtractResponse(BaseModel):
  320. """Schema for ZIP extraction response."""
  321. extracted: int
  322. folders_created: int
  323. files: list[ZipExtractResult]
  324. errors: list[ZipExtractError]
  325. # ============ STL Thumbnail Generation ============
  326. class BatchThumbnailRequest(BaseModel):
  327. """Schema for batch STL thumbnail generation request."""
  328. file_ids: list[int] | None = None
  329. folder_id: int | None = None
  330. all_missing: bool = False
  331. class BatchThumbnailResult(BaseModel):
  332. """Result for a single file thumbnail generation."""
  333. file_id: int
  334. filename: str
  335. success: bool
  336. error: str | None = None
  337. class BatchThumbnailResponse(BaseModel):
  338. """Schema for batch thumbnail generation response."""
  339. processed: int
  340. succeeded: int
  341. failed: int
  342. results: list[BatchThumbnailResult]
  343. class ClientThumbnailResponse(BaseModel):
  344. """Schema for the client-rendered preview thumbnail upload response (#2976).
  345. ``updated`` is false when the file already had a thumbnail — the upload is
  346. skipped so a stored thumbnail is never silently replaced.
  347. """
  348. updated: bool
  349. # ============ Variant Group Schemas (#671 / #2570) ============
  350. class VariantGroupMemberRequest(BaseModel):
  351. """One file joining a variant group.
  352. ``target_model`` is optional and normally omitted — it is read from the
  353. file's own ``sliced_for_model``. Supply it only for a legacy 3MF that
  354. declares no model, where there is nothing else to go on.
  355. """
  356. library_file_id: int
  357. target_model: str | None = Field(None, max_length=50)
  358. class VariantGroupCreate(BaseModel):
  359. """Declare that these files are the same job sliced for different printers.
  360. Order is significant: it is the priority used when more than one printer is
  361. idle at the same moment. Two members minimum — a group of one expresses no
  362. choice.
  363. """
  364. members: list[VariantGroupMemberRequest] = Field(..., min_length=2)
  365. name: str | None = Field(None, max_length=255)
  366. class VariantGroupUpdate(IgnoresNullForRequiredColumns):
  367. """Rename a group and/or re-order its members.
  368. ``member_file_ids`` must list exactly the group's current members; a partial
  369. list is rejected rather than guessing where the omitted ones belong.
  370. """
  371. __orm_table__ = "file_variant_groups"
  372. name: str | None = Field(None, max_length=255)
  373. member_file_ids: list[int] | None = None
  374. class VariantGroupMemberResponse(BaseModel):
  375. """A file within a group, with the model it will be dispatched to."""
  376. library_file_id: int
  377. filename: str
  378. target_model: str
  379. position: int
  380. class VariantGroupResponse(BaseModel):
  381. """A variant group and its members, in priority order."""
  382. id: int
  383. name: str
  384. members: list[VariantGroupMemberResponse]