projects.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import logging
  2. from fastapi import APIRouter, Depends, HTTPException
  3. from sqlalchemy.ext.asyncio import AsyncSession
  4. from sqlalchemy import select, func
  5. from backend.app.core.database import get_db
  6. from backend.app.models.project import Project
  7. from backend.app.models.archive import PrintArchive
  8. from backend.app.models.print_queue import PrintQueueItem
  9. from backend.app.schemas.project import (
  10. ProjectCreate,
  11. ProjectUpdate,
  12. ProjectResponse,
  13. ProjectListResponse,
  14. ProjectStats,
  15. BatchAddArchives,
  16. BatchAddQueueItems,
  17. ArchivePreview,
  18. )
  19. logger = logging.getLogger(__name__)
  20. router = APIRouter(prefix="/projects", tags=["projects"])
  21. async def compute_project_stats(
  22. db: AsyncSession, project_id: int, target_count: int | None = None
  23. ) -> ProjectStats:
  24. """Compute statistics for a project."""
  25. # Count total archives
  26. total_result = await db.execute(
  27. select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)
  28. )
  29. total_archives = total_result.scalar() or 0
  30. # Count completed archives
  31. completed_result = await db.execute(
  32. select(func.count(PrintArchive.id)).where(
  33. PrintArchive.project_id == project_id,
  34. PrintArchive.status == "completed"
  35. )
  36. )
  37. completed_prints = completed_result.scalar() or 0
  38. # Count failed archives
  39. failed_result = await db.execute(
  40. select(func.count(PrintArchive.id)).where(
  41. PrintArchive.project_id == project_id,
  42. PrintArchive.status == "failed"
  43. )
  44. )
  45. failed_prints = failed_result.scalar() or 0
  46. # Sum print time and filament
  47. sums_result = await db.execute(
  48. select(
  49. func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
  50. func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
  51. ).where(PrintArchive.project_id == project_id)
  52. )
  53. sums = sums_result.first()
  54. # Count queued items
  55. queued_result = await db.execute(
  56. select(func.count(PrintQueueItem.id)).where(
  57. PrintQueueItem.project_id == project_id,
  58. PrintQueueItem.status == "pending"
  59. )
  60. )
  61. queued_prints = queued_result.scalar() or 0
  62. # Count in-progress items
  63. in_progress_result = await db.execute(
  64. select(func.count(PrintQueueItem.id)).where(
  65. PrintQueueItem.project_id == project_id,
  66. PrintQueueItem.status == "printing"
  67. )
  68. )
  69. in_progress_prints = in_progress_result.scalar() or 0
  70. # Calculate progress
  71. progress_percent = None
  72. if target_count and target_count > 0:
  73. progress_percent = round((completed_prints / target_count) * 100, 1)
  74. return ProjectStats(
  75. total_archives=total_archives,
  76. completed_prints=completed_prints,
  77. failed_prints=failed_prints,
  78. queued_prints=queued_prints,
  79. in_progress_prints=in_progress_prints,
  80. total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
  81. total_filament_grams=round(sums.total_filament or 0, 2),
  82. progress_percent=progress_percent,
  83. )
  84. @router.get("", response_model=list[ProjectListResponse])
  85. @router.get("/", response_model=list[ProjectListResponse])
  86. async def list_projects(
  87. status: str | None = None,
  88. db: AsyncSession = Depends(get_db),
  89. ):
  90. """List all projects with basic stats."""
  91. query = select(Project)
  92. if status:
  93. query = query.where(Project.status == status)
  94. query = query.order_by(Project.updated_at.desc())
  95. result = await db.execute(query)
  96. projects = result.scalars().all()
  97. # Compute quick stats for each project
  98. response = []
  99. for project in projects:
  100. # Get archive count
  101. archive_count_result = await db.execute(
  102. select(func.count(PrintArchive.id)).where(
  103. PrintArchive.project_id == project.id
  104. )
  105. )
  106. archive_count = archive_count_result.scalar() or 0
  107. # Get queue count
  108. queue_count_result = await db.execute(
  109. select(func.count(PrintQueueItem.id)).where(
  110. PrintQueueItem.project_id == project.id,
  111. PrintQueueItem.status.in_(["pending", "printing"]),
  112. )
  113. )
  114. queue_count = queue_count_result.scalar() or 0
  115. # Get completed count for progress
  116. completed_result = await db.execute(
  117. select(func.count(PrintArchive.id)).where(
  118. PrintArchive.project_id == project.id,
  119. PrintArchive.status == "completed",
  120. )
  121. )
  122. completed_count = completed_result.scalar() or 0
  123. progress_percent = None
  124. if project.target_count and project.target_count > 0:
  125. progress_percent = round((completed_count / project.target_count) * 100, 1)
  126. # Get archive previews (up to 6 most recent)
  127. archives_result = await db.execute(
  128. select(PrintArchive)
  129. .where(PrintArchive.project_id == project.id)
  130. .order_by(PrintArchive.created_at.desc())
  131. .limit(6)
  132. )
  133. archives = archives_result.scalars().all()
  134. archive_previews = [
  135. ArchivePreview(
  136. id=a.id,
  137. print_name=a.print_name,
  138. thumbnail_path=a.thumbnail_path,
  139. status=a.status,
  140. )
  141. for a in archives
  142. ]
  143. response.append(
  144. ProjectListResponse(
  145. id=project.id,
  146. name=project.name,
  147. description=project.description,
  148. color=project.color,
  149. status=project.status,
  150. target_count=project.target_count,
  151. created_at=project.created_at,
  152. archive_count=archive_count,
  153. queue_count=queue_count,
  154. progress_percent=progress_percent,
  155. archives=archive_previews,
  156. )
  157. )
  158. return response
  159. @router.post("/", response_model=ProjectResponse)
  160. async def create_project(
  161. data: ProjectCreate,
  162. db: AsyncSession = Depends(get_db),
  163. ):
  164. """Create a new project."""
  165. project = Project(
  166. name=data.name,
  167. description=data.description,
  168. color=data.color,
  169. target_count=data.target_count,
  170. )
  171. db.add(project)
  172. await db.flush()
  173. await db.refresh(project)
  174. stats = await compute_project_stats(db, project.id, project.target_count)
  175. return ProjectResponse(
  176. id=project.id,
  177. name=project.name,
  178. description=project.description,
  179. color=project.color,
  180. status=project.status,
  181. target_count=project.target_count,
  182. created_at=project.created_at,
  183. updated_at=project.updated_at,
  184. stats=stats,
  185. )
  186. @router.get("/{project_id}", response_model=ProjectResponse)
  187. async def get_project(
  188. project_id: int,
  189. db: AsyncSession = Depends(get_db),
  190. ):
  191. """Get a project by ID with detailed stats."""
  192. result = await db.execute(select(Project).where(Project.id == project_id))
  193. project = result.scalar_one_or_none()
  194. if not project:
  195. raise HTTPException(status_code=404, detail="Project not found")
  196. stats = await compute_project_stats(db, project.id, project.target_count)
  197. return ProjectResponse(
  198. id=project.id,
  199. name=project.name,
  200. description=project.description,
  201. color=project.color,
  202. status=project.status,
  203. target_count=project.target_count,
  204. created_at=project.created_at,
  205. updated_at=project.updated_at,
  206. stats=stats,
  207. )
  208. @router.patch("/{project_id}", response_model=ProjectResponse)
  209. async def update_project(
  210. project_id: int,
  211. data: ProjectUpdate,
  212. db: AsyncSession = Depends(get_db),
  213. ):
  214. """Update a project."""
  215. result = await db.execute(select(Project).where(Project.id == project_id))
  216. project = result.scalar_one_or_none()
  217. if not project:
  218. raise HTTPException(status_code=404, detail="Project not found")
  219. # Update fields if provided
  220. if data.name is not None:
  221. project.name = data.name
  222. if data.description is not None:
  223. project.description = data.description
  224. if data.color is not None:
  225. project.color = data.color
  226. if data.status is not None:
  227. if data.status not in ["active", "completed", "archived"]:
  228. raise HTTPException(status_code=400, detail="Invalid status")
  229. project.status = data.status
  230. if data.target_count is not None:
  231. project.target_count = data.target_count
  232. await db.flush()
  233. await db.refresh(project)
  234. stats = await compute_project_stats(db, project.id, project.target_count)
  235. return ProjectResponse(
  236. id=project.id,
  237. name=project.name,
  238. description=project.description,
  239. color=project.color,
  240. status=project.status,
  241. target_count=project.target_count,
  242. created_at=project.created_at,
  243. updated_at=project.updated_at,
  244. stats=stats,
  245. )
  246. @router.delete("/{project_id}")
  247. async def delete_project(
  248. project_id: int,
  249. db: AsyncSession = Depends(get_db),
  250. ):
  251. """Delete a project. Archives and queue items will have project_id set to NULL."""
  252. result = await db.execute(select(Project).where(Project.id == project_id))
  253. project = result.scalar_one_or_none()
  254. if not project:
  255. raise HTTPException(status_code=404, detail="Project not found")
  256. await db.delete(project)
  257. return {"message": "Project deleted"}
  258. @router.get("/{project_id}/archives")
  259. async def list_project_archives(
  260. project_id: int,
  261. limit: int = 100,
  262. offset: int = 0,
  263. db: AsyncSession = Depends(get_db),
  264. ):
  265. """List archives in a project."""
  266. # Verify project exists
  267. result = await db.execute(select(Project).where(Project.id == project_id))
  268. if not result.scalar_one_or_none():
  269. raise HTTPException(status_code=404, detail="Project not found")
  270. # Get archives
  271. query = (
  272. select(PrintArchive)
  273. .where(PrintArchive.project_id == project_id)
  274. .order_by(PrintArchive.created_at.desc())
  275. .limit(limit)
  276. .offset(offset)
  277. )
  278. result = await db.execute(query)
  279. archives = result.scalars().all()
  280. # Import the response converter from archives module
  281. from backend.app.api.routes.archives import archive_to_response
  282. return [archive_to_response(a) for a in archives]
  283. @router.get("/{project_id}/queue")
  284. async def list_project_queue(
  285. project_id: int,
  286. db: AsyncSession = Depends(get_db),
  287. ):
  288. """List queue items in a project."""
  289. # Verify project exists
  290. result = await db.execute(select(Project).where(Project.id == project_id))
  291. if not result.scalar_one_or_none():
  292. raise HTTPException(status_code=404, detail="Project not found")
  293. # Get queue items
  294. query = (
  295. select(PrintQueueItem)
  296. .where(PrintQueueItem.project_id == project_id)
  297. .order_by(PrintQueueItem.position)
  298. )
  299. result = await db.execute(query)
  300. items = result.scalars().all()
  301. return items
  302. @router.post("/{project_id}/add-archives")
  303. async def add_archives_to_project(
  304. project_id: int,
  305. data: BatchAddArchives,
  306. db: AsyncSession = Depends(get_db),
  307. ):
  308. """Batch add archives to a project."""
  309. # Verify project exists
  310. result = await db.execute(select(Project).where(Project.id == project_id))
  311. if not result.scalar_one_or_none():
  312. raise HTTPException(status_code=404, detail="Project not found")
  313. # Update archives
  314. updated = 0
  315. for archive_id in data.archive_ids:
  316. result = await db.execute(
  317. select(PrintArchive).where(PrintArchive.id == archive_id)
  318. )
  319. archive = result.scalar_one_or_none()
  320. if archive:
  321. archive.project_id = project_id
  322. updated += 1
  323. return {"message": f"Added {updated} archives to project"}
  324. @router.post("/{project_id}/add-queue")
  325. async def add_queue_items_to_project(
  326. project_id: int,
  327. data: BatchAddQueueItems,
  328. db: AsyncSession = Depends(get_db),
  329. ):
  330. """Batch add queue items to a project."""
  331. # Verify project exists
  332. result = await db.execute(select(Project).where(Project.id == project_id))
  333. if not result.scalar_one_or_none():
  334. raise HTTPException(status_code=404, detail="Project not found")
  335. # Update queue items
  336. updated = 0
  337. for item_id in data.queue_item_ids:
  338. result = await db.execute(
  339. select(PrintQueueItem).where(PrintQueueItem.id == item_id)
  340. )
  341. item = result.scalar_one_or_none()
  342. if item:
  343. item.project_id = project_id
  344. updated += 1
  345. return {"message": f"Added {updated} queue items to project"}
  346. @router.post("/{project_id}/remove-archives")
  347. async def remove_archives_from_project(
  348. project_id: int,
  349. data: BatchAddArchives,
  350. db: AsyncSession = Depends(get_db),
  351. ):
  352. """Remove archives from a project (sets project_id to NULL)."""
  353. updated = 0
  354. for archive_id in data.archive_ids:
  355. result = await db.execute(
  356. select(PrintArchive).where(
  357. PrintArchive.id == archive_id,
  358. PrintArchive.project_id == project_id,
  359. )
  360. )
  361. archive = result.scalar_one_or_none()
  362. if archive:
  363. archive.project_id = None
  364. updated += 1
  365. return {"message": f"Removed {updated} archives from project"}