projects.py 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758
  1. import io
  2. import json
  3. import logging
  4. import os
  5. import uuid
  6. import zipfile
  7. from datetime import datetime
  8. from pathlib import Path
  9. from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
  10. from fastapi.responses import FileResponse, StreamingResponse
  11. from sqlalchemy import case, func, select
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from sqlalchemy.orm import selectinload
  14. from backend.app.api.routes.library import get_library_dir
  15. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  16. from backend.app.core.config import settings
  17. from backend.app.core.database import get_db
  18. from backend.app.core.permissions import Permission
  19. from backend.app.models.archive import PrintArchive
  20. from backend.app.models.library import LibraryFile, LibraryFolder
  21. from backend.app.models.print_queue import PrintQueueItem
  22. from backend.app.models.project import Project
  23. from backend.app.models.project_bom import ProjectBOMItem
  24. from backend.app.models.user import User
  25. from backend.app.schemas.project import (
  26. ArchivePreview,
  27. BatchAddArchives,
  28. BatchAddQueueItems,
  29. BOMItemCreate,
  30. BOMItemResponse,
  31. BOMItemUpdate,
  32. ProjectChildPreview,
  33. ProjectCreate,
  34. ProjectImport,
  35. ProjectListResponse,
  36. ProjectResponse,
  37. ProjectStats,
  38. ProjectUpdate,
  39. TimelineEvent,
  40. )
  41. logger = logging.getLogger(__name__)
  42. router = APIRouter(prefix="/projects", tags=["projects"])
  43. async def compute_project_stats(
  44. db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
  45. ) -> ProjectStats:
  46. """Compute statistics for a project."""
  47. # Count total archives (distinct print jobs)
  48. total_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id))
  49. total_archives = total_result.scalar() or 0
  50. # Sum total items (using quantity field)
  51. total_items_result = await db.execute(
  52. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project_id)
  53. )
  54. total_items = total_items_result.scalar() or 0
  55. # Count failed archives (number of print jobs) - includes all failure states
  56. failed_result = await db.execute(
  57. select(func.count(PrintArchive.id)).where(
  58. PrintArchive.project_id == project_id,
  59. PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
  60. )
  61. )
  62. failed_prints = failed_result.scalar() or 0
  63. # Sum print time, filament, and energy
  64. sums_result = await db.execute(
  65. select(
  66. func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
  67. func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
  68. func.coalesce(func.sum(PrintArchive.cost), 0).label("total_filament_cost"),
  69. func.coalesce(func.sum(PrintArchive.energy_kwh), 0).label("total_energy"),
  70. func.coalesce(func.sum(PrintArchive.energy_cost), 0).label("total_energy_cost"),
  71. ).where(PrintArchive.project_id == project_id)
  72. )
  73. sums = sums_result.first()
  74. # Count queued items
  75. queued_result = await db.execute(
  76. select(func.count(PrintQueueItem.id)).where(
  77. PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
  78. )
  79. )
  80. queued_prints = queued_result.scalar() or 0
  81. # Count in-progress items
  82. in_progress_result = await db.execute(
  83. select(func.count(PrintQueueItem.id)).where(
  84. PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
  85. )
  86. )
  87. in_progress_prints = in_progress_result.scalar() or 0
  88. # Sum completed items (parts) - sum of quantities for actually printed jobs
  89. completed_items_result = await db.execute(
  90. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  91. PrintArchive.project_id == project_id,
  92. PrintArchive.status == "completed",
  93. )
  94. )
  95. completed_items = int(completed_items_result.scalar() or 0)
  96. # Calculate progress for plates (target_count vs total_archives)
  97. progress_percent = None
  98. remaining_prints = None
  99. if target_count and target_count > 0:
  100. progress_percent = round((total_archives / target_count) * 100, 1)
  101. remaining_prints = max(0, target_count - total_archives)
  102. # Calculate progress for parts (target_parts_count vs completed_items)
  103. parts_progress_percent = None
  104. remaining_parts = None
  105. if target_parts_count and target_parts_count > 0:
  106. parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
  107. remaining_parts = max(0, target_parts_count - completed_items)
  108. # BOM stats
  109. bom_result = await db.execute(
  110. select(
  111. func.count(ProjectBOMItem.id).label("total"),
  112. func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
  113. "completed"
  114. ),
  115. func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
  116. ).where(ProjectBOMItem.project_id == project_id)
  117. )
  118. bom_stats = bom_result.first()
  119. return ProjectStats(
  120. total_archives=total_archives,
  121. total_items=int(total_items),
  122. completed_prints=completed_items, # Now reflects sum of quantities for completed prints
  123. failed_prints=int(failed_prints),
  124. queued_prints=queued_prints,
  125. in_progress_prints=in_progress_prints,
  126. total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
  127. total_filament_grams=round(sums.total_filament or 0, 2),
  128. progress_percent=progress_percent,
  129. parts_progress_percent=parts_progress_percent,
  130. estimated_cost=round((sums.total_filament_cost or 0), 2),
  131. total_energy_kwh=round((sums.total_energy or 0), 3),
  132. total_energy_cost=round((sums.total_energy_cost or 0), 3),
  133. remaining_prints=remaining_prints,
  134. remaining_parts=remaining_parts,
  135. bom_total_items=bom_stats.total or 0,
  136. bom_completed_items=int(bom_stats.completed or 0),
  137. bom_cost=round(float(bom_stats.bom_cost or 0), 2),
  138. )
  139. @router.get("", response_model=list[ProjectListResponse])
  140. @router.get("/", response_model=list[ProjectListResponse])
  141. async def list_projects(
  142. status: str | None = None,
  143. db: AsyncSession = Depends(get_db),
  144. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  145. ):
  146. """List all projects with basic stats."""
  147. query = select(Project)
  148. if status:
  149. query = query.where(Project.status == status)
  150. query = query.order_by(Project.updated_at.desc())
  151. result = await db.execute(query)
  152. projects = result.scalars().all()
  153. # Compute quick stats for each project
  154. response = []
  155. for project in projects:
  156. # Get archive count (number of print jobs)
  157. archive_count_result = await db.execute(
  158. select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
  159. )
  160. archive_count = archive_count_result.scalar() or 0
  161. # Get total items (sum of quantities)
  162. total_items_result = await db.execute(
  163. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(PrintArchive.project_id == project.id)
  164. )
  165. total_items = int(total_items_result.scalar() or 0)
  166. # Get queue count
  167. queue_count_result = await db.execute(
  168. select(func.count(PrintQueueItem.id)).where(
  169. PrintQueueItem.project_id == project.id,
  170. PrintQueueItem.status.in_(["pending", "printing"]),
  171. )
  172. )
  173. queue_count = queue_count_result.scalar() or 0
  174. # Sum completed parts (quantities) - only actually printed jobs
  175. completed_result = await db.execute(
  176. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  177. PrintArchive.project_id == project.id,
  178. PrintArchive.status == "completed",
  179. )
  180. )
  181. completed_count = int(completed_result.scalar() or 0)
  182. # Sum failed parts (quantities) - includes all failure states
  183. failed_result = await db.execute(
  184. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  185. PrintArchive.project_id == project.id,
  186. PrintArchive.status.in_(["failed", "aborted", "cancelled", "stopped"]),
  187. )
  188. )
  189. failed_count = int(failed_result.scalar() or 0)
  190. # Plates progress: archive_count / target_count
  191. progress_percent = None
  192. if project.target_count and project.target_count > 0:
  193. progress_percent = round((archive_count / project.target_count) * 100, 1)
  194. # Get archive previews (up to 6 most recent)
  195. archives_result = await db.execute(
  196. select(PrintArchive)
  197. .where(PrintArchive.project_id == project.id)
  198. .order_by(PrintArchive.created_at.desc())
  199. .limit(6)
  200. )
  201. archives = archives_result.scalars().all()
  202. archive_previews = [
  203. ArchivePreview(
  204. id=a.id,
  205. print_name=a.print_name,
  206. thumbnail_path=a.thumbnail_path,
  207. status=a.status,
  208. filament_type=a.filament_type,
  209. filament_color=a.filament_color,
  210. )
  211. for a in archives
  212. ]
  213. response.append(
  214. ProjectListResponse(
  215. id=project.id,
  216. name=project.name,
  217. description=project.description,
  218. color=project.color,
  219. status=project.status,
  220. target_count=project.target_count,
  221. target_parts_count=project.target_parts_count,
  222. budget=project.budget,
  223. created_at=project.created_at,
  224. archive_count=archive_count,
  225. total_items=total_items,
  226. completed_count=completed_count,
  227. failed_count=failed_count,
  228. queue_count=queue_count,
  229. progress_percent=progress_percent,
  230. archives=archive_previews,
  231. )
  232. )
  233. return response
  234. @router.post("/", response_model=ProjectResponse)
  235. async def create_project(
  236. data: ProjectCreate,
  237. db: AsyncSession = Depends(get_db),
  238. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  239. ):
  240. """Create a new project."""
  241. # Verify parent exists if specified
  242. parent_name = None
  243. if data.parent_id:
  244. parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
  245. parent = parent_result.scalar_one_or_none()
  246. if not parent:
  247. raise HTTPException(status_code=400, detail="Parent project not found")
  248. parent_name = parent.name
  249. project = Project(
  250. name=data.name,
  251. description=data.description,
  252. color=data.color,
  253. target_count=data.target_count,
  254. target_parts_count=data.target_parts_count,
  255. notes=data.notes,
  256. tags=data.tags,
  257. due_date=data.due_date,
  258. priority=data.priority,
  259. budget=data.budget,
  260. parent_id=data.parent_id,
  261. )
  262. db.add(project)
  263. await db.flush()
  264. await db.refresh(project)
  265. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  266. return ProjectResponse(
  267. id=project.id,
  268. name=project.name,
  269. description=project.description,
  270. color=project.color,
  271. status=project.status,
  272. target_count=project.target_count,
  273. target_parts_count=project.target_parts_count,
  274. notes=project.notes,
  275. attachments=project.attachments,
  276. tags=project.tags,
  277. due_date=project.due_date,
  278. priority=project.priority,
  279. budget=project.budget,
  280. is_template=project.is_template,
  281. template_source_id=project.template_source_id,
  282. parent_id=project.parent_id,
  283. parent_name=parent_name,
  284. children=[],
  285. created_at=project.created_at,
  286. updated_at=project.updated_at,
  287. stats=stats,
  288. )
  289. # ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============
  290. @router.get("/templates", response_model=list[ProjectListResponse])
  291. async def list_templates(
  292. db: AsyncSession = Depends(get_db),
  293. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  294. ):
  295. """List all project templates."""
  296. result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
  297. templates = result.scalars().all()
  298. response = []
  299. for project in templates:
  300. # Get archive count
  301. archive_count_result = await db.execute(
  302. select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
  303. )
  304. archive_count = archive_count_result.scalar() or 0
  305. response.append(
  306. ProjectListResponse(
  307. id=project.id,
  308. name=project.name,
  309. description=project.description,
  310. color=project.color,
  311. status=project.status,
  312. target_count=project.target_count,
  313. budget=project.budget,
  314. created_at=project.created_at,
  315. archive_count=archive_count,
  316. queue_count=0,
  317. progress_percent=None,
  318. archives=[],
  319. )
  320. )
  321. return response
  322. @router.post("/from-template/{template_id}", response_model=ProjectResponse)
  323. async def create_project_from_template(
  324. template_id: int,
  325. name: str = None,
  326. db: AsyncSession = Depends(get_db),
  327. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  328. ):
  329. """Create a new project from a template."""
  330. result = await db.execute(select(Project).where(Project.id == template_id))
  331. template = result.scalar_one_or_none()
  332. if not template:
  333. raise HTTPException(status_code=404, detail="Template not found")
  334. if not template.is_template:
  335. raise HTTPException(status_code=400, detail="Project is not a template")
  336. # Create new project
  337. project = Project(
  338. name=name or template.name.replace(" (Template)", ""),
  339. description=template.description,
  340. color=template.color,
  341. target_count=template.target_count,
  342. target_parts_count=template.target_parts_count,
  343. notes=template.notes,
  344. tags=template.tags,
  345. priority=template.priority,
  346. budget=template.budget,
  347. is_template=False,
  348. template_source_id=template.id,
  349. )
  350. db.add(project)
  351. await db.flush()
  352. # Copy BOM items
  353. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))
  354. bom_items = bom_result.scalars().all()
  355. for item in bom_items:
  356. new_item = ProjectBOMItem(
  357. project_id=project.id,
  358. name=item.name,
  359. quantity_needed=item.quantity_needed,
  360. quantity_acquired=0,
  361. unit_price=item.unit_price,
  362. sourcing_url=item.sourcing_url,
  363. stl_filename=item.stl_filename,
  364. remarks=item.remarks,
  365. sort_order=item.sort_order,
  366. )
  367. db.add(new_item)
  368. await db.flush()
  369. await db.refresh(project)
  370. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  371. return ProjectResponse(
  372. id=project.id,
  373. name=project.name,
  374. description=project.description,
  375. color=project.color,
  376. status=project.status,
  377. target_count=project.target_count,
  378. target_parts_count=project.target_parts_count,
  379. notes=project.notes,
  380. attachments=project.attachments,
  381. tags=project.tags,
  382. due_date=project.due_date,
  383. priority=project.priority,
  384. budget=project.budget,
  385. is_template=project.is_template,
  386. template_source_id=project.template_source_id,
  387. parent_id=project.parent_id,
  388. parent_name=None,
  389. children=[],
  390. created_at=project.created_at,
  391. updated_at=project.updated_at,
  392. stats=stats,
  393. )
  394. # ============ Dynamic {project_id} Routes ============
  395. async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
  396. """Get preview info for child projects."""
  397. result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
  398. children = result.scalars().all()
  399. previews = []
  400. for child in children:
  401. # Get completed count for progress (sum of quantities)
  402. completed_result = await db.execute(
  403. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  404. PrintArchive.project_id == child.id,
  405. PrintArchive.status == "completed",
  406. )
  407. )
  408. completed_count = completed_result.scalar() or 0
  409. progress = None
  410. if child.target_count and child.target_count > 0:
  411. progress = round((int(completed_count) / child.target_count) * 100, 1)
  412. previews.append(
  413. ProjectChildPreview(
  414. id=child.id,
  415. name=child.name,
  416. color=child.color,
  417. status=child.status,
  418. progress_percent=progress,
  419. )
  420. )
  421. return previews
  422. @router.get("/{project_id}", response_model=ProjectResponse)
  423. async def get_project(
  424. project_id: int,
  425. db: AsyncSession = Depends(get_db),
  426. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  427. ):
  428. """Get a project by ID with detailed stats."""
  429. result = await db.execute(select(Project).where(Project.id == project_id))
  430. project = result.scalar_one_or_none()
  431. if not project:
  432. raise HTTPException(status_code=404, detail="Project not found")
  433. # Get parent name
  434. parent_name = None
  435. if project.parent_id:
  436. parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
  437. parent_name = parent_result.scalar()
  438. # Get children
  439. children = await get_child_previews(db, project.id)
  440. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  441. return ProjectResponse(
  442. id=project.id,
  443. name=project.name,
  444. description=project.description,
  445. color=project.color,
  446. status=project.status,
  447. target_count=project.target_count,
  448. target_parts_count=project.target_parts_count,
  449. notes=project.notes,
  450. attachments=project.attachments,
  451. tags=project.tags,
  452. due_date=project.due_date,
  453. priority=project.priority,
  454. budget=project.budget,
  455. is_template=project.is_template,
  456. template_source_id=project.template_source_id,
  457. parent_id=project.parent_id,
  458. parent_name=parent_name,
  459. children=children,
  460. created_at=project.created_at,
  461. updated_at=project.updated_at,
  462. stats=stats,
  463. )
  464. @router.patch("/{project_id}", response_model=ProjectResponse)
  465. async def update_project(
  466. project_id: int,
  467. data: ProjectUpdate,
  468. db: AsyncSession = Depends(get_db),
  469. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  470. ):
  471. """Update a project."""
  472. result = await db.execute(select(Project).where(Project.id == project_id))
  473. project = result.scalar_one_or_none()
  474. if not project:
  475. raise HTTPException(status_code=404, detail="Project not found")
  476. # Update fields if provided
  477. if data.name is not None:
  478. project.name = data.name
  479. if data.description is not None:
  480. project.description = data.description
  481. if data.color is not None:
  482. project.color = data.color
  483. if data.status is not None:
  484. if data.status not in ["active", "completed", "archived"]:
  485. raise HTTPException(status_code=400, detail="Invalid status")
  486. project.status = data.status
  487. if data.target_count is not None:
  488. project.target_count = data.target_count
  489. if data.target_parts_count is not None:
  490. project.target_parts_count = data.target_parts_count
  491. if data.notes is not None:
  492. project.notes = data.notes
  493. if data.tags is not None:
  494. project.tags = data.tags
  495. if data.due_date is not None:
  496. project.due_date = data.due_date
  497. if data.priority is not None:
  498. if data.priority not in ["low", "normal", "high", "urgent"]:
  499. raise HTTPException(status_code=400, detail="Invalid priority")
  500. project.priority = data.priority
  501. if "budget" in data.model_fields_set:
  502. project.budget = data.budget
  503. if data.parent_id is not None:
  504. # Verify parent exists and prevent circular reference
  505. if data.parent_id == project_id:
  506. raise HTTPException(status_code=400, detail="Project cannot be its own parent")
  507. if data.parent_id != 0: # 0 means remove parent
  508. parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
  509. if not parent_result.scalar_one_or_none():
  510. raise HTTPException(status_code=400, detail="Parent project not found")
  511. project.parent_id = data.parent_id
  512. else:
  513. project.parent_id = None
  514. await db.flush()
  515. await db.refresh(project)
  516. # Get parent name
  517. parent_name = None
  518. if project.parent_id:
  519. parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
  520. parent_name = parent_result.scalar()
  521. # Get children
  522. children = await get_child_previews(db, project.id)
  523. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  524. return ProjectResponse(
  525. id=project.id,
  526. name=project.name,
  527. description=project.description,
  528. color=project.color,
  529. status=project.status,
  530. target_count=project.target_count,
  531. target_parts_count=project.target_parts_count,
  532. notes=project.notes,
  533. attachments=project.attachments,
  534. tags=project.tags,
  535. due_date=project.due_date,
  536. priority=project.priority,
  537. budget=project.budget,
  538. is_template=project.is_template,
  539. template_source_id=project.template_source_id,
  540. parent_id=project.parent_id,
  541. parent_name=parent_name,
  542. children=children,
  543. created_at=project.created_at,
  544. updated_at=project.updated_at,
  545. stats=stats,
  546. )
  547. @router.delete("/{project_id}")
  548. async def delete_project(
  549. project_id: int,
  550. db: AsyncSession = Depends(get_db),
  551. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
  552. ):
  553. """Delete a project. Archives and queue items will have project_id set to NULL."""
  554. result = await db.execute(select(Project).where(Project.id == project_id))
  555. project = result.scalar_one_or_none()
  556. if not project:
  557. raise HTTPException(status_code=404, detail="Project not found")
  558. await db.delete(project)
  559. return {"message": "Project deleted"}
  560. @router.get("/{project_id}/archives")
  561. async def list_project_archives(
  562. project_id: int,
  563. limit: int = 100,
  564. offset: int = 0,
  565. db: AsyncSession = Depends(get_db),
  566. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  567. ):
  568. """List archives in a project."""
  569. # Verify project exists
  570. result = await db.execute(select(Project).where(Project.id == project_id))
  571. if not result.scalar_one_or_none():
  572. raise HTTPException(status_code=404, detail="Project not found")
  573. # Get archives with project relationship eagerly loaded
  574. query = (
  575. select(PrintArchive)
  576. .options(selectinload(PrintArchive.project))
  577. .where(PrintArchive.project_id == project_id)
  578. .order_by(PrintArchive.created_at.desc())
  579. .limit(limit)
  580. .offset(offset)
  581. )
  582. result = await db.execute(query)
  583. archives = result.scalars().all()
  584. # Import the response converter from archives module
  585. from backend.app.api.routes.archives import archive_to_response
  586. return [archive_to_response(a) for a in archives]
  587. @router.get("/{project_id}/queue")
  588. async def list_project_queue(
  589. project_id: int,
  590. db: AsyncSession = Depends(get_db),
  591. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  592. ):
  593. """List queue items in a project."""
  594. # Verify project exists
  595. result = await db.execute(select(Project).where(Project.id == project_id))
  596. if not result.scalar_one_or_none():
  597. raise HTTPException(status_code=404, detail="Project not found")
  598. # Get queue items
  599. query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
  600. result = await db.execute(query)
  601. items = result.scalars().all()
  602. return items
  603. @router.post("/{project_id}/add-archives")
  604. async def add_archives_to_project(
  605. project_id: int,
  606. data: BatchAddArchives,
  607. db: AsyncSession = Depends(get_db),
  608. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  609. ):
  610. """Batch add archives to a project."""
  611. # Verify project exists
  612. result = await db.execute(select(Project).where(Project.id == project_id))
  613. if not result.scalar_one_or_none():
  614. raise HTTPException(status_code=404, detail="Project not found")
  615. # Update archives
  616. updated = 0
  617. for archive_id in data.archive_ids:
  618. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  619. archive = result.scalar_one_or_none()
  620. if archive:
  621. archive.project_id = project_id
  622. updated += 1
  623. return {"message": f"Added {updated} archives to project"}
  624. @router.post("/{project_id}/add-queue")
  625. async def add_queue_items_to_project(
  626. project_id: int,
  627. data: BatchAddQueueItems,
  628. db: AsyncSession = Depends(get_db),
  629. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  630. ):
  631. """Batch add queue items to a project."""
  632. # Verify project exists
  633. result = await db.execute(select(Project).where(Project.id == project_id))
  634. if not result.scalar_one_or_none():
  635. raise HTTPException(status_code=404, detail="Project not found")
  636. # Update queue items
  637. updated = 0
  638. for item_id in data.queue_item_ids:
  639. result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
  640. item = result.scalar_one_or_none()
  641. if item:
  642. item.project_id = project_id
  643. updated += 1
  644. return {"message": f"Added {updated} queue items to project"}
  645. @router.post("/{project_id}/remove-archives")
  646. async def remove_archives_from_project(
  647. project_id: int,
  648. data: BatchAddArchives,
  649. db: AsyncSession = Depends(get_db),
  650. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  651. ):
  652. """Remove archives from a project (sets project_id to NULL)."""
  653. updated = 0
  654. for archive_id in data.archive_ids:
  655. result = await db.execute(
  656. select(PrintArchive).where(
  657. PrintArchive.id == archive_id,
  658. PrintArchive.project_id == project_id,
  659. )
  660. )
  661. archive = result.scalar_one_or_none()
  662. if archive:
  663. archive.project_id = None
  664. updated += 1
  665. return {"message": f"Removed {updated} archives from project"}
  666. def get_project_attachments_dir(project_id: int) -> Path:
  667. """Get the attachments directory for a project."""
  668. base_dir = Path(settings.archive_dir)
  669. return base_dir / "projects" / str(project_id) / "attachments"
  670. # Allowed file extensions for attachments
  671. ALLOWED_ATTACHMENT_EXTENSIONS = {
  672. # Images
  673. ".jpg",
  674. ".jpeg",
  675. ".png",
  676. ".gif",
  677. ".webp",
  678. ".svg",
  679. ".bmp",
  680. ".ico",
  681. # Documents
  682. ".pdf",
  683. ".doc",
  684. ".docx",
  685. ".xls",
  686. ".xlsx",
  687. ".ppt",
  688. ".pptx",
  689. ".odt",
  690. ".ods",
  691. ".odp",
  692. ".txt",
  693. ".rtf",
  694. ".csv",
  695. ".md",
  696. # 3D/CAD files
  697. ".stl",
  698. ".obj",
  699. ".3mf",
  700. ".step",
  701. ".stp",
  702. ".iges",
  703. ".igs",
  704. ".f3d",
  705. ".scad",
  706. # Archives
  707. ".zip",
  708. ".rar",
  709. ".7z",
  710. ".tar",
  711. ".gz",
  712. # Code/scripts (for Klipper macros, scripts, etc.)
  713. ".py",
  714. ".sh",
  715. ".cfg",
  716. ".conf",
  717. ".gcode",
  718. ".ini",
  719. # Other common formats
  720. ".json",
  721. ".xml",
  722. ".yaml",
  723. ".yml",
  724. }
  725. @router.post("/{project_id}/attachments")
  726. async def upload_attachment(
  727. project_id: int,
  728. file: UploadFile = File(...),
  729. db: AsyncSession = Depends(get_db),
  730. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  731. ):
  732. """Upload an attachment to a project."""
  733. logger.info("=== UPLOAD START: %s for project %s ===", file.filename, project_id)
  734. # Verify project exists
  735. result = await db.execute(select(Project).where(Project.id == project_id))
  736. project = result.scalar_one_or_none()
  737. if not project:
  738. raise HTTPException(status_code=404, detail="Project not found")
  739. # Validate file extension
  740. original_name = file.filename or "unknown"
  741. ext = os.path.splitext(original_name)[1].lower()
  742. if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
  743. raise HTTPException(
  744. status_code=400,
  745. detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
  746. )
  747. # Create attachments directory
  748. attachments_dir = get_project_attachments_dir(project_id)
  749. attachments_dir.mkdir(parents=True, exist_ok=True)
  750. # Generate unique filename
  751. unique_filename = f"{uuid.uuid4().hex}{ext}"
  752. file_path = attachments_dir / unique_filename
  753. # Save file
  754. try:
  755. with open(file_path, "wb") as f:
  756. content = await file.read()
  757. f.write(content)
  758. logger.info("=== FILE SAVED: %s, size: %s ===", file_path, len(content))
  759. except Exception as e:
  760. logger.error("Failed to save attachment: %s", e)
  761. raise HTTPException(status_code=500, detail="Failed to save attachment")
  762. # Update project attachments JSON
  763. attachments = list(project.attachments or [])
  764. new_attachment = {
  765. "filename": unique_filename,
  766. "original_name": original_name,
  767. "size": len(content),
  768. "uploaded_at": datetime.now().isoformat(),
  769. }
  770. attachments.append(new_attachment)
  771. # Simple ORM update
  772. project.attachments = attachments
  773. db.add(project) # Explicitly add to session
  774. logger.info("=== BEFORE COMMIT: %s attachments ===", len(attachments))
  775. await db.flush()
  776. await db.commit()
  777. logger.info("=== AFTER COMMIT ===")
  778. # Verify by re-querying
  779. result = await db.execute(select(Project).where(Project.id == project_id))
  780. fresh_project = result.scalar_one()
  781. logger.info("=== VERIFIED: %s attachments ===", len(fresh_project.attachments or []))
  782. return {
  783. "status": "success",
  784. "filename": unique_filename,
  785. "original_name": original_name,
  786. "attachments": fresh_project.attachments,
  787. }
  788. @router.get("/{project_id}/attachments/{filename}")
  789. async def download_attachment(
  790. project_id: int,
  791. filename: str,
  792. db: AsyncSession = Depends(get_db),
  793. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  794. ):
  795. """Download an attachment from a project."""
  796. # Validate filename to prevent path traversal
  797. if "/" in filename or "\\" in filename or ".." in filename or not filename:
  798. raise HTTPException(status_code=400, detail="Invalid filename")
  799. # Verify project exists
  800. result = await db.execute(select(Project).where(Project.id == project_id))
  801. project = result.scalar_one_or_none()
  802. if not project:
  803. raise HTTPException(status_code=404, detail="Project not found")
  804. # Verify attachment exists in project
  805. attachments = project.attachments or []
  806. attachment = next((a for a in attachments if a.get("filename") == filename), None)
  807. if not attachment:
  808. raise HTTPException(status_code=404, detail="Attachment not found")
  809. # Check file exists
  810. file_path = get_project_attachments_dir(project_id) / filename
  811. if not file_path.exists():
  812. raise HTTPException(status_code=404, detail="Attachment file not found")
  813. return FileResponse(
  814. file_path,
  815. filename=attachment.get("original_name", filename),
  816. media_type="application/octet-stream",
  817. )
  818. @router.delete("/{project_id}/attachments/{filename}")
  819. async def delete_attachment(
  820. project_id: int,
  821. filename: str,
  822. db: AsyncSession = Depends(get_db),
  823. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  824. ):
  825. """Delete an attachment from a project."""
  826. # Validate filename to prevent path traversal
  827. if "/" in filename or "\\" in filename or ".." in filename or not filename:
  828. raise HTTPException(status_code=400, detail="Invalid filename")
  829. # Verify project exists
  830. result = await db.execute(select(Project).where(Project.id == project_id))
  831. project = result.scalar_one_or_none()
  832. if not project:
  833. raise HTTPException(status_code=404, detail="Project not found")
  834. # Find and remove attachment from list
  835. attachments = project.attachments or []
  836. attachment = next((a for a in attachments if a.get("filename") == filename), None)
  837. if not attachment:
  838. raise HTTPException(status_code=404, detail="Attachment not found")
  839. # Remove from list
  840. attachments = [a for a in attachments if a.get("filename") != filename]
  841. project.attachments = attachments if attachments else None
  842. # Delete file
  843. file_path = get_project_attachments_dir(project_id) / filename
  844. if file_path.exists():
  845. try:
  846. os.remove(file_path)
  847. except Exception as e:
  848. logger.warning("Failed to delete attachment file: %s", e)
  849. await db.flush()
  850. await db.refresh(project)
  851. return {
  852. "status": "success",
  853. "message": "Attachment deleted",
  854. "attachments": project.attachments,
  855. }
  856. # ============ Phase 7: BOM Endpoints ============
  857. @router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
  858. async def list_bom_items(
  859. project_id: int,
  860. db: AsyncSession = Depends(get_db),
  861. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  862. ):
  863. """List all BOM items for a project."""
  864. # Verify project exists
  865. result = await db.execute(select(Project).where(Project.id == project_id))
  866. if not result.scalar_one_or_none():
  867. raise HTTPException(status_code=404, detail="Project not found")
  868. # Get BOM items
  869. result = await db.execute(
  870. select(ProjectBOMItem)
  871. .where(ProjectBOMItem.project_id == project_id)
  872. .order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)
  873. )
  874. items = result.scalars().all()
  875. response = []
  876. for item in items:
  877. # Get archive name if linked
  878. archive_name = None
  879. if item.archive_id:
  880. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  881. archive_name = archive_result.scalar()
  882. response.append(
  883. BOMItemResponse(
  884. id=item.id,
  885. project_id=item.project_id,
  886. name=item.name,
  887. quantity_needed=item.quantity_needed,
  888. quantity_acquired=item.quantity_acquired,
  889. unit_price=item.unit_price,
  890. sourcing_url=item.sourcing_url,
  891. archive_id=item.archive_id,
  892. archive_name=archive_name,
  893. stl_filename=item.stl_filename,
  894. remarks=item.remarks,
  895. sort_order=item.sort_order,
  896. is_complete=item.quantity_acquired >= item.quantity_needed,
  897. created_at=item.created_at,
  898. updated_at=item.updated_at,
  899. )
  900. )
  901. return response
  902. @router.post("/{project_id}/bom", response_model=BOMItemResponse)
  903. async def create_bom_item(
  904. project_id: int,
  905. data: BOMItemCreate,
  906. db: AsyncSession = Depends(get_db),
  907. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  908. ):
  909. """Add a BOM item to a project."""
  910. # Verify project exists
  911. result = await db.execute(select(Project).where(Project.id == project_id))
  912. if not result.scalar_one_or_none():
  913. raise HTTPException(status_code=404, detail="Project not found")
  914. # Get max sort order
  915. max_order_result = await db.execute(
  916. select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)
  917. )
  918. max_order = max_order_result.scalar() or 0
  919. item = ProjectBOMItem(
  920. project_id=project_id,
  921. name=data.name,
  922. quantity_needed=data.quantity_needed,
  923. unit_price=data.unit_price,
  924. sourcing_url=data.sourcing_url,
  925. archive_id=data.archive_id,
  926. stl_filename=data.stl_filename,
  927. remarks=data.remarks,
  928. sort_order=max_order + 1,
  929. )
  930. db.add(item)
  931. await db.flush()
  932. await db.refresh(item)
  933. # Get archive name if linked
  934. archive_name = None
  935. if item.archive_id:
  936. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  937. archive_name = archive_result.scalar()
  938. return BOMItemResponse(
  939. id=item.id,
  940. project_id=item.project_id,
  941. name=item.name,
  942. quantity_needed=item.quantity_needed,
  943. quantity_acquired=item.quantity_acquired,
  944. unit_price=item.unit_price,
  945. sourcing_url=item.sourcing_url,
  946. archive_id=item.archive_id,
  947. archive_name=archive_name,
  948. stl_filename=item.stl_filename,
  949. remarks=item.remarks,
  950. sort_order=item.sort_order,
  951. is_complete=item.quantity_acquired >= item.quantity_needed,
  952. created_at=item.created_at,
  953. updated_at=item.updated_at,
  954. )
  955. @router.patch("/{project_id}/bom/{item_id}", response_model=BOMItemResponse)
  956. async def update_bom_item(
  957. project_id: int,
  958. item_id: int,
  959. data: BOMItemUpdate,
  960. db: AsyncSession = Depends(get_db),
  961. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  962. ):
  963. """Update a BOM item."""
  964. result = await db.execute(
  965. select(ProjectBOMItem).where(
  966. ProjectBOMItem.id == item_id,
  967. ProjectBOMItem.project_id == project_id,
  968. )
  969. )
  970. item = result.scalar_one_or_none()
  971. if not item:
  972. raise HTTPException(status_code=404, detail="BOM item not found")
  973. if data.name is not None:
  974. item.name = data.name
  975. if data.quantity_needed is not None:
  976. item.quantity_needed = data.quantity_needed
  977. if data.quantity_acquired is not None:
  978. item.quantity_acquired = data.quantity_acquired
  979. if data.unit_price is not None:
  980. item.unit_price = data.unit_price if data.unit_price != 0 else None
  981. if data.sourcing_url is not None:
  982. item.sourcing_url = data.sourcing_url if data.sourcing_url else None
  983. if data.archive_id is not None:
  984. item.archive_id = data.archive_id if data.archive_id != 0 else None
  985. if data.stl_filename is not None:
  986. item.stl_filename = data.stl_filename if data.stl_filename else None
  987. if data.remarks is not None:
  988. item.remarks = data.remarks if data.remarks else None
  989. await db.flush()
  990. await db.refresh(item)
  991. # Get archive name if linked
  992. archive_name = None
  993. if item.archive_id:
  994. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  995. archive_name = archive_result.scalar()
  996. return BOMItemResponse(
  997. id=item.id,
  998. project_id=item.project_id,
  999. name=item.name,
  1000. quantity_needed=item.quantity_needed,
  1001. quantity_acquired=item.quantity_acquired,
  1002. unit_price=item.unit_price,
  1003. sourcing_url=item.sourcing_url,
  1004. archive_id=item.archive_id,
  1005. archive_name=archive_name,
  1006. stl_filename=item.stl_filename,
  1007. remarks=item.remarks,
  1008. sort_order=item.sort_order,
  1009. is_complete=item.quantity_acquired >= item.quantity_needed,
  1010. created_at=item.created_at,
  1011. updated_at=item.updated_at,
  1012. )
  1013. @router.delete("/{project_id}/bom/{item_id}")
  1014. async def delete_bom_item(
  1015. project_id: int,
  1016. item_id: int,
  1017. db: AsyncSession = Depends(get_db),
  1018. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  1019. ):
  1020. """Delete a BOM item."""
  1021. result = await db.execute(
  1022. select(ProjectBOMItem).where(
  1023. ProjectBOMItem.id == item_id,
  1024. ProjectBOMItem.project_id == project_id,
  1025. )
  1026. )
  1027. item = result.scalar_one_or_none()
  1028. if not item:
  1029. raise HTTPException(status_code=404, detail="BOM item not found")
  1030. await db.delete(item)
  1031. return {"status": "success", "message": "BOM item deleted"}
  1032. @router.post("/{project_id}/create-template", response_model=ProjectResponse)
  1033. async def create_template_from_project(
  1034. project_id: int,
  1035. db: AsyncSession = Depends(get_db),
  1036. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  1037. ):
  1038. """Create a template from an existing project."""
  1039. result = await db.execute(select(Project).where(Project.id == project_id))
  1040. source = result.scalar_one_or_none()
  1041. if not source:
  1042. raise HTTPException(status_code=404, detail="Project not found")
  1043. # Create template
  1044. template = Project(
  1045. name=f"{source.name} (Template)",
  1046. description=source.description,
  1047. color=source.color,
  1048. target_count=source.target_count,
  1049. target_parts_count=source.target_parts_count,
  1050. notes=source.notes,
  1051. tags=source.tags,
  1052. priority=source.priority,
  1053. budget=source.budget,
  1054. is_template=True,
  1055. template_source_id=source.id,
  1056. )
  1057. db.add(template)
  1058. await db.flush()
  1059. # Copy BOM items
  1060. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))
  1061. bom_items = bom_result.scalars().all()
  1062. for item in bom_items:
  1063. new_item = ProjectBOMItem(
  1064. project_id=template.id,
  1065. name=item.name,
  1066. quantity_needed=item.quantity_needed,
  1067. quantity_acquired=0,
  1068. unit_price=item.unit_price,
  1069. sourcing_url=item.sourcing_url,
  1070. stl_filename=item.stl_filename,
  1071. remarks=item.remarks,
  1072. sort_order=item.sort_order,
  1073. )
  1074. db.add(new_item)
  1075. await db.flush()
  1076. await db.refresh(template)
  1077. stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)
  1078. return ProjectResponse(
  1079. id=template.id,
  1080. name=template.name,
  1081. description=template.description,
  1082. color=template.color,
  1083. status=template.status,
  1084. target_count=template.target_count,
  1085. target_parts_count=template.target_parts_count,
  1086. notes=template.notes,
  1087. attachments=template.attachments,
  1088. tags=template.tags,
  1089. due_date=template.due_date,
  1090. priority=template.priority,
  1091. budget=template.budget,
  1092. is_template=template.is_template,
  1093. template_source_id=template.template_source_id,
  1094. parent_id=template.parent_id,
  1095. parent_name=None,
  1096. children=[],
  1097. created_at=template.created_at,
  1098. updated_at=template.updated_at,
  1099. stats=stats,
  1100. )
  1101. # ============ Phase 9: Timeline Endpoint ============
  1102. @router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
  1103. async def get_project_timeline(
  1104. project_id: int,
  1105. limit: int = 50,
  1106. db: AsyncSession = Depends(get_db),
  1107. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  1108. ):
  1109. """Get timeline of events for a project."""
  1110. # Verify project exists
  1111. result = await db.execute(select(Project).where(Project.id == project_id))
  1112. project = result.scalar_one_or_none()
  1113. if not project:
  1114. raise HTTPException(status_code=404, detail="Project not found")
  1115. events = []
  1116. # Project creation event
  1117. events.append(
  1118. TimelineEvent(
  1119. event_type="project_created",
  1120. timestamp=project.created_at,
  1121. title="Project created",
  1122. description=f"Project '{project.name}' was created",
  1123. )
  1124. )
  1125. # Get archives and add events
  1126. archives_result = await db.execute(
  1127. select(PrintArchive)
  1128. .where(PrintArchive.project_id == project_id)
  1129. .order_by(PrintArchive.created_at.desc())
  1130. .limit(limit)
  1131. )
  1132. archives = archives_result.scalars().all()
  1133. for archive in archives:
  1134. if archive.status == "completed":
  1135. events.append(
  1136. TimelineEvent(
  1137. event_type="print_completed",
  1138. timestamp=archive.completed_at or archive.created_at,
  1139. title="Print completed",
  1140. description=archive.print_name,
  1141. metadata={
  1142. "archive_id": archive.id,
  1143. "print_time_hours": round((archive.print_time_seconds or 0) / 3600, 2),
  1144. "filament_grams": round(archive.filament_used_grams or 0, 1),
  1145. },
  1146. )
  1147. )
  1148. elif archive.status == "failed":
  1149. events.append(
  1150. TimelineEvent(
  1151. event_type="print_failed",
  1152. timestamp=archive.completed_at or archive.created_at,
  1153. title="Print failed",
  1154. description=archive.print_name,
  1155. metadata={"archive_id": archive.id},
  1156. )
  1157. )
  1158. # Get queue items
  1159. queue_result = await db.execute(
  1160. select(PrintQueueItem)
  1161. .where(PrintQueueItem.project_id == project_id)
  1162. .order_by(PrintQueueItem.created_at.desc())
  1163. .limit(limit)
  1164. )
  1165. queue_items = queue_result.scalars().all()
  1166. for item in queue_items:
  1167. if item.status == "printing":
  1168. events.append(
  1169. TimelineEvent(
  1170. event_type="print_started",
  1171. timestamp=item.started_at or item.created_at,
  1172. title="Print started",
  1173. description=item.print_name,
  1174. metadata={"queue_item_id": item.id},
  1175. )
  1176. )
  1177. elif item.status == "pending":
  1178. events.append(
  1179. TimelineEvent(
  1180. event_type="queued",
  1181. timestamp=item.created_at,
  1182. title="Added to queue",
  1183. description=item.print_name,
  1184. metadata={"queue_item_id": item.id},
  1185. )
  1186. )
  1187. # Sort by timestamp descending
  1188. events.sort(key=lambda e: e.timestamp, reverse=True)
  1189. return events[:limit]
  1190. # ============ Phase 10: Import/Export Endpoints ============
  1191. @router.get("/{project_id}/export")
  1192. async def export_project(
  1193. project_id: int,
  1194. format: str = "zip", # "zip" (with files) or "json" (metadata only)
  1195. db: AsyncSession = Depends(get_db),
  1196. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  1197. ):
  1198. """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
  1199. result = await db.execute(select(Project).where(Project.id == project_id))
  1200. project = result.scalar_one_or_none()
  1201. if not project:
  1202. raise HTTPException(status_code=404, detail="Project not found")
  1203. # Get BOM items
  1204. bom_result = await db.execute(
  1205. select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)
  1206. )
  1207. bom_items = bom_result.scalars().all()
  1208. bom_export = [
  1209. {
  1210. "name": item.name,
  1211. "quantity_needed": item.quantity_needed,
  1212. "quantity_acquired": item.quantity_acquired,
  1213. "unit_price": item.unit_price,
  1214. "sourcing_url": item.sourcing_url,
  1215. "stl_filename": item.stl_filename,
  1216. "remarks": item.remarks,
  1217. }
  1218. for item in bom_items
  1219. ]
  1220. # Get linked folders and their files
  1221. folders_result = await db.execute(
  1222. select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)
  1223. )
  1224. linked_folders = folders_result.scalars().all()
  1225. folders_export = []
  1226. files_to_include = [] # (archive_path, zip_path)
  1227. for folder in linked_folders:
  1228. # Get files in this folder
  1229. files_result = await db.execute(
  1230. select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
  1231. )
  1232. files = files_result.scalars().all()
  1233. folder_files = []
  1234. for f in files:
  1235. folder_files.append(
  1236. {
  1237. "filename": f.filename,
  1238. "file_type": f.file_type,
  1239. "notes": f.notes,
  1240. }
  1241. )
  1242. # Add file to include in ZIP
  1243. library_dir = get_library_dir()
  1244. file_path = library_dir / f.file_path
  1245. if file_path.exists():
  1246. zip_path = f"files/{folder.name}/{f.filename}"
  1247. files_to_include.append((file_path, zip_path))
  1248. # Also include thumbnail if exists
  1249. if f.thumbnail_path:
  1250. thumb_path = library_dir / f.thumbnail_path
  1251. if thumb_path.exists():
  1252. thumb_zip_path = f"files/{folder.name}/.thumbnails/{f.filename}.png"
  1253. files_to_include.append((thumb_path, thumb_zip_path))
  1254. folders_export.append(
  1255. {
  1256. "name": folder.name,
  1257. "files": folder_files,
  1258. }
  1259. )
  1260. # Build project JSON
  1261. project_data = {
  1262. "name": project.name,
  1263. "description": project.description,
  1264. "color": project.color,
  1265. "status": project.status,
  1266. "target_count": project.target_count,
  1267. "target_parts_count": project.target_parts_count,
  1268. "notes": project.notes,
  1269. "tags": project.tags,
  1270. "due_date": project.due_date.isoformat() if project.due_date else None,
  1271. "priority": project.priority,
  1272. "budget": project.budget,
  1273. "bom_items": bom_export,
  1274. "linked_folders": folders_export,
  1275. }
  1276. # Return JSON if requested (for bulk export)
  1277. if format == "json":
  1278. return project_data
  1279. # Create ZIP in memory
  1280. zip_buffer = io.BytesIO()
  1281. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1282. # Add project.json
  1283. zf.writestr("project.json", json.dumps(project_data, indent=2))
  1284. # Add files
  1285. for file_path, zip_path in files_to_include:
  1286. zf.write(file_path, zip_path)
  1287. zip_buffer.seek(0)
  1288. # Generate filename
  1289. safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in project.name)
  1290. filename = f"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip"
  1291. return StreamingResponse(
  1292. zip_buffer,
  1293. media_type="application/zip",
  1294. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  1295. )
  1296. @router.post("/import", response_model=ProjectResponse)
  1297. async def import_project(
  1298. data: ProjectImport,
  1299. db: AsyncSession = Depends(get_db),
  1300. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  1301. ):
  1302. """Import a project with optional BOM items and linked folders."""
  1303. # Create the project
  1304. project = Project(
  1305. name=data.name,
  1306. description=data.description,
  1307. color=data.color,
  1308. status=data.status,
  1309. target_count=data.target_count,
  1310. target_parts_count=data.target_parts_count,
  1311. notes=data.notes,
  1312. tags=data.tags,
  1313. due_date=data.due_date,
  1314. priority=data.priority,
  1315. budget=data.budget,
  1316. )
  1317. db.add(project)
  1318. await db.flush()
  1319. # Create BOM items
  1320. for idx, bom_data in enumerate(data.bom_items):
  1321. bom_item = ProjectBOMItem(
  1322. project_id=project.id,
  1323. name=bom_data.name,
  1324. quantity_needed=bom_data.quantity_needed,
  1325. quantity_acquired=bom_data.quantity_acquired,
  1326. unit_price=bom_data.unit_price,
  1327. sourcing_url=bom_data.sourcing_url,
  1328. stl_filename=bom_data.stl_filename,
  1329. remarks=bom_data.remarks,
  1330. sort_order=idx,
  1331. )
  1332. db.add(bom_item)
  1333. # Create linked folders in library
  1334. for folder_data in data.linked_folders:
  1335. # Check if folder with this name already exists at root level
  1336. existing_result = await db.execute(
  1337. select(LibraryFolder).where(
  1338. LibraryFolder.name == folder_data.name,
  1339. LibraryFolder.parent_id.is_(None),
  1340. )
  1341. )
  1342. existing_folder = existing_result.scalar_one_or_none()
  1343. if existing_folder:
  1344. # Link existing folder to project
  1345. existing_folder.project_id = project.id
  1346. else:
  1347. # Create new folder linked to project
  1348. new_folder = LibraryFolder(
  1349. name=folder_data.name,
  1350. project_id=project.id,
  1351. is_external=False,
  1352. external_readonly=False,
  1353. external_show_hidden=False,
  1354. )
  1355. db.add(new_folder)
  1356. await db.flush()
  1357. await db.refresh(project)
  1358. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  1359. return ProjectResponse(
  1360. id=project.id,
  1361. name=project.name,
  1362. description=project.description,
  1363. color=project.color,
  1364. status=project.status,
  1365. target_count=project.target_count,
  1366. target_parts_count=project.target_parts_count,
  1367. notes=project.notes,
  1368. attachments=project.attachments,
  1369. tags=project.tags,
  1370. due_date=project.due_date,
  1371. priority=project.priority,
  1372. budget=project.budget,
  1373. is_template=project.is_template,
  1374. template_source_id=project.template_source_id,
  1375. parent_id=project.parent_id,
  1376. parent_name=None,
  1377. children=[],
  1378. created_at=project.created_at,
  1379. updated_at=project.updated_at,
  1380. stats=stats,
  1381. )
  1382. @router.post("/import/file", response_model=ProjectResponse)
  1383. async def import_project_file(
  1384. file: UploadFile = File(...),
  1385. db: AsyncSession = Depends(get_db),
  1386. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  1387. ):
  1388. """Import a project from a ZIP or JSON file."""
  1389. if not file.filename:
  1390. raise HTTPException(status_code=400, detail="No filename provided")
  1391. # Determine file type
  1392. filename_lower = file.filename.lower()
  1393. content = await file.read()
  1394. if filename_lower.endswith(".zip"):
  1395. # Extract project.json from ZIP
  1396. try:
  1397. with zipfile.ZipFile(io.BytesIO(content)) as zf:
  1398. if "project.json" not in zf.namelist():
  1399. raise HTTPException(status_code=400, detail="ZIP must contain project.json")
  1400. project_json = zf.read("project.json")
  1401. data = json.loads(project_json)
  1402. # Get list of files in the ZIP
  1403. zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith("files/")}
  1404. except zipfile.BadZipFile:
  1405. raise HTTPException(status_code=400, detail="Invalid ZIP file")
  1406. elif filename_lower.endswith(".json"):
  1407. try:
  1408. data = json.loads(content)
  1409. zip_files = {}
  1410. except json.JSONDecodeError:
  1411. raise HTTPException(status_code=400, detail="Invalid JSON file")
  1412. else:
  1413. raise HTTPException(status_code=400, detail="File must be .zip or .json")
  1414. # Create the project
  1415. project = Project(
  1416. name=data.get("name", "Imported Project"),
  1417. description=data.get("description"),
  1418. color=data.get("color"),
  1419. status=data.get("status", "active"),
  1420. target_count=data.get("target_count"),
  1421. target_parts_count=data.get("target_parts_count"),
  1422. notes=data.get("notes"),
  1423. tags=data.get("tags"),
  1424. due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
  1425. priority=data.get("priority", 0),
  1426. budget=data.get("budget"),
  1427. )
  1428. db.add(project)
  1429. await db.flush()
  1430. # Create BOM items
  1431. for idx, bom_data in enumerate(data.get("bom_items", [])):
  1432. bom_item = ProjectBOMItem(
  1433. project_id=project.id,
  1434. name=bom_data.get("name", "Unnamed"),
  1435. quantity_needed=bom_data.get("quantity_needed", 1),
  1436. quantity_acquired=bom_data.get("quantity_acquired", 0),
  1437. unit_price=bom_data.get("unit_price"),
  1438. sourcing_url=bom_data.get("sourcing_url"),
  1439. stl_filename=bom_data.get("stl_filename"),
  1440. remarks=bom_data.get("remarks"),
  1441. sort_order=idx,
  1442. )
  1443. db.add(bom_item)
  1444. # Create linked folders and files
  1445. library_dir = get_library_dir()
  1446. for folder_data in data.get("linked_folders", []):
  1447. folder_name = folder_data.get("name")
  1448. if not folder_name:
  1449. continue
  1450. # Check if folder exists
  1451. existing_result = await db.execute(
  1452. select(LibraryFolder).where(
  1453. LibraryFolder.name == folder_name,
  1454. LibraryFolder.parent_id.is_(None),
  1455. )
  1456. )
  1457. existing_folder = existing_result.scalar_one_or_none()
  1458. if existing_folder:
  1459. # Link existing folder to project
  1460. existing_folder.project_id = project.id
  1461. folder = existing_folder
  1462. else:
  1463. # Create new folder
  1464. folder = LibraryFolder(
  1465. name=folder_name,
  1466. project_id=project.id,
  1467. is_external=False,
  1468. external_readonly=False,
  1469. external_show_hidden=False,
  1470. )
  1471. db.add(folder)
  1472. await db.flush()
  1473. # Create folder on disk
  1474. folder_path = library_dir / folder_name
  1475. folder_path.mkdir(parents=True, exist_ok=True)
  1476. # Import files for this folder from ZIP
  1477. folder_prefix = f"files/{folder_name}/"
  1478. for zip_path, file_content in zip_files.items():
  1479. if not zip_path.startswith(folder_prefix):
  1480. continue
  1481. if "/.thumbnails/" in zip_path:
  1482. continue # Skip thumbnails, we'll regenerate them
  1483. relative_path = zip_path[len(folder_prefix) :]
  1484. if not relative_path:
  1485. continue
  1486. # Write file to disk
  1487. file_disk_path = library_dir / folder_name / relative_path
  1488. file_disk_path.parent.mkdir(parents=True, exist_ok=True)
  1489. file_disk_path.write_bytes(file_content)
  1490. # Determine file type
  1491. ext = Path(relative_path).suffix.lower()
  1492. if ext in [".stl", ".3mf", ".obj"]:
  1493. file_type = "model"
  1494. elif ext in [".gcode"]:
  1495. file_type = "gcode"
  1496. elif ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
  1497. file_type = "image"
  1498. else:
  1499. file_type = "other"
  1500. # Create library file record
  1501. lib_file = LibraryFile(
  1502. folder_id=folder.id,
  1503. filename=relative_path,
  1504. file_path=f"{folder_name}/{relative_path}",
  1505. file_type=file_type,
  1506. file_size=len(file_content),
  1507. is_external=False,
  1508. )
  1509. db.add(lib_file)
  1510. await db.flush()
  1511. await db.refresh(project)
  1512. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  1513. return ProjectResponse(
  1514. id=project.id,
  1515. name=project.name,
  1516. description=project.description,
  1517. color=project.color,
  1518. status=project.status,
  1519. target_count=project.target_count,
  1520. target_parts_count=project.target_parts_count,
  1521. notes=project.notes,
  1522. attachments=project.attachments,
  1523. tags=project.tags,
  1524. due_date=project.due_date,
  1525. priority=project.priority,
  1526. budget=project.budget,
  1527. is_template=project.is_template,
  1528. template_source_id=project.template_source_id,
  1529. parent_id=project.parent_id,
  1530. parent_name=None,
  1531. children=[],
  1532. created_at=project.created_at,
  1533. updated_at=project.updated_at,
  1534. stats=stats,
  1535. )