projects.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324
  1. import logging
  2. import os
  3. import uuid
  4. from datetime import datetime
  5. from pathlib import Path
  6. from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
  7. from fastapi.responses import FileResponse
  8. from sqlalchemy import case, func, select
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from sqlalchemy.orm import selectinload
  11. from backend.app.core.config import settings
  12. from backend.app.core.database import get_db
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.print_queue import PrintQueueItem
  15. from backend.app.models.project import Project
  16. from backend.app.models.project_bom import ProjectBOMItem
  17. from backend.app.schemas.project import (
  18. ArchivePreview,
  19. BatchAddArchives,
  20. BatchAddQueueItems,
  21. BOMItemCreate,
  22. BOMItemResponse,
  23. BOMItemUpdate,
  24. ProjectChildPreview,
  25. ProjectCreate,
  26. ProjectListResponse,
  27. ProjectResponse,
  28. ProjectStats,
  29. ProjectUpdate,
  30. TimelineEvent,
  31. )
  32. logger = logging.getLogger(__name__)
  33. router = APIRouter(prefix="/projects", tags=["projects"])
  34. async def compute_project_stats(
  35. db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
  36. ) -> ProjectStats:
  37. """Compute statistics for a project."""
  38. # Count total archives (distinct print jobs)
  39. total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
  40. total_archives = total_result.scalar() or 0
  41. # Sum total items (using quantity field)
  42. total_items_result = await db.execute(
  43. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
  44. )
  45. total_items = total_items_result.scalar() or 0
  46. # Count failed archives (number of print jobs) - includes all failure states
  47. failed_result = await db.execute(
  48. select(func.count(PrintArchive.id)).where(
  49. PrintArchive.project_id == project_id,
  50. PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
  51. )
  52. )
  53. failed_prints = failed_result.scalar() or 0
  54. # Sum print time, filament, and energy
  55. sums_result = await db.execute(
  56. select(
  57. func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
  58. func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
  59. func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
  60. func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
  61. func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
  62. ).where(PrintArchive.project_id == project_id)
  63. )
  64. sums = sums_result.first()
  65. # Count queued items
  66. queued_result = await db.execute(
  67. select(func.count(PrintQueueItem.id)).where(
  68. PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
  69. )
  70. )
  71. queued_prints = queued_result.scalar() or 0
  72. # Count in-progress items
  73. in_progress_result = await db.execute(
  74. select(func.count(PrintQueueItem.id)).where(
  75. PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
  76. )
  77. )
  78. in_progress_prints = in_progress_result.scalar() or 0
  79. # Sum completed items (parts) - sum of quantities for successful prints
  80. completed_items_result = await db.execute(
  81. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  82. PrintArchive.project_id == project_id,
  83. PrintArchive.status.in_(["completed", "archived"]),
  84. )
  85. )
  86. completed_items = int(completed_items_result.scalar() or 0)
  87. # Calculate progress for plates (target_count vs total_archives)
  88. progress_percent = None
  89. remaining_prints = None
  90. if target_count and target_count > 0:
  91. progress_percent = round((total_archives / target_count) * 100, 1)
  92. remaining_prints = max(0, target_count - total_archives)
  93. # Calculate progress for parts (target_parts_count vs completed_items)
  94. parts_progress_percent = None
  95. remaining_parts = None
  96. if target_parts_count and target_parts_count > 0:
  97. parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
  98. remaining_parts = max(0, target_parts_count - completed_items)
  99. # BOM stats
  100. bom_result = await db.execute(
  101. select(
  102. func.count(ProjectBOMItem.id).label("total"),
  103. func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
  104. "completed"
  105. ),
  106. ).where(ProjectBOMItem.project_id == project_id)
  107. )
  108. bom_stats = bom_result.first()
  109. return ProjectStats(
  110. total_archives=total_archives,
  111. total_items=int(total_items),
  112. completed_prints=completed_items, # Now reflects sum of quantities for completed prints
  113. failed_prints=int(failed_prints),
  114. queued_prints=queued_prints,
  115. in_progress_prints=in_progress_prints,
  116. total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
  117. total_filament_grams=round(sums.total_filament or 0, 2),
  118. progress_percent=progress_percent,
  119. parts_progress_percent=parts_progress_percent,
  120. estimated_cost=round((sums.total_filament_cost or 0), 2),
  121. total_energy_kwh=round((sums.total_energy or 0), 3),
  122. total_energy_cost=round((sums.total_energy_cost or 0), 2),
  123. remaining_prints=remaining_prints,
  124. remaining_parts=remaining_parts,
  125. bom_total_items=bom_stats.total or 0,
  126. bom_completed_items=int(bom_stats.completed or 0),
  127. )
  128. @router.get("", response_model=list[ProjectListResponse])
  129. @router.get("/", response_model=list[ProjectListResponse])
  130. async def list_projects(
  131. status: str | None = None,
  132. db: AsyncSession = Depends(get_db),
  133. ):
  134. """List all projects with basic stats."""
  135. query = select(Project)
  136. if status:
  137. query = query.where(Project.status == status)
  138. query = query.order_by(Project.updated_at.desc())
  139. result = await db.execute(query)
  140. projects = result.scalars().all()
  141. # Compute quick stats for each project
  142. response = []
  143. for project in projects:
  144. # Get archive count (number of print jobs)
  145. archive_count_result = await db.execute(
  146. select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
  147. )
  148. archive_count = archive_count_result.scalar() or 0
  149. # Get total items (sum of quantities)
  150. total_items_result = await db.execute(
  151. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
  152. )
  153. total_items = int(total_items_result.scalar() or 0)
  154. # Get queue count
  155. queue_count_result = await db.execute(
  156. select(func.count(PrintQueueItem.id)).where(
  157. PrintQueueItem.project_id == project.id,
  158. PrintQueueItem.status.in_(["pending", "printing"]),
  159. )
  160. )
  161. queue_count = queue_count_result.scalar() or 0
  162. # Sum completed parts (quantities) - includes "archived" as successful
  163. completed_result = await db.execute(
  164. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  165. PrintArchive.project_id == project.id,
  166. PrintArchive.status.in_(["completed", "archived"]),
  167. )
  168. )
  169. completed_count = int(completed_result.scalar() or 0)
  170. # Sum failed parts (quantities) - includes all failure states
  171. failed_result = await db.execute(
  172. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  173. PrintArchive.project_id == project.id,
  174. PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
  175. )
  176. )
  177. failed_count = int(failed_result.scalar() or 0)
  178. # Plates progress: archive_count / target_count
  179. progress_percent = None
  180. if project.target_count and project.target_count > 0:
  181. progress_percent = round((archive_count / project.target_count) * 100, 1)
  182. # Get archive previews (up to 6 most recent)
  183. archives_result = await db.execute(
  184. select(PrintArchive)
  185. .where(PrintArchive.project_id == project.id)
  186. .order_by(PrintArchive.created_at.desc())
  187. .limit(6)
  188. )
  189. archives = archives_result.scalars().all()
  190. archive_previews = [
  191. ArchivePreview(
  192. id=a.id,
  193. print_name=a.print_name,
  194. thumbnail_path=a.thumbnail_path,
  195. status=a.status,
  196. filament_type=a.filament_type,
  197. filament_color=a.filament_color,
  198. )
  199. for a in archives
  200. ]
  201. response.append(
  202. ProjectListResponse(
  203. id=project.id,
  204. name=project.name,
  205. description=project.description,
  206. color=project.color,
  207. status=project.status,
  208. target_count=project.target_count,
  209. target_parts_count=project.target_parts_count,
  210. created_at=project.created_at,
  211. archive_count=archive_count,
  212. total_items=total_items,
  213. completed_count=completed_count,
  214. failed_count=failed_count,
  215. queue_count=queue_count,
  216. progress_percent=progress_percent,
  217. archives=archive_previews,
  218. )
  219. )
  220. return response
  221. @router.post("/", response_model=ProjectResponse)
  222. async def create_project(
  223. data: ProjectCreate,
  224. db: AsyncSession = Depends(get_db),
  225. ):
  226. """Create a new project."""
  227. # Verify parent exists if specified
  228. parent_name = None
  229. if data.parent_id:
  230. parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
  231. parent = parent_result.scalar_one_or_none()
  232. if not parent:
  233. raise HTTPException(status_code=400, detail="Parent project not found")
  234. parent_name = parent.name
  235. project = Project(
  236. name=data.name,
  237. description=data.description,
  238. color=data.color,
  239. target_count=data.target_count,
  240. target_parts_count=data.target_parts_count,
  241. notes=data.notes,
  242. tags=data.tags,
  243. due_date=data.due_date,
  244. priority=data.priority,
  245. budget=data.budget,
  246. parent_id=data.parent_id,
  247. )
  248. db.add(project)
  249. await db.flush()
  250. await db.refresh(project)
  251. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  252. return ProjectResponse(
  253. id=project.id,
  254. name=project.name,
  255. description=project.description,
  256. color=project.color,
  257. status=project.status,
  258. target_count=project.target_count,
  259. target_parts_count=project.target_parts_count,
  260. notes=project.notes,
  261. attachments=project.attachments,
  262. tags=project.tags,
  263. due_date=project.due_date,
  264. priority=project.priority,
  265. budget=project.budget,
  266. is_template=project.is_template,
  267. template_source_id=project.template_source_id,
  268. parent_id=project.parent_id,
  269. parent_name=parent_name,
  270. children=[],
  271. created_at=project.created_at,
  272. updated_at=project.updated_at,
  273. stats=stats,
  274. )
  275. # ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============
  276. @router.get("/templates", response_model=list[ProjectListResponse])
  277. async def list_templates(
  278. db: AsyncSession = Depends(get_db),
  279. ):
  280. """List all project templates."""
  281. result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
  282. templates = result.scalars().all()
  283. response = []
  284. for project in templates:
  285. # Get archive count
  286. archive_count_result = await db.execute(
  287. select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
  288. )
  289. archive_count = archive_count_result.scalar() or 0
  290. response.append(
  291. ProjectListResponse(
  292. id=project.id,
  293. name=project.name,
  294. description=project.description,
  295. color=project.color,
  296. status=project.status,
  297. target_count=project.target_count,
  298. created_at=project.created_at,
  299. archive_count=archive_count,
  300. queue_count=0,
  301. progress_percent=None,
  302. archives=[],
  303. )
  304. )
  305. return response
  306. @router.post("/from-template/{template_id}", response_model=ProjectResponse)
  307. async def create_project_from_template(
  308. template_id: int,
  309. name: str = None,
  310. db: AsyncSession = Depends(get_db),
  311. ):
  312. """Create a new project from a template."""
  313. result = await db.execute(select(Project).where(Project.id == template_id))
  314. template = result.scalar_one_or_none()
  315. if not template:
  316. raise HTTPException(status_code=404, detail="Template not found")
  317. if not template.is_template:
  318. raise HTTPException(status_code=400, detail="Project is not a template")
  319. # Create new project
  320. project = Project(
  321. name=name or template.name.replace(" (Template)", ""),
  322. description=template.description,
  323. color=template.color,
  324. target_count=template.target_count,
  325. target_parts_count=template.target_parts_count,
  326. notes=template.notes,
  327. tags=template.tags,
  328. priority=template.priority,
  329. budget=template.budget,
  330. is_template=False,
  331. template_source_id=template.id,
  332. )
  333. db.add(project)
  334. await db.flush()
  335. # Copy BOM items
  336. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))
  337. bom_items = bom_result.scalars().all()
  338. for item in bom_items:
  339. new_item = ProjectBOMItem(
  340. project_id=project.id,
  341. name=item.name,
  342. quantity_needed=item.quantity_needed,
  343. quantity_acquired=0,
  344. unit_price=item.unit_price,
  345. sourcing_url=item.sourcing_url,
  346. stl_filename=item.stl_filename,
  347. remarks=item.remarks,
  348. sort_order=item.sort_order,
  349. )
  350. db.add(new_item)
  351. await db.flush()
  352. await db.refresh(project)
  353. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  354. return ProjectResponse(
  355. id=project.id,
  356. name=project.name,
  357. description=project.description,
  358. color=project.color,
  359. status=project.status,
  360. target_count=project.target_count,
  361. target_parts_count=project.target_parts_count,
  362. notes=project.notes,
  363. attachments=project.attachments,
  364. tags=project.tags,
  365. due_date=project.due_date,
  366. priority=project.priority,
  367. budget=project.budget,
  368. is_template=project.is_template,
  369. template_source_id=project.template_source_id,
  370. parent_id=project.parent_id,
  371. parent_name=None,
  372. children=[],
  373. created_at=project.created_at,
  374. updated_at=project.updated_at,
  375. stats=stats,
  376. )
  377. # ============ Dynamic {project_id} Routes ============
  378. async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
  379. """Get preview info for child projects."""
  380. result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
  381. children = result.scalars().all()
  382. previews = []
  383. for child in children:
  384. # Get completed count for progress (sum of quantities)
  385. completed_result = await db.execute(
  386. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  387. PrintArchive.project_id == child.id,
  388. PrintArchive.status == "completed",
  389. )
  390. )
  391. completed_count = completed_result.scalar() or 0
  392. progress = None
  393. if child.target_count and child.target_count > 0:
  394. progress = round((int(completed_count) / child.target_count) * 100, 1)
  395. previews.append(
  396. ProjectChildPreview(
  397. id=child.id,
  398. name=child.name,
  399. color=child.color,
  400. status=child.status,
  401. progress_percent=progress,
  402. )
  403. )
  404. return previews
  405. @router.get("/{project_id}", response_model=ProjectResponse)
  406. async def get_project(
  407. project_id: int,
  408. db: AsyncSession = Depends(get_db),
  409. ):
  410. """Get a project by ID with detailed stats."""
  411. result = await db.execute(select(Project).where(Project.id == project_id))
  412. project = result.scalar_one_or_none()
  413. if not project:
  414. raise HTTPException(status_code=404, detail="Project not found")
  415. # Get parent name
  416. parent_name = None
  417. if project.parent_id:
  418. parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
  419. parent_name = parent_result.scalar()
  420. # Get children
  421. children = await get_child_previews(db, project.id)
  422. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  423. return ProjectResponse(
  424. id=project.id,
  425. name=project.name,
  426. description=project.description,
  427. color=project.color,
  428. status=project.status,
  429. target_count=project.target_count,
  430. target_parts_count=project.target_parts_count,
  431. notes=project.notes,
  432. attachments=project.attachments,
  433. tags=project.tags,
  434. due_date=project.due_date,
  435. priority=project.priority,
  436. budget=project.budget,
  437. is_template=project.is_template,
  438. template_source_id=project.template_source_id,
  439. parent_id=project.parent_id,
  440. parent_name=parent_name,
  441. children=children,
  442. created_at=project.created_at,
  443. updated_at=project.updated_at,
  444. stats=stats,
  445. )
  446. @router.patch("/{project_id}", response_model=ProjectResponse)
  447. async def update_project(
  448. project_id: int,
  449. data: ProjectUpdate,
  450. db: AsyncSession = Depends(get_db),
  451. ):
  452. """Update a project."""
  453. result = await db.execute(select(Project).where(Project.id == project_id))
  454. project = result.scalar_one_or_none()
  455. if not project:
  456. raise HTTPException(status_code=404, detail="Project not found")
  457. # Update fields if provided
  458. if data.name is not None:
  459. project.name = data.name
  460. if data.description is not None:
  461. project.description = data.description
  462. if data.color is not None:
  463. project.color = data.color
  464. if data.status is not None:
  465. if data.status not in ["active", "completed", "archived"]:
  466. raise HTTPException(status_code=400, detail="Invalid status")
  467. project.status = data.status
  468. if data.target_count is not None:
  469. project.target_count = data.target_count
  470. if data.target_parts_count is not None:
  471. project.target_parts_count = data.target_parts_count
  472. if data.notes is not None:
  473. project.notes = data.notes
  474. if data.tags is not None:
  475. project.tags = data.tags
  476. if data.due_date is not None:
  477. project.due_date = data.due_date
  478. if data.priority is not None:
  479. if data.priority not in ["low", "normal", "high", "urgent"]:
  480. raise HTTPException(status_code=400, detail="Invalid priority")
  481. project.priority = data.priority
  482. if data.budget is not None:
  483. project.budget = data.budget
  484. if data.parent_id is not None:
  485. # Verify parent exists and prevent circular reference
  486. if data.parent_id == project_id:
  487. raise HTTPException(status_code=400, detail="Project cannot be its own parent")
  488. if data.parent_id != 0: # 0 means remove parent
  489. parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
  490. if not parent_result.scalar_one_or_none():
  491. raise HTTPException(status_code=400, detail="Parent project not found")
  492. project.parent_id = data.parent_id
  493. else:
  494. project.parent_id = None
  495. await db.flush()
  496. await db.refresh(project)
  497. # Get parent name
  498. parent_name = None
  499. if project.parent_id:
  500. parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
  501. parent_name = parent_result.scalar()
  502. # Get children
  503. children = await get_child_previews(db, project.id)
  504. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  505. return ProjectResponse(
  506. id=project.id,
  507. name=project.name,
  508. description=project.description,
  509. color=project.color,
  510. status=project.status,
  511. target_count=project.target_count,
  512. target_parts_count=project.target_parts_count,
  513. notes=project.notes,
  514. attachments=project.attachments,
  515. tags=project.tags,
  516. due_date=project.due_date,
  517. priority=project.priority,
  518. budget=project.budget,
  519. is_template=project.is_template,
  520. template_source_id=project.template_source_id,
  521. parent_id=project.parent_id,
  522. parent_name=parent_name,
  523. children=children,
  524. created_at=project.created_at,
  525. updated_at=project.updated_at,
  526. stats=stats,
  527. )
  528. @router.delete("/{project_id}")
  529. async def delete_project(
  530. project_id: int,
  531. db: AsyncSession = Depends(get_db),
  532. ):
  533. """Delete a project. Archives and queue items will have project_id set to NULL."""
  534. result = await db.execute(select(Project).where(Project.id == project_id))
  535. project = result.scalar_one_or_none()
  536. if not project:
  537. raise HTTPException(status_code=404, detail="Project not found")
  538. await db.delete(project)
  539. return {"message": "Project deleted"}
  540. @router.get("/{project_id}/archives")
  541. async def list_project_archives(
  542. project_id: int,
  543. limit: int = 100,
  544. offset: int = 0,
  545. db: AsyncSession = Depends(get_db),
  546. ):
  547. """List archives in a project."""
  548. # Verify project exists
  549. result = await db.execute(select(Project).where(Project.id == project_id))
  550. if not result.scalar_one_or_none():
  551. raise HTTPException(status_code=404, detail="Project not found")
  552. # Get archives with project relationship eagerly loaded
  553. query = (
  554. select(PrintArchive)
  555. .options(selectinload(PrintArchive.project))
  556. .where(PrintArchive.project_id == project_id)
  557. .order_by(PrintArchive.created_at.desc())
  558. .limit(limit)
  559. .offset(offset)
  560. )
  561. result = await db.execute(query)
  562. archives = result.scalars().all()
  563. # Import the response converter from archives module
  564. from backend.app.api.routes.archives import archive_to_response
  565. return [archive_to_response(a) for a in archives]
  566. @router.get("/{project_id}/queue")
  567. async def list_project_queue(
  568. project_id: int,
  569. db: AsyncSession = Depends(get_db),
  570. ):
  571. """List queue items in a project."""
  572. # Verify project exists
  573. result = await db.execute(select(Project).where(Project.id == project_id))
  574. if not result.scalar_one_or_none():
  575. raise HTTPException(status_code=404, detail="Project not found")
  576. # Get queue items
  577. query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
  578. result = await db.execute(query)
  579. items = result.scalars().all()
  580. return items
  581. @router.post("/{project_id}/add-archives")
  582. async def add_archives_to_project(
  583. project_id: int,
  584. data: BatchAddArchives,
  585. db: AsyncSession = Depends(get_db),
  586. ):
  587. """Batch add archives to a project."""
  588. # Verify project exists
  589. result = await db.execute(select(Project).where(Project.id == project_id))
  590. if not result.scalar_one_or_none():
  591. raise HTTPException(status_code=404, detail="Project not found")
  592. # Update archives
  593. updated = 0
  594. for archive_id in data.archive_ids:
  595. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  596. archive = result.scalar_one_or_none()
  597. if archive:
  598. archive.project_id = project_id
  599. updated += 1
  600. return {"message": f"Added {updated} archives to project"}
  601. @router.post("/{project_id}/add-queue")
  602. async def add_queue_items_to_project(
  603. project_id: int,
  604. data: BatchAddQueueItems,
  605. db: AsyncSession = Depends(get_db),
  606. ):
  607. """Batch add queue items to a project."""
  608. # Verify project exists
  609. result = await db.execute(select(Project).where(Project.id == project_id))
  610. if not result.scalar_one_or_none():
  611. raise HTTPException(status_code=404, detail="Project not found")
  612. # Update queue items
  613. updated = 0
  614. for item_id in data.queue_item_ids:
  615. result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
  616. item = result.scalar_one_or_none()
  617. if item:
  618. item.project_id = project_id
  619. updated += 1
  620. return {"message": f"Added {updated} queue items to project"}
  621. @router.post("/{project_id}/remove-archives")
  622. async def remove_archives_from_project(
  623. project_id: int,
  624. data: BatchAddArchives,
  625. db: AsyncSession = Depends(get_db),
  626. ):
  627. """Remove archives from a project (sets project_id to NULL)."""
  628. updated = 0
  629. for archive_id in data.archive_ids:
  630. result = await db.execute(
  631. select(PrintArchive).where(
  632. PrintArchive.id == archive_id,
  633. PrintArchive.project_id == project_id,
  634. )
  635. )
  636. archive = result.scalar_one_or_none()
  637. if archive:
  638. archive.project_id = None
  639. updated += 1
  640. return {"message": f"Removed {updated} archives from project"}
  641. def get_project_attachments_dir(project_id: int) -> Path:
  642. """Get the attachments directory for a project."""
  643. base_dir = Path(settings.archive_dir)
  644. return base_dir / "projects" / str(project_id) / "attachments"
  645. # Allowed file extensions for attachments
  646. ALLOWED_ATTACHMENT_EXTENSIONS = {
  647. # Images
  648. ".jpg",
  649. ".jpeg",
  650. ".png",
  651. ".gif",
  652. ".webp",
  653. ".svg",
  654. ".bmp",
  655. ".ico",
  656. # Documents
  657. ".pdf",
  658. ".doc",
  659. ".docx",
  660. ".xls",
  661. ".xlsx",
  662. ".ppt",
  663. ".pptx",
  664. ".odt",
  665. ".ods",
  666. ".odp",
  667. ".txt",
  668. ".rtf",
  669. ".csv",
  670. ".md",
  671. # 3D/CAD files
  672. ".stl",
  673. ".obj",
  674. ".3mf",
  675. ".step",
  676. ".stp",
  677. ".iges",
  678. ".igs",
  679. ".f3d",
  680. ".scad",
  681. # Archives
  682. ".zip",
  683. ".rar",
  684. ".7z",
  685. ".tar",
  686. ".gz",
  687. # Code/scripts (for Klipper macros, scripts, etc.)
  688. ".py",
  689. ".sh",
  690. ".cfg",
  691. ".conf",
  692. ".gcode",
  693. ".ini",
  694. # Other common formats
  695. ".json",
  696. ".xml",
  697. ".yaml",
  698. ".yml",
  699. }
  700. @router.post("/{project_id}/attachments")
  701. async def upload_attachment(
  702. project_id: int,
  703. file: UploadFile = File(...),
  704. db: AsyncSession = Depends(get_db),
  705. ):
  706. """Upload an attachment to a project."""
  707. logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
  708. # Verify project exists
  709. result = await db.execute(select(Project).where(Project.id == project_id))
  710. project = result.scalar_one_or_none()
  711. if not project:
  712. raise HTTPException(status_code=404, detail="Project not found")
  713. # Validate file extension
  714. original_name = file.filename or "unknown"
  715. ext = os.path.splitext(original_name)[1].lower()
  716. if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
  717. raise HTTPException(
  718. status_code=400,
  719. detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
  720. )
  721. # Create attachments directory
  722. attachments_dir = get_project_attachments_dir(project_id)
  723. attachments_dir.mkdir(parents=True, exist_ok=True)
  724. # Generate unique filename
  725. unique_filename = f"{uuid.uuid4().hex}{ext}"
  726. file_path = attachments_dir / unique_filename
  727. # Save file
  728. try:
  729. with open(file_path, "wb") as f:
  730. content = await file.read()
  731. f.write(content)
  732. logger.info(f"=== FILE SAVED: {file_path}, size: {len(content)} ===")
  733. except Exception as e:
  734. logger.error(f"Failed to save attachment: {e}")
  735. raise HTTPException(status_code=500, detail="Failed to save attachment")
  736. # Update project attachments JSON
  737. attachments = list(project.attachments or [])
  738. new_attachment = {
  739. "filename": unique_filename,
  740. "original_name": original_name,
  741. "size": len(content),
  742. "uploaded_at": datetime.now().isoformat(),
  743. }
  744. attachments.append(new_attachment)
  745. # Simple ORM update
  746. project.attachments = attachments
  747. db.add(project) # Explicitly add to session
  748. logger.info(f"=== BEFORE COMMIT: {len(attachments)} attachments ===")
  749. await db.flush()
  750. await db.commit()
  751. logger.info("=== AFTER COMMIT ===")
  752. # Verify by re-querying
  753. result = await db.execute(select(Project).where(Project.id == project_id))
  754. fresh_project = result.scalar_one()
  755. logger.info(f"=== VERIFIED: {len(fresh_project.attachments or [])} attachments ===")
  756. return {
  757. "status": "success",
  758. "filename": unique_filename,
  759. "original_name": original_name,
  760. "attachments": fresh_project.attachments,
  761. }
  762. @router.get("/{project_id}/attachments/{filename}")
  763. async def download_attachment(
  764. project_id: int,
  765. filename: str,
  766. db: AsyncSession = Depends(get_db),
  767. ):
  768. """Download an attachment from a project."""
  769. # Verify project exists
  770. result = await db.execute(select(Project).where(Project.id == project_id))
  771. project = result.scalar_one_or_none()
  772. if not project:
  773. raise HTTPException(status_code=404, detail="Project not found")
  774. # Verify attachment exists in project
  775. attachments = project.attachments or []
  776. attachment = next((a for a in attachments if a.get("filename") == filename), None)
  777. if not attachment:
  778. raise HTTPException(status_code=404, detail="Attachment not found")
  779. # Check file exists
  780. file_path = get_project_attachments_dir(project_id) / filename
  781. if not file_path.exists():
  782. raise HTTPException(status_code=404, detail="Attachment file not found")
  783. return FileResponse(
  784. file_path,
  785. filename=attachment.get("original_name", filename),
  786. media_type="application/octet-stream",
  787. )
  788. @router.delete("/{project_id}/attachments/{filename}")
  789. async def delete_attachment(
  790. project_id: int,
  791. filename: str,
  792. db: AsyncSession = Depends(get_db),
  793. ):
  794. """Delete an attachment from a project."""
  795. # Verify project exists
  796. result = await db.execute(select(Project).where(Project.id == project_id))
  797. project = result.scalar_one_or_none()
  798. if not project:
  799. raise HTTPException(status_code=404, detail="Project not found")
  800. # Find and remove attachment from list
  801. attachments = project.attachments or []
  802. attachment = next((a for a in attachments if a.get("filename") == filename), None)
  803. if not attachment:
  804. raise HTTPException(status_code=404, detail="Attachment not found")
  805. # Remove from list
  806. attachments = [a for a in attachments if a.get("filename") != filename]
  807. project.attachments = attachments if attachments else None
  808. # Delete file
  809. file_path = get_project_attachments_dir(project_id) / filename
  810. if file_path.exists():
  811. try:
  812. os.remove(file_path)
  813. except Exception as e:
  814. logger.warning(f"Failed to delete attachment file: {e}")
  815. await db.flush()
  816. await db.refresh(project)
  817. return {
  818. "status": "success",
  819. "message": "Attachment deleted",
  820. "attachments": project.attachments,
  821. }
  822. # ============ Phase 7: BOM Endpoints ============
  823. @router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
  824. async def list_bom_items(
  825. project_id: int,
  826. db: AsyncSession = Depends(get_db),
  827. ):
  828. """List all BOM items for a project."""
  829. # Verify project exists
  830. result = await db.execute(select(Project).where(Project.id == project_id))
  831. if not result.scalar_one_or_none():
  832. raise HTTPException(status_code=404, detail="Project not found")
  833. # Get BOM items
  834. result = await db.execute(
  835. select(ProjectBOMItem)
  836. .where(ProjectBOMItem.project_id == project_id)
  837. .order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)
  838. )
  839. items = result.scalars().all()
  840. response = []
  841. for item in items:
  842. # Get archive name if linked
  843. archive_name = None
  844. if item.archive_id:
  845. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  846. archive_name = archive_result.scalar()
  847. response.append(
  848. BOMItemResponse(
  849. id=item.id,
  850. project_id=item.project_id,
  851. name=item.name,
  852. quantity_needed=item.quantity_needed,
  853. quantity_acquired=item.quantity_acquired,
  854. unit_price=item.unit_price,
  855. sourcing_url=item.sourcing_url,
  856. archive_id=item.archive_id,
  857. archive_name=archive_name,
  858. stl_filename=item.stl_filename,
  859. remarks=item.remarks,
  860. sort_order=item.sort_order,
  861. is_complete=item.quantity_acquired >= item.quantity_needed,
  862. created_at=item.created_at,
  863. updated_at=item.updated_at,
  864. )
  865. )
  866. return response
  867. @router.post("/{project_id}/bom", response_model=BOMItemResponse)
  868. async def create_bom_item(
  869. project_id: int,
  870. data: BOMItemCreate,
  871. db: AsyncSession = Depends(get_db),
  872. ):
  873. """Add a BOM item to a project."""
  874. # Verify project exists
  875. result = await db.execute(select(Project).where(Project.id == project_id))
  876. if not result.scalar_one_or_none():
  877. raise HTTPException(status_code=404, detail="Project not found")
  878. # Get max sort order
  879. max_order_result = await db.execute(
  880. select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)
  881. )
  882. max_order = max_order_result.scalar() or 0
  883. item = ProjectBOMItem(
  884. project_id=project_id,
  885. name=data.name,
  886. quantity_needed=data.quantity_needed,
  887. unit_price=data.unit_price,
  888. sourcing_url=data.sourcing_url,
  889. archive_id=data.archive_id,
  890. stl_filename=data.stl_filename,
  891. remarks=data.remarks,
  892. sort_order=max_order + 1,
  893. )
  894. db.add(item)
  895. await db.flush()
  896. await db.refresh(item)
  897. # Get archive name if linked
  898. archive_name = None
  899. if item.archive_id:
  900. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  901. archive_name = archive_result.scalar()
  902. return BOMItemResponse(
  903. id=item.id,
  904. project_id=item.project_id,
  905. name=item.name,
  906. quantity_needed=item.quantity_needed,
  907. quantity_acquired=item.quantity_acquired,
  908. unit_price=item.unit_price,
  909. sourcing_url=item.sourcing_url,
  910. archive_id=item.archive_id,
  911. archive_name=archive_name,
  912. stl_filename=item.stl_filename,
  913. remarks=item.remarks,
  914. sort_order=item.sort_order,
  915. is_complete=item.quantity_acquired >= item.quantity_needed,
  916. created_at=item.created_at,
  917. updated_at=item.updated_at,
  918. )
  919. @router.patch("/{project_id}/bom/{item_id}", response_model=BOMItemResponse)
  920. async def update_bom_item(
  921. project_id: int,
  922. item_id: int,
  923. data: BOMItemUpdate,
  924. db: AsyncSession = Depends(get_db),
  925. ):
  926. """Update a BOM item."""
  927. result = await db.execute(
  928. select(ProjectBOMItem).where(
  929. ProjectBOMItem.id == item_id,
  930. ProjectBOMItem.project_id == project_id,
  931. )
  932. )
  933. item = result.scalar_one_or_none()
  934. if not item:
  935. raise HTTPException(status_code=404, detail="BOM item not found")
  936. if data.name is not None:
  937. item.name = data.name
  938. if data.quantity_needed is not None:
  939. item.quantity_needed = data.quantity_needed
  940. if data.quantity_acquired is not None:
  941. item.quantity_acquired = data.quantity_acquired
  942. if data.unit_price is not None:
  943. item.unit_price = data.unit_price if data.unit_price != 0 else None
  944. if data.sourcing_url is not None:
  945. item.sourcing_url = data.sourcing_url if data.sourcing_url else None
  946. if data.archive_id is not None:
  947. item.archive_id = data.archive_id if data.archive_id != 0 else None
  948. if data.stl_filename is not None:
  949. item.stl_filename = data.stl_filename if data.stl_filename else None
  950. if data.remarks is not None:
  951. item.remarks = data.remarks if data.remarks else None
  952. await db.flush()
  953. await db.refresh(item)
  954. # Get archive name if linked
  955. archive_name = None
  956. if item.archive_id:
  957. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  958. archive_name = archive_result.scalar()
  959. return BOMItemResponse(
  960. id=item.id,
  961. project_id=item.project_id,
  962. name=item.name,
  963. quantity_needed=item.quantity_needed,
  964. quantity_acquired=item.quantity_acquired,
  965. unit_price=item.unit_price,
  966. sourcing_url=item.sourcing_url,
  967. archive_id=item.archive_id,
  968. archive_name=archive_name,
  969. stl_filename=item.stl_filename,
  970. remarks=item.remarks,
  971. sort_order=item.sort_order,
  972. is_complete=item.quantity_acquired >= item.quantity_needed,
  973. created_at=item.created_at,
  974. updated_at=item.updated_at,
  975. )
  976. @router.delete("/{project_id}/bom/{item_id}")
  977. async def delete_bom_item(
  978. project_id: int,
  979. item_id: int,
  980. db: AsyncSession = Depends(get_db),
  981. ):
  982. """Delete a BOM item."""
  983. result = await db.execute(
  984. select(ProjectBOMItem).where(
  985. ProjectBOMItem.id == item_id,
  986. ProjectBOMItem.project_id == project_id,
  987. )
  988. )
  989. item = result.scalar_one_or_none()
  990. if not item:
  991. raise HTTPException(status_code=404, detail="BOM item not found")
  992. await db.delete(item)
  993. return {"status": "success", "message": "BOM item deleted"}
  994. @router.post("/{project_id}/create-template", response_model=ProjectResponse)
  995. async def create_template_from_project(
  996. project_id: int,
  997. db: AsyncSession = Depends(get_db),
  998. ):
  999. """Create a template from an existing project."""
  1000. result = await db.execute(select(Project).where(Project.id == project_id))
  1001. source = result.scalar_one_or_none()
  1002. if not source:
  1003. raise HTTPException(status_code=404, detail="Project not found")
  1004. # Create template
  1005. template = Project(
  1006. name=f"{source.name} (Template)",
  1007. description=source.description,
  1008. color=source.color,
  1009. target_count=source.target_count,
  1010. target_parts_count=source.target_parts_count,
  1011. notes=source.notes,
  1012. tags=source.tags,
  1013. priority=source.priority,
  1014. budget=source.budget,
  1015. is_template=True,
  1016. template_source_id=source.id,
  1017. )
  1018. db.add(template)
  1019. await db.flush()
  1020. # Copy BOM items
  1021. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))
  1022. bom_items = bom_result.scalars().all()
  1023. for item in bom_items:
  1024. new_item = ProjectBOMItem(
  1025. project_id=template.id,
  1026. name=item.name,
  1027. quantity_needed=item.quantity_needed,
  1028. quantity_acquired=0,
  1029. unit_price=item.unit_price,
  1030. sourcing_url=item.sourcing_url,
  1031. stl_filename=item.stl_filename,
  1032. remarks=item.remarks,
  1033. sort_order=item.sort_order,
  1034. )
  1035. db.add(new_item)
  1036. await db.flush()
  1037. await db.refresh(template)
  1038. stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)
  1039. return ProjectResponse(
  1040. id=template.id,
  1041. name=template.name,
  1042. description=template.description,
  1043. color=template.color,
  1044. status=template.status,
  1045. target_count=template.target_count,
  1046. target_parts_count=template.target_parts_count,
  1047. notes=template.notes,
  1048. attachments=template.attachments,
  1049. tags=template.tags,
  1050. due_date=template.due_date,
  1051. priority=template.priority,
  1052. budget=template.budget,
  1053. is_template=template.is_template,
  1054. template_source_id=template.template_source_id,
  1055. parent_id=template.parent_id,
  1056. parent_name=None,
  1057. children=[],
  1058. created_at=template.created_at,
  1059. updated_at=template.updated_at,
  1060. stats=stats,
  1061. )
  1062. # ============ Phase 9: Timeline Endpoint ============
  1063. @router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
  1064. async def get_project_timeline(
  1065. project_id: int,
  1066. limit: int = 50,
  1067. db: AsyncSession = Depends(get_db),
  1068. ):
  1069. """Get timeline of events for a project."""
  1070. # Verify project exists
  1071. result = await db.execute(select(Project).where(Project.id == project_id))
  1072. project = result.scalar_one_or_none()
  1073. if not project:
  1074. raise HTTPException(status_code=404, detail="Project not found")
  1075. events = []
  1076. # Project creation event
  1077. events.append(
  1078. TimelineEvent(
  1079. event_type="project_created",
  1080. timestamp=project.created_at,
  1081. title="Project created",
  1082. description=f"Project '{project.name}' was created",
  1083. )
  1084. )
  1085. # Get archives and add events
  1086. archives_result = await db.execute(
  1087. select(PrintArchive)
  1088. .where(PrintArchive.project_id == project_id)
  1089. .order_by(PrintArchive.created_at.desc())
  1090. .limit(limit)
  1091. )
  1092. archives = archives_result.scalars().all()
  1093. for archive in archives:
  1094. if archive.status == "completed":
  1095. events.append(
  1096. TimelineEvent(
  1097. event_type="print_completed",
  1098. timestamp=archive.completed_at or archive.created_at,
  1099. title="Print completed",
  1100. description=archive.print_name,
  1101. metadata={
  1102. "archive_id": archive.id,
  1103. "print_time_hours": round((archive.print_time_seconds or 0) / 3600, 2),
  1104. "filament_grams": round(archive.filament_used_grams or 0, 1),
  1105. },
  1106. )
  1107. )
  1108. elif archive.status == "failed":
  1109. events.append(
  1110. TimelineEvent(
  1111. event_type="print_failed",
  1112. timestamp=archive.completed_at or archive.created_at,
  1113. title="Print failed",
  1114. description=archive.print_name,
  1115. metadata={"archive_id": archive.id},
  1116. )
  1117. )
  1118. # Get queue items
  1119. queue_result = await db.execute(
  1120. select(PrintQueueItem)
  1121. .where(PrintQueueItem.project_id == project_id)
  1122. .order_by(PrintQueueItem.created_at.desc())
  1123. .limit(limit)
  1124. )
  1125. queue_items = queue_result.scalars().all()
  1126. for item in queue_items:
  1127. if item.status == "printing":
  1128. events.append(
  1129. TimelineEvent(
  1130. event_type="print_started",
  1131. timestamp=item.started_at or item.created_at,
  1132. title="Print started",
  1133. description=item.print_name,
  1134. metadata={"queue_item_id": item.id},
  1135. )
  1136. )
  1137. elif item.status == "pending":
  1138. events.append(
  1139. TimelineEvent(
  1140. event_type="queued",
  1141. timestamp=item.created_at,
  1142. title="Added to queue",
  1143. description=item.print_name,
  1144. metadata={"queue_item_id": item.id},
  1145. )
  1146. )
  1147. # Sort by timestamp descending
  1148. events.sort(key=lambda e: e.timestamp, reverse=True)
  1149. return events[:limit]