project.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. from datetime import datetime
  2. from pydantic import BaseModel, field_validator
  3. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  4. def _validate_project_url(value: str | None) -> str | None:
  5. """Reject anything that isn't an http(s) URL — the URL is rendered as a
  6. clickable `<a href>` so a `javascript:` / `data:` / `file:` value would be
  7. an XSS vector even with React's default escaping (#1155)."""
  8. if value is None:
  9. return value
  10. trimmed = value.strip()
  11. if not trimmed:
  12. return None
  13. lowered = trimmed.lower()
  14. if not (lowered.startswith("http://") or lowered.startswith("https://")):
  15. raise ValueError("url must start with http:// or https://")
  16. return trimmed
  17. class ProjectCreate(BaseModel):
  18. """Schema for creating a new project."""
  19. name: str
  20. description: str | None = None
  21. color: str | None = None
  22. target_count: int | None = None
  23. target_parts_count: int | None = None
  24. target_sets: int | None = None # Copies-per-file target (#1897)
  25. notes: str | None = None
  26. tags: str | None = None
  27. due_date: datetime | None = None
  28. priority: str = "normal"
  29. budget: float | None = None
  30. parent_id: int | None = None # For sub-projects
  31. url: str | None = None
  32. @field_validator("url")
  33. @classmethod
  34. def _check_url(cls, v: str | None) -> str | None:
  35. return _validate_project_url(v)
  36. class ProjectUpdate(IgnoresNullForRequiredColumns):
  37. """Schema for updating a project."""
  38. __orm_table__ = "projects"
  39. name: str | None = None
  40. description: str | None = None
  41. color: str | None = None
  42. status: str | None = None # active, completed, archived
  43. target_count: int | None = None
  44. target_parts_count: int | None = None
  45. target_sets: int | None = None # Copies-per-file target (#1897)
  46. notes: str | None = None
  47. tags: str | None = None
  48. due_date: datetime | None = None
  49. priority: str | None = None
  50. budget: float | None = None
  51. parent_id: int | None = None
  52. url: str | None = None
  53. @field_validator("url")
  54. @classmethod
  55. def _check_url(cls, v: str | None) -> str | None:
  56. return _validate_project_url(v)
  57. class ProjectStats(BaseModel):
  58. """Statistics for a project."""
  59. total_archives: int = 0 # Number of archive records
  60. total_items: int = 0 # Sum of quantities (total items printed)
  61. completed_prints: int = 0 # Sum of quantities for completed prints
  62. failed_prints: int = 0 # Sum of quantities for failed prints
  63. queued_prints: int = 0
  64. in_progress_prints: int = 0
  65. total_print_time_hours: float = 0.0
  66. total_filament_grams: float = 0.0
  67. progress_percent: float | None = None # Based on target_count (plates)
  68. parts_progress_percent: float | None = None # Based on target_parts_count
  69. # Cost tracking (Phase 6)
  70. estimated_cost: float = 0.0 # Based on filament cost
  71. total_energy_kwh: float = 0.0
  72. total_energy_cost: float = 0.0
  73. total_wear_cost: float = 0.0 # Printer wear (#694)
  74. remaining_prints: int | None = None # target_count - total_archives
  75. remaining_parts: int | None = None # target_parts_count - completed_prints
  76. # BOM stats (Phase 7)
  77. bom_total_items: int = 0
  78. bom_completed_items: int = 0
  79. bom_cost: float = 0.0 # Total cost of BOM items (sum of unit_price * quantity_needed)
  80. class ProjectChildPreview(BaseModel):
  81. """A sub-project as listed on its parent's page.
  82. The figures cover the child's *own* subtree, not just its own prints, so
  83. the listed rows add up to the parent's roll-up minus the parent's own
  84. prints (#1264).
  85. """
  86. id: int
  87. name: str
  88. color: str | None
  89. status: str
  90. progress_percent: float | None = None
  91. descendant_count: int = 0 # Sub-projects nested under this one, at any depth
  92. total_archives: int = 0
  93. completed_prints: int = 0
  94. total_print_time_hours: float = 0.0
  95. total_filament_grams: float = 0.0
  96. total_cost: float = 0.0 # Filament + energy + BOM, matching the parent's cost card
  97. class ProjectResponse(BaseModel):
  98. """Schema for project response."""
  99. id: int
  100. name: str
  101. description: str | None
  102. color: str | None
  103. status: str
  104. target_count: int | None
  105. target_parts_count: int | None = None
  106. target_sets: int | None = None # Copies-per-file target (#1897)
  107. notes: str | None = None
  108. attachments: list | None = None
  109. tags: str | None = None
  110. due_date: datetime | None = None
  111. priority: str = "normal"
  112. budget: float | None = None
  113. is_template: bool = False
  114. template_source_id: int | None = None
  115. parent_id: int | None = None
  116. parent_name: str | None = None # For display
  117. children: list[ProjectChildPreview] = []
  118. descendant_count: int = 0 # Sub-projects at any depth beneath this one (#1264)
  119. created_at: datetime
  120. updated_at: datetime
  121. stats: ProjectStats | None = None
  122. # This project's numbers combined with every sub-project's. Null when there
  123. # are none, since it would only repeat ``stats`` (#1264).
  124. rollup_stats: ProjectStats | None = None
  125. url: str | None = None
  126. cover_image_filename: str | None = None
  127. class Config:
  128. from_attributes = True
  129. class ProjectFileProgress(BaseModel):
  130. """Completed-run count for one library file inside a project (#1897)."""
  131. file_id: int
  132. completed_count: int
  133. class ArchivePreview(BaseModel):
  134. """Minimal archive data for project preview."""
  135. id: int
  136. print_name: str | None
  137. thumbnail_path: str | None
  138. status: str
  139. filament_type: str | None = None
  140. filament_color: str | None = None
  141. class ProjectListResponse(BaseModel):
  142. """Schema for project list item (lighter weight)."""
  143. id: int
  144. name: str
  145. description: str | None
  146. color: str | None
  147. status: str
  148. target_count: int | None
  149. target_parts_count: int | None = None
  150. target_sets: int | None = None # Copies-per-file target (#1897); the shared edit dialog needs it
  151. budget: float | None = None
  152. # The edit dialog is shared with the project detail page and seeds its fields
  153. # from whichever project object it is handed, so the list payload has to carry
  154. # everything the dialog edits — otherwise a save from the list view submits a
  155. # blank tags field and a default priority over the stored values (#2536).
  156. tags: str | None = None
  157. due_date: datetime | None = None
  158. priority: str = "normal"
  159. created_at: datetime
  160. # Quick stats
  161. archive_count: int = 0 # Number of print jobs
  162. total_items: int = 0 # Sum of quantities (total items printed, including failed)
  163. completed_count: int = 0 # Sum of quantities for completed prints only
  164. failed_count: int = 0 # Sum of quantities for failed prints
  165. queue_count: int = 0
  166. progress_percent: float | None = None
  167. # Nesting (#1264) — the grid needs both to tell a sub-project apart from a
  168. # top-level one without fetching every project's detail.
  169. parent_id: int | None = None
  170. child_count: int = 0 # Direct sub-projects only
  171. # Preview of archives (up to 5)
  172. archives: list[ArchivePreview] = []
  173. # #1155: card-level metadata
  174. url: str | None = None
  175. cover_image_filename: str | None = None
  176. class Config:
  177. from_attributes = True
  178. class BatchAddArchives(BaseModel):
  179. """Schema for batch adding archives to a project."""
  180. archive_ids: list[int]
  181. class BatchAddQueueItems(BaseModel):
  182. """Schema for batch adding queue items to a project."""
  183. queue_item_ids: list[int]
  184. # Phase 7: BOM Schemas - Tracks sourced/purchased parts
  185. class BOMItemCreate(BaseModel):
  186. """Schema for creating a BOM item."""
  187. name: str
  188. quantity_needed: int = 1
  189. unit_price: float | None = None
  190. sourcing_url: str | None = None
  191. archive_id: int | None = None
  192. stl_filename: str | None = None
  193. remarks: str | None = None
  194. class BOMItemUpdate(IgnoresNullForRequiredColumns):
  195. """Schema for updating a BOM item."""
  196. __orm_table__ = "project_bom_items"
  197. name: str | None = None
  198. quantity_needed: int | None = None
  199. quantity_acquired: int | None = None
  200. unit_price: float | None = None
  201. sourcing_url: str | None = None
  202. archive_id: int | None = None
  203. stl_filename: str | None = None
  204. remarks: str | None = None
  205. class BOMItemResponse(BaseModel):
  206. """Schema for BOM item response."""
  207. id: int
  208. project_id: int
  209. name: str
  210. quantity_needed: int
  211. quantity_acquired: int
  212. unit_price: float | None
  213. sourcing_url: str | None
  214. archive_id: int | None
  215. archive_name: str | None = None
  216. stl_filename: str | None
  217. remarks: str | None
  218. sort_order: int
  219. is_complete: bool = False
  220. created_at: datetime
  221. updated_at: datetime
  222. class Config:
  223. from_attributes = True
  224. # Phase 9: Timeline Schemas
  225. class TimelineEvent(BaseModel):
  226. """Schema for a timeline event."""
  227. event_type: str # archive_added, queue_started, queue_completed, status_changed, note_updated
  228. timestamp: datetime
  229. title: str
  230. description: str | None = None
  231. metadata: dict | None = None # Additional event-specific data
  232. # Phase 10: Import/Export Schemas
  233. class BOMItemExport(BaseModel):
  234. """Schema for exporting a BOM item."""
  235. name: str
  236. quantity_needed: int
  237. quantity_acquired: int
  238. unit_price: float | None
  239. sourcing_url: str | None
  240. stl_filename: str | None
  241. remarks: str | None
  242. class LinkedFolderExport(BaseModel):
  243. """Schema for exporting a linked library folder."""
  244. name: str
  245. class ProjectExport(BaseModel):
  246. """Schema for exporting a project."""
  247. name: str
  248. description: str | None
  249. color: str | None
  250. status: str
  251. target_count: int | None
  252. target_parts_count: int | None
  253. target_sets: int | None = None
  254. notes: str | None
  255. tags: str | None
  256. due_date: datetime | None
  257. priority: str
  258. budget: float | None
  259. bom_items: list[BOMItemExport] = []
  260. linked_folders: list[LinkedFolderExport] = []
  261. class ProjectImport(BaseModel):
  262. """Schema for importing a project."""
  263. name: str
  264. description: str | None = None
  265. color: str | None = None
  266. status: str = "active"
  267. target_count: int | None = None
  268. target_parts_count: int | None = None
  269. target_sets: int | None = None
  270. notes: str | None = None
  271. tags: str | None = None
  272. due_date: datetime | None = None
  273. priority: str = "normal"
  274. budget: float | None = None
  275. bom_items: list[BOMItemExport] = []
  276. linked_folders: list[LinkedFolderExport] = []