library.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. """Pydantic schemas for library (File Manager) functionality."""
  2. from datetime import datetime
  3. from pydantic import BaseModel, Field
  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. class FolderResponse(BaseModel):
  25. """Schema for folder response."""
  26. id: int
  27. name: str
  28. parent_id: int | None
  29. project_id: int | None = None
  30. archive_id: int | None = None
  31. project_name: str | None = None
  32. archive_name: str | None = None
  33. is_external: bool = False
  34. external_path: str | None = None
  35. external_readonly: bool = False
  36. external_show_hidden: bool = False
  37. file_count: int = 0 # Computed field
  38. # max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
  39. # File Manager folder tree's "sort by recent activity" mode (#1770) so that
  40. # adding a file inside a folder bubbles it up — folder.updated_at alone only
  41. # tracks rename/move events. Recursion across subfolders is intentionally
  42. # left out to keep the route a single GROUP BY rather than a recursive CTE.
  43. latest_activity_at: datetime | None = None
  44. created_at: datetime
  45. updated_at: datetime
  46. class Config:
  47. from_attributes = True
  48. class FolderReadmeResponse(BaseModel):
  49. """Markdown sidebar payload for a folder (#1268).
  50. ``filename`` is the on-disk name (so the UI can show "README.md") and
  51. ``content`` is the raw markdown — the FE renders it. ``truncated`` is
  52. True when the source file was clipped at the size cap.
  53. """
  54. filename: str
  55. content: str
  56. truncated: bool
  57. class FolderTreeItem(BaseModel):
  58. """Schema for folder tree item (includes children)."""
  59. id: int
  60. name: str
  61. parent_id: int | None
  62. project_id: int | None = None
  63. archive_id: int | None = None
  64. project_name: str | None = None
  65. archive_name: str | None = None
  66. is_external: bool = False
  67. external_path: str | None = None
  68. external_readonly: bool = False
  69. file_count: int = 0
  70. # See FolderResponse.latest_activity_at — #1770 folder sort source.
  71. latest_activity_at: datetime | None = None
  72. children: list["FolderTreeItem"] = []
  73. class Config:
  74. from_attributes = True
  75. # ============ File Schemas ============
  76. class FileCreate(BaseModel):
  77. """Schema for creating a file entry (internal use after upload)."""
  78. filename: str
  79. file_path: str
  80. file_type: str
  81. file_size: int
  82. file_hash: str | None = None
  83. thumbnail_path: str | None = None
  84. metadata: dict | None = None
  85. folder_id: int | None = None
  86. project_id: int | None = None
  87. class FileUpdate(BaseModel):
  88. """Schema for updating a file."""
  89. filename: str | None = Field(None, min_length=1, max_length=255)
  90. folder_id: int | None = None
  91. project_id: int | None = None
  92. notes: str | None = None
  93. class FileDuplicate(BaseModel):
  94. """Reference to a duplicate file."""
  95. id: int
  96. filename: str
  97. folder_id: int | None
  98. folder_name: str | None
  99. created_at: datetime
  100. class FileResponse(BaseModel):
  101. """Schema for file response."""
  102. id: int
  103. folder_id: int | None
  104. folder_name: str | None = None
  105. project_id: int | None
  106. project_name: str | None = None
  107. is_external: bool = False
  108. filename: str
  109. file_path: str
  110. file_type: str
  111. file_size: int
  112. file_hash: str | None
  113. thumbnail_path: str | None
  114. metadata: dict | None
  115. print_count: int
  116. last_printed_at: datetime | None
  117. notes: str | None
  118. # Duplicate detection
  119. duplicates: list[FileDuplicate] | None = None
  120. duplicate_count: int = 0
  121. # User tracking (Issue #206)
  122. created_by_id: int | None = None
  123. created_by_username: str | None = None
  124. created_at: datetime
  125. updated_at: datetime
  126. # Metadata fields
  127. print_name: str | None = None
  128. print_time_seconds: int | None = None
  129. filament_used_grams: float | None = None
  130. sliced_for_model: str | None = None
  131. class Config:
  132. from_attributes = True
  133. class TagSummary(BaseModel):
  134. """Compact tag projection — embedded in file listings (#1268)."""
  135. id: int
  136. name: str
  137. class Config:
  138. from_attributes = True
  139. class FileListResponse(BaseModel):
  140. """Schema for file list item (lighter than full response)."""
  141. id: int
  142. folder_id: int | None
  143. is_external: bool = False
  144. filename: str
  145. file_type: str
  146. file_size: int
  147. thumbnail_path: str | None
  148. print_count: int
  149. duplicate_count: int = 0
  150. # User tracking (Issue #206)
  151. created_by_id: int | None = None
  152. created_by_username: str | None = None
  153. created_at: datetime
  154. # Real on-disk modification time (#2680). Populated for external files from
  155. # their filesystem mtime; null for managed uploads. The file pane's date sort
  156. # and the "Modified" column use ``fs_modified_at ?? created_at``.
  157. fs_modified_at: datetime | None = None
  158. # Key metadata fields for display
  159. print_name: str | None = None
  160. print_time_seconds: int | None = None
  161. filament_used_grams: float | None = None
  162. sliced_for_model: str | None = None
  163. # Tags assigned to this file (#1268). Empty list when the file has none —
  164. # never null, so the FE can iterate without a guard.
  165. tags: list[TagSummary] = []
  166. # Variant grouping (#671 / #2570). ``variant_count`` is the size of the whole
  167. # group, not of the current listing — members can live in different folders,
  168. # so counting the rows on screen would under-report. Projected in the list
  169. # query so the badge and the smart-print decision cost no extra request.
  170. variant_group_id: int | None = None
  171. variant_count: int = 0
  172. class Config:
  173. from_attributes = True
  174. # ============ Tag Schemas (#1268) ============
  175. class TagResponse(BaseModel):
  176. """Tag with the count of files currently using it."""
  177. id: int
  178. name: str
  179. file_count: int
  180. created_at: datetime
  181. updated_at: datetime
  182. class Config:
  183. from_attributes = True
  184. class TagCreate(BaseModel):
  185. """Create a new tag (catalog row)."""
  186. name: str = Field(..., min_length=1, max_length=64)
  187. class TagUpdate(BaseModel):
  188. """Rename a tag. ``name`` is required — there's nothing else to update."""
  189. name: str = Field(..., min_length=1, max_length=64)
  190. class TagBulkAssignRequest(BaseModel):
  191. """Bulk tag assignment payload.
  192. ``action='add'`` → append tags to every listed file (idempotent on dup).
  193. ``action='remove'`` → strip the listed tags from every listed file.
  194. ``action='replace'`` → REPLACE the tag set on every listed file with the
  195. exact set in ``tag_ids`` (omitting tag_ids clears
  196. them all).
  197. """
  198. file_ids: list[int] = Field(..., min_length=1)
  199. tag_ids: list[int] = Field(default_factory=list)
  200. action: str = Field("add", pattern="^(add|remove|replace)$")
  201. class TagBulkAssignResponse(BaseModel):
  202. """Result of a bulk-assign call."""
  203. files_updated: int
  204. associations_added: int
  205. associations_removed: int
  206. class FileMoveRequest(BaseModel):
  207. """Schema for moving files to a folder."""
  208. file_ids: list[int]
  209. folder_id: int | None = None # None = move to root
  210. class FileUploadResponse(BaseModel):
  211. """Schema for file upload response."""
  212. id: int
  213. filename: str
  214. file_type: str
  215. file_size: int
  216. thumbnail_path: str | None
  217. duplicate_of: int | None = None # ID of existing file with same hash
  218. metadata: dict | None = None
  219. # ============ Bulk Operations ============
  220. class BulkDeleteRequest(BaseModel):
  221. """Schema for bulk delete operations."""
  222. file_ids: list[int] = []
  223. folder_ids: list[int] = []
  224. class BulkDeleteResponse(BaseModel):
  225. """Schema for bulk delete response."""
  226. deleted_files: int
  227. deleted_folders: int
  228. # ============ Queue Operations ============
  229. class AddToQueueRequest(BaseModel):
  230. """Schema for adding library files to the print queue."""
  231. file_ids: list[int] = Field(..., min_length=1)
  232. class AddToQueueResult(BaseModel):
  233. """Result for a single file added to queue."""
  234. file_id: int
  235. filename: str
  236. queue_item_id: int
  237. class AddToQueueError(BaseModel):
  238. """Error for a file that couldn't be added to queue."""
  239. file_id: int
  240. filename: str
  241. error: str
  242. class AddToQueueResponse(BaseModel):
  243. """Schema for add-to-queue response."""
  244. added: list[AddToQueueResult]
  245. errors: list[AddToQueueError]
  246. # ============ ZIP Extraction ============
  247. class ZipExtractResult(BaseModel):
  248. """Result for a single file extracted from ZIP."""
  249. filename: str
  250. file_id: int
  251. folder_id: int | None = None
  252. class ZipExtractError(BaseModel):
  253. """Error for a file that couldn't be extracted."""
  254. filename: str
  255. error: str
  256. class ZipExtractResponse(BaseModel):
  257. """Schema for ZIP extraction response."""
  258. extracted: int
  259. folders_created: int
  260. files: list[ZipExtractResult]
  261. errors: list[ZipExtractError]
  262. # ============ STL Thumbnail Generation ============
  263. class BatchThumbnailRequest(BaseModel):
  264. """Schema for batch STL thumbnail generation request."""
  265. file_ids: list[int] | None = None
  266. folder_id: int | None = None
  267. all_missing: bool = False
  268. class BatchThumbnailResult(BaseModel):
  269. """Result for a single file thumbnail generation."""
  270. file_id: int
  271. filename: str
  272. success: bool
  273. error: str | None = None
  274. class BatchThumbnailResponse(BaseModel):
  275. """Schema for batch thumbnail generation response."""
  276. processed: int
  277. succeeded: int
  278. failed: int
  279. results: list[BatchThumbnailResult]
  280. # ============ Variant Group Schemas (#671 / #2570) ============
  281. class VariantGroupMemberRequest(BaseModel):
  282. """One file joining a variant group.
  283. ``target_model`` is optional and normally omitted — it is read from the
  284. file's own ``sliced_for_model``. Supply it only for a legacy 3MF that
  285. declares no model, where there is nothing else to go on.
  286. """
  287. library_file_id: int
  288. target_model: str | None = Field(None, max_length=50)
  289. class VariantGroupCreate(BaseModel):
  290. """Declare that these files are the same job sliced for different printers.
  291. Order is significant: it is the priority used when more than one printer is
  292. idle at the same moment. Two members minimum — a group of one expresses no
  293. choice.
  294. """
  295. members: list[VariantGroupMemberRequest] = Field(..., min_length=2)
  296. name: str | None = Field(None, max_length=255)
  297. class VariantGroupUpdate(BaseModel):
  298. """Rename a group and/or re-order its members.
  299. ``member_file_ids`` must list exactly the group's current members; a partial
  300. list is rejected rather than guessing where the omitted ones belong.
  301. """
  302. name: str | None = Field(None, max_length=255)
  303. member_file_ids: list[int] | None = None
  304. class VariantGroupMemberResponse(BaseModel):
  305. """A file within a group, with the model it will be dispatched to."""
  306. library_file_id: int
  307. filename: str
  308. target_model: str
  309. position: int
  310. class VariantGroupResponse(BaseModel):
  311. """A variant group and its members, in priority order."""
  312. id: int
  313. name: str
  314. members: list[VariantGroupMemberResponse]