library.py 15 KB

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