projects.py 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082
  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 RequireCameraStreamTokenIfAuthEnabled, 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_log import PrintLogEntry
  22. from backend.app.models.print_queue import PrintQueueItem
  23. from backend.app.models.project import Project
  24. from backend.app.models.project_bom import ProjectBOMItem
  25. from backend.app.models.user import User
  26. from backend.app.schemas.project import (
  27. ArchivePreview,
  28. BatchAddArchives,
  29. BatchAddQueueItems,
  30. BOMItemCreate,
  31. BOMItemResponse,
  32. BOMItemUpdate,
  33. ProjectChildPreview,
  34. ProjectCreate,
  35. ProjectFileProgress,
  36. ProjectImport,
  37. ProjectListResponse,
  38. ProjectResponse,
  39. ProjectStats,
  40. ProjectUpdate,
  41. TimelineEvent,
  42. )
  43. from backend.app.utils.http import build_content_disposition
  44. from backend.app.utils.safe_path import safe_join_under
  45. logger = logging.getLogger(__name__)
  46. router = APIRouter(prefix="/projects", tags=["projects"])
  47. _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
  48. # Soft-deleted archives (#1343) keep their row — and therefore their
  49. # ``project_id`` — after their files have been removed from disk, so that global
  50. # Quick Stats can still count their filament / time / cost. Nothing in this
  51. # module filtered on that, which left deleted prints listed on the project with
  52. # thumbnails pointing at files that no longer exist, and no way to unassign them
  53. # (the only unassign UI lives on the Archives page, which correctly hides them)
  54. # — #2731.
  55. #
  56. # Every project-scoped query filters them out, counts included: a project that
  57. # lists 11 prints must not claim 12. That is a deliberate divergence from the
  58. # global Quick Stats behaviour, where the whole point of the soft delete is that
  59. # the contribution survives. A project is a piece of work with a definite
  60. # membership, not a lifetime total, so a print the user deleted has left it.
  61. _LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
  62. async def compute_project_stats(
  63. db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
  64. ) -> ProjectStats:
  65. """Compute statistics for a project.
  66. Aggregates from ``print_log_entries`` joined to ``print_archives`` so
  67. every actual run contributes — pre-fix this counted ``print_archives``
  68. (one row per file), which under-reported every reprint by collapsing
  69. runs back into the source file (#1593). The Archive Print Log view
  70. already drives off the same source (``archives.py::list_archives_slim``),
  71. so project stats now stay aligned with the per-archive numbers.
  72. Orphan log entries (``archive_id IS NULL`` after archive deletion via
  73. ``ON DELETE SET NULL``) are excluded by the inner join — they can't
  74. be attributed to a project.
  75. """
  76. # Per-run aggregates from print_log_entries joined on archive_id so
  77. # the WHERE filters by archives.project_id. Each run's duration,
  78. # filament, cost, and energy come from the log row, not the source
  79. # archive — so multi-plate 3MFs and reprints both count correctly.
  80. log_stats_result = await db.execute(
  81. select(
  82. func.count(PrintLogEntry.id).label("total_runs"),
  83. func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
  84. func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
  85. func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
  86. func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
  87. func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
  88. )
  89. .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
  90. .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
  91. )
  92. log_stats = log_stats_result.first()
  93. total_archives = int(log_stats.total_runs or 0)
  94. # Total items the project has produced or attempted: sum of quantity
  95. # per run (each run contributes its archive's quantity). The total/
  96. # completed/failed splits are all per-run, not per-file.
  97. items_split_result = await db.execute(
  98. select(
  99. func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
  100. func.coalesce(
  101. func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
  102. 0,
  103. ).label("completed_items"),
  104. func.coalesce(
  105. func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
  106. 0,
  107. ).label("failed_runs"),
  108. )
  109. .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
  110. .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
  111. )
  112. items_split = items_split_result.first()
  113. total_items = int(items_split.total_items or 0)
  114. completed_items = int(items_split.completed_items or 0)
  115. failed_prints = int(items_split.failed_runs or 0)
  116. # Count queued items
  117. queued_result = await db.execute(
  118. select(func.count(PrintQueueItem.id)).where(
  119. PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
  120. )
  121. )
  122. queued_prints = queued_result.scalar() or 0
  123. # Count in-progress items
  124. in_progress_result = await db.execute(
  125. select(func.count(PrintQueueItem.id)).where(
  126. PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
  127. )
  128. )
  129. in_progress_prints = in_progress_result.scalar() or 0
  130. # Calculate progress for plates (target_count vs total_archives)
  131. progress_percent = None
  132. remaining_prints = None
  133. if target_count and target_count > 0:
  134. progress_percent = round((total_archives / target_count) * 100, 1)
  135. remaining_prints = max(0, target_count - total_archives)
  136. # Calculate progress for parts (target_parts_count vs completed_items)
  137. parts_progress_percent = None
  138. remaining_parts = None
  139. if target_parts_count and target_parts_count > 0:
  140. parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
  141. remaining_parts = max(0, target_parts_count - completed_items)
  142. # BOM stats
  143. bom_result = await db.execute(
  144. select(
  145. func.count(ProjectBOMItem.id).label("total"),
  146. func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
  147. "completed"
  148. ),
  149. func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
  150. ).where(ProjectBOMItem.project_id == project_id)
  151. )
  152. bom_stats = bom_result.first()
  153. return ProjectStats(
  154. total_archives=total_archives,
  155. total_items=int(total_items),
  156. completed_prints=completed_items, # Now reflects sum of quantities for completed prints
  157. failed_prints=int(failed_prints),
  158. queued_prints=queued_prints,
  159. in_progress_prints=in_progress_prints,
  160. total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
  161. total_filament_grams=round(log_stats.total_filament or 0, 2),
  162. progress_percent=progress_percent,
  163. parts_progress_percent=parts_progress_percent,
  164. estimated_cost=round((log_stats.total_filament_cost or 0), 2),
  165. total_energy_kwh=round((log_stats.total_energy or 0), 3),
  166. total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
  167. remaining_prints=remaining_prints,
  168. remaining_parts=remaining_parts,
  169. bom_total_items=bom_stats.total or 0,
  170. bom_completed_items=int(bom_stats.completed or 0),
  171. bom_cost=round(float(bom_stats.bom_cost or 0), 2),
  172. )
  173. @router.get("", response_model=list[ProjectListResponse])
  174. @router.get("/", response_model=list[ProjectListResponse])
  175. async def list_projects(
  176. status: str | None = None,
  177. db: AsyncSession = Depends(get_db),
  178. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  179. ):
  180. """List all projects with basic stats."""
  181. query = select(Project)
  182. if status:
  183. query = query.where(Project.status == status)
  184. query = query.order_by(Project.updated_at.desc())
  185. result = await db.execute(query)
  186. projects = result.scalars().all()
  187. # Compute quick stats for each project. Same per-run aggregation as
  188. # ``compute_project_stats`` — counts and quantities come from
  189. # ``print_log_entries`` joined to ``print_archives`` so reprints and
  190. # multi-plate prints contribute every run, not just the source file
  191. # (#1593). Quick stats and the full stats endpoint must agree.
  192. response = []
  193. for project in projects:
  194. log_quick_result = await db.execute(
  195. select(
  196. func.count(PrintLogEntry.id).label("archive_count"),
  197. func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
  198. func.coalesce(
  199. func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
  200. 0,
  201. ).label("completed_count"),
  202. func.coalesce(
  203. func.sum(case((PrintLogEntry.status.in_(_FAILURE_STATUSES), 1), else_=0)),
  204. 0,
  205. ).label("failed_count"),
  206. )
  207. .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
  208. .where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
  209. )
  210. log_quick = log_quick_result.first()
  211. archive_count = int(log_quick.archive_count or 0)
  212. total_items = int(log_quick.total_items or 0)
  213. completed_count = int(log_quick.completed_count or 0)
  214. failed_count = int(log_quick.failed_count or 0)
  215. # Get queue count
  216. queue_count_result = await db.execute(
  217. select(func.count(PrintQueueItem.id)).where(
  218. PrintQueueItem.project_id == project.id,
  219. PrintQueueItem.status.in_(["pending", "printing"]),
  220. )
  221. )
  222. queue_count = queue_count_result.scalar() or 0
  223. # Plates progress: archive_count / target_count
  224. progress_percent = None
  225. if project.target_count and project.target_count > 0:
  226. progress_percent = round((archive_count / project.target_count) * 100, 1)
  227. # Get archive previews (up to 6 most recent)
  228. archives_result = await db.execute(
  229. select(PrintArchive)
  230. .where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
  231. .order_by(PrintArchive.created_at.desc())
  232. .limit(6)
  233. )
  234. archives = archives_result.scalars().all()
  235. archive_previews = [
  236. ArchivePreview(
  237. id=a.id,
  238. print_name=a.print_name,
  239. thumbnail_path=a.thumbnail_path,
  240. status=a.status,
  241. filament_type=a.filament_type,
  242. filament_color=a.filament_color,
  243. )
  244. for a in archives
  245. ]
  246. response.append(
  247. ProjectListResponse(
  248. id=project.id,
  249. name=project.name,
  250. description=project.description,
  251. color=project.color,
  252. status=project.status,
  253. target_count=project.target_count,
  254. target_parts_count=project.target_parts_count,
  255. target_sets=project.target_sets,
  256. budget=project.budget,
  257. tags=project.tags,
  258. due_date=project.due_date,
  259. priority=project.priority,
  260. created_at=project.created_at,
  261. archive_count=archive_count,
  262. total_items=total_items,
  263. completed_count=completed_count,
  264. failed_count=failed_count,
  265. queue_count=queue_count,
  266. progress_percent=progress_percent,
  267. archives=archive_previews,
  268. url=project.url,
  269. cover_image_filename=project.cover_image_filename,
  270. )
  271. )
  272. return response
  273. @router.post("/", response_model=ProjectResponse)
  274. async def create_project(
  275. data: ProjectCreate,
  276. db: AsyncSession = Depends(get_db),
  277. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  278. ):
  279. """Create a new project."""
  280. # Verify parent exists if specified
  281. parent_name = None
  282. if data.parent_id:
  283. parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
  284. parent = parent_result.scalar_one_or_none()
  285. if not parent:
  286. raise HTTPException(status_code=400, detail="Parent project not found")
  287. parent_name = parent.name
  288. project = Project(
  289. name=data.name,
  290. description=data.description,
  291. color=data.color,
  292. target_count=data.target_count,
  293. target_parts_count=data.target_parts_count,
  294. target_sets=data.target_sets,
  295. notes=data.notes,
  296. tags=data.tags,
  297. due_date=data.due_date,
  298. priority=data.priority,
  299. budget=data.budget,
  300. parent_id=data.parent_id,
  301. url=data.url,
  302. )
  303. db.add(project)
  304. await db.flush()
  305. await db.refresh(project)
  306. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  307. return ProjectResponse(
  308. id=project.id,
  309. name=project.name,
  310. description=project.description,
  311. color=project.color,
  312. status=project.status,
  313. target_count=project.target_count,
  314. target_parts_count=project.target_parts_count,
  315. target_sets=project.target_sets,
  316. notes=project.notes,
  317. attachments=project.attachments,
  318. url=project.url,
  319. cover_image_filename=project.cover_image_filename,
  320. tags=project.tags,
  321. due_date=project.due_date,
  322. priority=project.priority,
  323. budget=project.budget,
  324. is_template=project.is_template,
  325. template_source_id=project.template_source_id,
  326. parent_id=project.parent_id,
  327. parent_name=parent_name,
  328. children=[],
  329. created_at=project.created_at,
  330. updated_at=project.updated_at,
  331. stats=stats,
  332. )
  333. # ============ Phase 8: Template Endpoints (Static routes BEFORE dynamic {project_id}) ============
  334. @router.get("/templates", response_model=list[ProjectListResponse])
  335. async def list_templates(
  336. db: AsyncSession = Depends(get_db),
  337. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  338. ):
  339. """List all project templates."""
  340. result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
  341. templates = result.scalars().all()
  342. response = []
  343. for project in templates:
  344. # Get archive count
  345. archive_count_result = await db.execute(
  346. select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
  347. )
  348. archive_count = archive_count_result.scalar() or 0
  349. response.append(
  350. ProjectListResponse(
  351. id=project.id,
  352. name=project.name,
  353. description=project.description,
  354. color=project.color,
  355. status=project.status,
  356. target_count=project.target_count,
  357. target_parts_count=project.target_parts_count,
  358. target_sets=project.target_sets,
  359. budget=project.budget,
  360. tags=project.tags,
  361. due_date=project.due_date,
  362. priority=project.priority,
  363. created_at=project.created_at,
  364. archive_count=archive_count,
  365. queue_count=0,
  366. progress_percent=None,
  367. archives=[],
  368. url=project.url,
  369. cover_image_filename=project.cover_image_filename,
  370. )
  371. )
  372. return response
  373. @router.post("/from-template/{template_id}", response_model=ProjectResponse)
  374. async def create_project_from_template(
  375. template_id: int,
  376. name: str = None,
  377. db: AsyncSession = Depends(get_db),
  378. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  379. ):
  380. """Create a new project from a template."""
  381. result = await db.execute(select(Project).where(Project.id == template_id))
  382. template = result.scalar_one_or_none()
  383. if not template:
  384. raise HTTPException(status_code=404, detail="Template not found")
  385. if not template.is_template:
  386. raise HTTPException(status_code=400, detail="Project is not a template")
  387. # Create new project
  388. project = Project(
  389. name=name or template.name.replace(" (Template)", ""),
  390. description=template.description,
  391. color=template.color,
  392. target_count=template.target_count,
  393. target_parts_count=template.target_parts_count,
  394. target_sets=template.target_sets,
  395. notes=template.notes,
  396. tags=template.tags,
  397. priority=template.priority,
  398. budget=template.budget,
  399. is_template=False,
  400. template_source_id=template.id,
  401. url=template.url,
  402. )
  403. db.add(project)
  404. await db.flush()
  405. # Copy BOM items
  406. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == template_id))
  407. bom_items = bom_result.scalars().all()
  408. for item in bom_items:
  409. new_item = ProjectBOMItem(
  410. project_id=project.id,
  411. name=item.name,
  412. quantity_needed=item.quantity_needed,
  413. quantity_acquired=0,
  414. unit_price=item.unit_price,
  415. sourcing_url=item.sourcing_url,
  416. stl_filename=item.stl_filename,
  417. remarks=item.remarks,
  418. sort_order=item.sort_order,
  419. )
  420. db.add(new_item)
  421. await db.flush()
  422. await db.refresh(project)
  423. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  424. return ProjectResponse(
  425. id=project.id,
  426. name=project.name,
  427. description=project.description,
  428. color=project.color,
  429. status=project.status,
  430. target_count=project.target_count,
  431. target_parts_count=project.target_parts_count,
  432. target_sets=project.target_sets,
  433. notes=project.notes,
  434. attachments=project.attachments,
  435. url=project.url,
  436. cover_image_filename=project.cover_image_filename,
  437. tags=project.tags,
  438. due_date=project.due_date,
  439. priority=project.priority,
  440. budget=project.budget,
  441. is_template=project.is_template,
  442. template_source_id=project.template_source_id,
  443. parent_id=project.parent_id,
  444. parent_name=None,
  445. children=[],
  446. created_at=project.created_at,
  447. updated_at=project.updated_at,
  448. stats=stats,
  449. )
  450. # ============ Dynamic {project_id} Routes ============
  451. async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
  452. """Get preview info for child projects."""
  453. result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
  454. children = result.scalars().all()
  455. previews = []
  456. for child in children:
  457. # Get completed count for progress (sum of quantities)
  458. completed_result = await db.execute(
  459. select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
  460. PrintArchive.project_id == child.id,
  461. PrintArchive.status == "completed",
  462. _LIVE_ARCHIVE,
  463. )
  464. )
  465. completed_count = completed_result.scalar() or 0
  466. progress = None
  467. if child.target_count and child.target_count > 0:
  468. progress = round((int(completed_count) / child.target_count) * 100, 1)
  469. previews.append(
  470. ProjectChildPreview(
  471. id=child.id,
  472. name=child.name,
  473. color=child.color,
  474. status=child.status,
  475. progress_percent=progress,
  476. )
  477. )
  478. return previews
  479. @router.get("/{project_id}", response_model=ProjectResponse)
  480. async def get_project(
  481. project_id: int,
  482. db: AsyncSession = Depends(get_db),
  483. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  484. ):
  485. """Get a project by ID with detailed stats."""
  486. result = await db.execute(select(Project).where(Project.id == project_id))
  487. project = result.scalar_one_or_none()
  488. if not project:
  489. raise HTTPException(status_code=404, detail="Project not found")
  490. # Get parent name
  491. parent_name = None
  492. if project.parent_id:
  493. parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
  494. parent_name = parent_result.scalar()
  495. # Get children
  496. children = await get_child_previews(db, project.id)
  497. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  498. return ProjectResponse(
  499. id=project.id,
  500. name=project.name,
  501. description=project.description,
  502. color=project.color,
  503. status=project.status,
  504. target_count=project.target_count,
  505. target_parts_count=project.target_parts_count,
  506. target_sets=project.target_sets,
  507. notes=project.notes,
  508. attachments=project.attachments,
  509. url=project.url,
  510. cover_image_filename=project.cover_image_filename,
  511. tags=project.tags,
  512. due_date=project.due_date,
  513. priority=project.priority,
  514. budget=project.budget,
  515. is_template=project.is_template,
  516. template_source_id=project.template_source_id,
  517. parent_id=project.parent_id,
  518. parent_name=parent_name,
  519. children=children,
  520. created_at=project.created_at,
  521. updated_at=project.updated_at,
  522. stats=stats,
  523. )
  524. @router.patch("/{project_id}", response_model=ProjectResponse)
  525. async def update_project(
  526. project_id: int,
  527. data: ProjectUpdate,
  528. db: AsyncSession = Depends(get_db),
  529. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  530. ):
  531. """Update a project."""
  532. result = await db.execute(select(Project).where(Project.id == project_id))
  533. project = result.scalar_one_or_none()
  534. if not project:
  535. raise HTTPException(status_code=404, detail="Project not found")
  536. # Update fields if provided
  537. if data.name is not None:
  538. project.name = data.name
  539. if data.description is not None:
  540. project.description = data.description
  541. if data.color is not None:
  542. project.color = data.color
  543. if data.status is not None:
  544. if data.status not in ["active", "completed", "archived"]:
  545. raise HTTPException(status_code=400, detail="Invalid status")
  546. project.status = data.status
  547. if data.target_count is not None:
  548. project.target_count = data.target_count
  549. if data.target_parts_count is not None:
  550. project.target_parts_count = data.target_parts_count
  551. # Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
  552. # alone (same #2536 semantics as tags/due_date below).
  553. if "target_sets" in data.model_fields_set:
  554. project.target_sets = data.target_sets
  555. if data.notes is not None:
  556. project.notes = data.notes
  557. # Sent-but-null clears the field; omitted leaves it alone. Guarding on
  558. # ``is not None`` would make an emptied tags field or a removed due date
  559. # silently revert to the stored value (#2536).
  560. if "tags" in data.model_fields_set:
  561. project.tags = data.tags
  562. if "due_date" in data.model_fields_set:
  563. project.due_date = data.due_date
  564. if data.priority is not None:
  565. if data.priority not in ["low", "normal", "high", "urgent"]:
  566. raise HTTPException(status_code=400, detail="Invalid priority")
  567. project.priority = data.priority
  568. if "budget" in data.model_fields_set:
  569. project.budget = data.budget
  570. if "url" in data.model_fields_set:
  571. # Pydantic validator already guarantees http(s) prefix or None.
  572. project.url = data.url
  573. if data.parent_id is not None:
  574. # Verify parent exists and prevent circular reference
  575. if data.parent_id == project_id:
  576. raise HTTPException(status_code=400, detail="Project cannot be its own parent")
  577. if data.parent_id != 0: # 0 means remove parent
  578. parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
  579. if not parent_result.scalar_one_or_none():
  580. raise HTTPException(status_code=400, detail="Parent project not found")
  581. project.parent_id = data.parent_id
  582. else:
  583. project.parent_id = None
  584. await db.flush()
  585. await db.refresh(project)
  586. # Get parent name
  587. parent_name = None
  588. if project.parent_id:
  589. parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
  590. parent_name = parent_result.scalar()
  591. # Get children
  592. children = await get_child_previews(db, project.id)
  593. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  594. return ProjectResponse(
  595. id=project.id,
  596. name=project.name,
  597. description=project.description,
  598. color=project.color,
  599. status=project.status,
  600. target_count=project.target_count,
  601. target_parts_count=project.target_parts_count,
  602. target_sets=project.target_sets,
  603. notes=project.notes,
  604. attachments=project.attachments,
  605. url=project.url,
  606. cover_image_filename=project.cover_image_filename,
  607. tags=project.tags,
  608. due_date=project.due_date,
  609. priority=project.priority,
  610. budget=project.budget,
  611. is_template=project.is_template,
  612. template_source_id=project.template_source_id,
  613. parent_id=project.parent_id,
  614. parent_name=parent_name,
  615. children=children,
  616. created_at=project.created_at,
  617. updated_at=project.updated_at,
  618. stats=stats,
  619. )
  620. @router.delete("/{project_id}")
  621. async def delete_project(
  622. project_id: int,
  623. db: AsyncSession = Depends(get_db),
  624. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
  625. ):
  626. """Delete a project. Archives and queue items will have project_id set to NULL."""
  627. result = await db.execute(select(Project).where(Project.id == project_id))
  628. project = result.scalar_one_or_none()
  629. if not project:
  630. raise HTTPException(status_code=404, detail="Project not found")
  631. await db.delete(project)
  632. return {"message": "Project deleted"}
  633. @router.get("/{project_id}/archives")
  634. async def list_project_archives(
  635. project_id: int,
  636. limit: int = 100,
  637. offset: int = 0,
  638. db: AsyncSession = Depends(get_db),
  639. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  640. ):
  641. """List archives in a project."""
  642. # Verify project exists
  643. result = await db.execute(select(Project).where(Project.id == project_id))
  644. if not result.scalar_one_or_none():
  645. raise HTTPException(status_code=404, detail="Project not found")
  646. # Get archives with both ``project`` and ``created_by`` eagerly loaded.
  647. # ``archive_to_response`` accesses ``archive.created_by.username`` to
  648. # surface the creator on the archive card; without selectinload that's
  649. # a lazy attribute access on a closed async session, which throws
  650. # ``MissingGreenlet`` and produces a 500. ``ArchiveService.list_archives``
  651. # already loads both — this route just got out of step.
  652. query = (
  653. select(PrintArchive)
  654. .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
  655. .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
  656. .order_by(PrintArchive.created_at.desc())
  657. .limit(limit)
  658. .offset(offset)
  659. )
  660. result = await db.execute(query)
  661. archives = result.scalars().all()
  662. # Import the response converter from archives module
  663. from backend.app.api.routes.archives import _load_run_aggregates, archive_to_response
  664. # Load run aggregates so multi-run archives' time/accuracy badge is
  665. # suppressed consistently with the main archives list endpoint (#1608).
  666. run_aggregates = await _load_run_aggregates(db, [a.id for a in archives])
  667. return [archive_to_response(a, run_aggregate=run_aggregates.get(a.id)) for a in archives]
  668. @router.get("/{project_id}/queue")
  669. async def list_project_queue(
  670. project_id: int,
  671. db: AsyncSession = Depends(get_db),
  672. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  673. ):
  674. """List queue items in a project."""
  675. # Verify project exists
  676. result = await db.execute(select(Project).where(Project.id == project_id))
  677. if not result.scalar_one_or_none():
  678. raise HTTPException(status_code=404, detail="Project not found")
  679. # Get queue items
  680. query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
  681. result = await db.execute(query)
  682. items = result.scalars().all()
  683. return items
  684. @router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
  685. async def get_project_file_progress(
  686. project_id: int,
  687. db: AsyncSession = Depends(get_db),
  688. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  689. ):
  690. """Completed-run counts per library file inside a project (#1897).
  691. Counts completed ``PrintLogEntry`` rows (same source as the aggregate
  692. project stats) of archives attributed to this project, and maps each run to
  693. one of the project's library files — the files living in folders linked to
  694. the project, the same set the project detail page renders.
  695. A run is attributed to exactly one file, by the strongest available match:
  696. 1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
  697. 2. content hash (covers historical rows),
  698. 3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
  699. Files with no completed runs are omitted — the frontend treats absence as 0.
  700. """
  701. result = await db.execute(select(Project.id).where(Project.id == project_id))
  702. if result.scalar_one_or_none() is None:
  703. raise HTTPException(status_code=404, detail="Project not found")
  704. files_result = await db.execute(
  705. select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
  706. .join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
  707. .where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
  708. )
  709. file_rows = files_result.all()
  710. if not file_rows:
  711. return []
  712. # First match wins within each tier, so iteration order (file id) is stable
  713. # when duplicates share a hash or filename.
  714. by_id = {fid for fid, _, _ in file_rows}
  715. by_hash: dict[str, int] = {}
  716. by_name: dict[str, int] = {}
  717. for fid, fhash, fname in file_rows:
  718. if fhash and fhash not in by_hash:
  719. by_hash[fhash] = fid
  720. if fname not in by_name:
  721. by_name[fname] = fid
  722. runs_result = await db.execute(
  723. select(
  724. PrintArchive.library_file_id,
  725. PrintArchive.content_hash,
  726. PrintArchive.filename,
  727. func.count(PrintLogEntry.id),
  728. )
  729. .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
  730. .where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE)
  731. .group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
  732. )
  733. counts: dict[int, int] = {}
  734. for lib_file_id, content_hash, filename, run_count in runs_result.all():
  735. if lib_file_id in by_id:
  736. fid = lib_file_id
  737. elif content_hash and content_hash in by_hash:
  738. fid = by_hash[content_hash]
  739. elif filename in by_name:
  740. fid = by_name[filename]
  741. else:
  742. continue
  743. counts[fid] = counts.get(fid, 0) + run_count
  744. return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
  745. @router.post("/{project_id}/add-archives")
  746. async def add_archives_to_project(
  747. project_id: int,
  748. data: BatchAddArchives,
  749. db: AsyncSession = Depends(get_db),
  750. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  751. ):
  752. """Batch add archives to a project."""
  753. # Verify project exists
  754. result = await db.execute(select(Project).where(Project.id == project_id))
  755. if not result.scalar_one_or_none():
  756. raise HTTPException(status_code=404, detail="Project not found")
  757. # Update archives
  758. updated = 0
  759. for archive_id in data.archive_ids:
  760. result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
  761. archive = result.scalar_one_or_none()
  762. if archive:
  763. archive.project_id = project_id
  764. updated += 1
  765. return {"message": f"Added {updated} archives to project"}
  766. @router.post("/{project_id}/add-queue")
  767. async def add_queue_items_to_project(
  768. project_id: int,
  769. data: BatchAddQueueItems,
  770. db: AsyncSession = Depends(get_db),
  771. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  772. ):
  773. """Batch add queue items to a project."""
  774. # Verify project exists
  775. result = await db.execute(select(Project).where(Project.id == project_id))
  776. if not result.scalar_one_or_none():
  777. raise HTTPException(status_code=404, detail="Project not found")
  778. # Update queue items
  779. updated = 0
  780. for item_id in data.queue_item_ids:
  781. result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
  782. item = result.scalar_one_or_none()
  783. if item:
  784. item.project_id = project_id
  785. updated += 1
  786. return {"message": f"Added {updated} queue items to project"}
  787. @router.post("/{project_id}/remove-archives")
  788. async def remove_archives_from_project(
  789. project_id: int,
  790. data: BatchAddArchives,
  791. db: AsyncSession = Depends(get_db),
  792. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  793. ):
  794. """Remove archives from a project (sets project_id to NULL)."""
  795. updated = 0
  796. for archive_id in data.archive_ids:
  797. result = await db.execute(
  798. select(PrintArchive).where(
  799. PrintArchive.id == archive_id,
  800. PrintArchive.project_id == project_id,
  801. )
  802. )
  803. archive = result.scalar_one_or_none()
  804. if archive:
  805. archive.project_id = None
  806. updated += 1
  807. return {"message": f"Removed {updated} archives from project"}
  808. def get_project_attachments_dir(project_id: int) -> Path:
  809. """Get the attachments directory for a project."""
  810. base_dir = Path(settings.archive_dir)
  811. return base_dir / "projects" / str(project_id) / "attachments"
  812. # Cover-image upload accepts only common web-renderable image types (#1155).
  813. # Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't
  814. # render well as a card thumbnail.
  815. COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
  816. COVER_IMAGE_CONTENT_TYPES = {
  817. ".jpg": "image/jpeg",
  818. ".jpeg": "image/jpeg",
  819. ".png": "image/png",
  820. ".gif": "image/gif",
  821. ".webp": "image/webp",
  822. }
  823. # Allowed file extensions for attachments
  824. ALLOWED_ATTACHMENT_EXTENSIONS = {
  825. # Images
  826. ".jpg",
  827. ".jpeg",
  828. ".png",
  829. ".gif",
  830. ".webp",
  831. ".svg",
  832. ".bmp",
  833. ".ico",
  834. # Documents
  835. ".pdf",
  836. ".doc",
  837. ".docx",
  838. ".xls",
  839. ".xlsx",
  840. ".ppt",
  841. ".pptx",
  842. ".odt",
  843. ".ods",
  844. ".odp",
  845. ".txt",
  846. ".rtf",
  847. ".csv",
  848. ".md",
  849. # 3D/CAD files
  850. ".stl",
  851. ".obj",
  852. ".3mf",
  853. ".step",
  854. ".stp",
  855. ".iges",
  856. ".igs",
  857. ".f3d",
  858. ".scad",
  859. # Archives
  860. ".zip",
  861. ".rar",
  862. ".7z",
  863. ".tar",
  864. ".gz",
  865. # Code/scripts (for Klipper macros, scripts, etc.)
  866. ".py",
  867. ".sh",
  868. ".cfg",
  869. ".conf",
  870. ".gcode",
  871. ".ini",
  872. # Other common formats
  873. ".json",
  874. ".xml",
  875. ".yaml",
  876. ".yml",
  877. }
  878. @router.post("/{project_id}/attachments")
  879. async def upload_attachment(
  880. project_id: int,
  881. file: UploadFile = File(...),
  882. db: AsyncSession = Depends(get_db),
  883. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  884. ):
  885. """Upload an attachment to a project."""
  886. logger.info("=== UPLOAD START: %s for project %s ===", file.filename, project_id)
  887. # Verify project exists
  888. result = await db.execute(select(Project).where(Project.id == project_id))
  889. project = result.scalar_one_or_none()
  890. if not project:
  891. raise HTTPException(status_code=404, detail="Project not found")
  892. # Validate file extension
  893. original_name = file.filename or "unknown"
  894. ext = os.path.splitext(original_name)[1].lower()
  895. if ext not in ALLOWED_ATTACHMENT_EXTENSIONS:
  896. raise HTTPException(
  897. status_code=400,
  898. detail=f"File type '{ext}' not supported. Allowed: images, PDFs, documents, STL, 3MF, archives.",
  899. )
  900. # Create attachments directory
  901. attachments_dir = get_project_attachments_dir(project_id)
  902. attachments_dir.mkdir(parents=True, exist_ok=True)
  903. # Generate unique filename
  904. unique_filename = f"{uuid.uuid4().hex}{ext}"
  905. file_path = attachments_dir / unique_filename # SEC-PATH-OK: unique_filename = uuid.uuid4().hex + ext
  906. # Save file
  907. try:
  908. with open(file_path, "wb") as f:
  909. content = await file.read()
  910. f.write(content)
  911. logger.info("=== FILE SAVED: %s, size: %s ===", file_path, len(content))
  912. except Exception as e:
  913. logger.error("Failed to save attachment: %s", e)
  914. raise HTTPException(status_code=500, detail="Failed to save attachment")
  915. # Update project attachments JSON
  916. attachments = list(project.attachments or [])
  917. new_attachment = {
  918. "filename": unique_filename,
  919. "original_name": original_name,
  920. "size": len(content),
  921. "uploaded_at": datetime.now().isoformat(),
  922. }
  923. attachments.append(new_attachment)
  924. # Simple ORM update
  925. project.attachments = attachments
  926. db.add(project) # Explicitly add to session
  927. logger.info("=== BEFORE COMMIT: %s attachments ===", len(attachments))
  928. await db.flush()
  929. await db.commit()
  930. logger.info("=== AFTER COMMIT ===")
  931. # Verify by re-querying
  932. result = await db.execute(select(Project).where(Project.id == project_id))
  933. fresh_project = result.scalar_one()
  934. logger.info("=== VERIFIED: %s attachments ===", len(fresh_project.attachments or []))
  935. return {
  936. "status": "success",
  937. "filename": unique_filename,
  938. "original_name": original_name,
  939. "attachments": fresh_project.attachments,
  940. }
  941. @router.get("/{project_id}/attachments/{filename}")
  942. async def download_attachment(
  943. project_id: int,
  944. filename: str,
  945. db: AsyncSession = Depends(get_db),
  946. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  947. ):
  948. """Download an attachment from a project."""
  949. # Validate filename to prevent path traversal
  950. if "/" in filename or "\\" in filename or ".." in filename or not filename:
  951. raise HTTPException(status_code=400, detail="Invalid filename")
  952. # Verify project exists
  953. result = await db.execute(select(Project).where(Project.id == project_id))
  954. project = result.scalar_one_or_none()
  955. if not project:
  956. raise HTTPException(status_code=404, detail="Project not found")
  957. # Verify attachment exists in project
  958. attachments = project.attachments or []
  959. attachment = next((a for a in attachments if a.get("filename") == filename), None)
  960. if not attachment:
  961. raise HTTPException(status_code=404, detail="Attachment not found")
  962. # Check file exists
  963. file_path = (
  964. get_project_attachments_dir(project_id) / filename
  965. ) # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
  966. if not file_path.exists():
  967. raise HTTPException(status_code=404, detail="Attachment file not found")
  968. return FileResponse(
  969. file_path,
  970. filename=attachment.get("original_name", filename),
  971. media_type="application/octet-stream",
  972. )
  973. @router.delete("/{project_id}/attachments/{filename}")
  974. async def delete_attachment(
  975. project_id: int,
  976. filename: str,
  977. db: AsyncSession = Depends(get_db),
  978. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  979. ):
  980. """Delete an attachment from a project."""
  981. # Validate filename to prevent path traversal
  982. if "/" in filename or "\\" in filename or ".." in filename or not filename:
  983. raise HTTPException(status_code=400, detail="Invalid filename")
  984. # Verify project exists
  985. result = await db.execute(select(Project).where(Project.id == project_id))
  986. project = result.scalar_one_or_none()
  987. if not project:
  988. raise HTTPException(status_code=404, detail="Project not found")
  989. # Find and remove attachment from list
  990. attachments = project.attachments or []
  991. attachment = next((a for a in attachments if a.get("filename") == filename), None)
  992. if not attachment:
  993. raise HTTPException(status_code=404, detail="Attachment not found")
  994. # Remove from list
  995. attachments = [a for a in attachments if a.get("filename") != filename]
  996. project.attachments = attachments if attachments else None
  997. # Delete file
  998. file_path = (
  999. get_project_attachments_dir(project_id) / filename
  1000. ) # SEC-PATH-OK: filename validated above (no /, \\, .., empty) + attachment membership check
  1001. if file_path.exists():
  1002. try:
  1003. os.remove(file_path)
  1004. except Exception as e:
  1005. logger.warning("Failed to delete attachment file: %s", e)
  1006. await db.flush()
  1007. await db.refresh(project)
  1008. return {
  1009. "status": "success",
  1010. "message": "Attachment deleted",
  1011. "attachments": project.attachments,
  1012. }
  1013. # ============ #1155: Cover image ============
  1014. @router.post("/{project_id}/cover-image")
  1015. async def upload_project_cover_image(
  1016. project_id: int,
  1017. file: UploadFile = File(...),
  1018. db: AsyncSession = Depends(get_db),
  1019. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  1020. ):
  1021. """Upload (or replace) the project's cover image (#1155).
  1022. Stored alongside other attachments but tracked via Project.cover_image_filename
  1023. so swap/delete operations don't touch the attachments list. Replaces any
  1024. existing cover image — the prior file is deleted on disk before the new one
  1025. lands so a stuck filesystem reference can't accumulate orphaned images.
  1026. """
  1027. result = await db.execute(select(Project).where(Project.id == project_id))
  1028. project = result.scalar_one_or_none()
  1029. if not project:
  1030. raise HTTPException(status_code=404, detail="Project not found")
  1031. original_name = file.filename or "cover"
  1032. ext = os.path.splitext(original_name)[1].lower()
  1033. if ext not in COVER_IMAGE_EXTENSIONS:
  1034. raise HTTPException(
  1035. status_code=400,
  1036. detail=f"Cover image must be one of {sorted(COVER_IMAGE_EXTENSIONS)}",
  1037. )
  1038. attachments_dir = get_project_attachments_dir(project_id)
  1039. attachments_dir.mkdir(parents=True, exist_ok=True)
  1040. # Remove the previous cover-image file from disk first so we don't accumulate
  1041. # orphans when users repeatedly replace it. Best-effort: a missing/locked file
  1042. # shouldn't block a successful replacement.
  1043. if project.cover_image_filename:
  1044. old_path = attachments_dir / project.cover_image_filename
  1045. if old_path.exists():
  1046. try:
  1047. os.remove(old_path)
  1048. except OSError as e:
  1049. logger.warning("Failed to delete old cover image %s: %s", old_path, e)
  1050. unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
  1051. file_path = attachments_dir / unique_filename # SEC-PATH-OK: unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
  1052. try:
  1053. with open(file_path, "wb") as f:
  1054. content = await file.read()
  1055. f.write(content)
  1056. except OSError as e:
  1057. logger.error("Failed to save cover image: %s", e)
  1058. raise HTTPException(status_code=500, detail="Failed to save cover image")
  1059. project.cover_image_filename = unique_filename
  1060. db.add(project)
  1061. await db.flush()
  1062. await db.commit()
  1063. return {
  1064. "status": "success",
  1065. "filename": unique_filename,
  1066. "size": len(content),
  1067. }
  1068. @router.get("/{project_id}/cover-image")
  1069. async def get_project_cover_image(
  1070. project_id: int,
  1071. db: AsyncSession = Depends(get_db),
  1072. _: None = RequireCameraStreamTokenIfAuthEnabled,
  1073. ):
  1074. """Stream the project's cover image (#1155).
  1075. Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
  1076. so this route accepts the same `?token=` stream-credential as
  1077. /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
  1078. result = await db.execute(select(Project).where(Project.id == project_id))
  1079. project = result.scalar_one_or_none()
  1080. if not project:
  1081. raise HTTPException(status_code=404, detail="Project not found")
  1082. if not project.cover_image_filename:
  1083. raise HTTPException(status_code=404, detail="No cover image set")
  1084. file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
  1085. if not file_path.exists():
  1086. # DB references a file that vanished from disk — clear the dangling
  1087. # reference so future GETs get a clean 404 instead of repeatedly
  1088. # touching the filesystem.
  1089. logger.warning("Cover image file missing for project %s: %s", project_id, file_path)
  1090. project.cover_image_filename = None
  1091. await db.commit()
  1092. raise HTTPException(status_code=404, detail="Cover image file not found")
  1093. ext = os.path.splitext(project.cover_image_filename)[1].lower()
  1094. media_type = COVER_IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream")
  1095. return FileResponse(file_path, media_type=media_type)
  1096. @router.delete("/{project_id}/cover-image")
  1097. async def delete_project_cover_image(
  1098. project_id: int,
  1099. db: AsyncSession = Depends(get_db),
  1100. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  1101. ):
  1102. """Remove the project's cover image (#1155)."""
  1103. result = await db.execute(select(Project).where(Project.id == project_id))
  1104. project = result.scalar_one_or_none()
  1105. if not project:
  1106. raise HTTPException(status_code=404, detail="Project not found")
  1107. if project.cover_image_filename:
  1108. file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
  1109. if file_path.exists():
  1110. try:
  1111. os.remove(file_path)
  1112. except OSError as e:
  1113. logger.warning("Failed to delete cover image file %s: %s", file_path, e)
  1114. project.cover_image_filename = None
  1115. db.add(project)
  1116. await db.flush()
  1117. await db.commit()
  1118. return {"status": "success"}
  1119. # ============ Phase 7: BOM Endpoints ============
  1120. @router.get("/{project_id}/bom", response_model=list[BOMItemResponse])
  1121. async def list_bom_items(
  1122. project_id: int,
  1123. db: AsyncSession = Depends(get_db),
  1124. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  1125. ):
  1126. """List all BOM items for a project."""
  1127. # Verify project exists
  1128. result = await db.execute(select(Project).where(Project.id == project_id))
  1129. if not result.scalar_one_or_none():
  1130. raise HTTPException(status_code=404, detail="Project not found")
  1131. # Get BOM items
  1132. result = await db.execute(
  1133. select(ProjectBOMItem)
  1134. .where(ProjectBOMItem.project_id == project_id)
  1135. .order_by(ProjectBOMItem.sort_order, ProjectBOMItem.id)
  1136. )
  1137. items = result.scalars().all()
  1138. response = []
  1139. for item in items:
  1140. # Get archive name if linked
  1141. archive_name = None
  1142. if item.archive_id:
  1143. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  1144. archive_name = archive_result.scalar()
  1145. response.append(
  1146. BOMItemResponse(
  1147. id=item.id,
  1148. project_id=item.project_id,
  1149. name=item.name,
  1150. quantity_needed=item.quantity_needed,
  1151. quantity_acquired=item.quantity_acquired,
  1152. unit_price=item.unit_price,
  1153. sourcing_url=item.sourcing_url,
  1154. archive_id=item.archive_id,
  1155. archive_name=archive_name,
  1156. stl_filename=item.stl_filename,
  1157. remarks=item.remarks,
  1158. sort_order=item.sort_order,
  1159. is_complete=item.quantity_acquired >= item.quantity_needed,
  1160. created_at=item.created_at,
  1161. updated_at=item.updated_at,
  1162. )
  1163. )
  1164. return response
  1165. @router.post("/{project_id}/bom", response_model=BOMItemResponse)
  1166. async def create_bom_item(
  1167. project_id: int,
  1168. data: BOMItemCreate,
  1169. db: AsyncSession = Depends(get_db),
  1170. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  1171. ):
  1172. """Add a BOM item to a project."""
  1173. # Verify project exists
  1174. result = await db.execute(select(Project).where(Project.id == project_id))
  1175. if not result.scalar_one_or_none():
  1176. raise HTTPException(status_code=404, detail="Project not found")
  1177. # Get max sort order
  1178. max_order_result = await db.execute(
  1179. select(func.max(ProjectBOMItem.sort_order)).where(ProjectBOMItem.project_id == project_id)
  1180. )
  1181. max_order = max_order_result.scalar() or 0
  1182. item = ProjectBOMItem(
  1183. project_id=project_id,
  1184. name=data.name,
  1185. quantity_needed=data.quantity_needed,
  1186. unit_price=data.unit_price,
  1187. sourcing_url=data.sourcing_url,
  1188. archive_id=data.archive_id,
  1189. stl_filename=data.stl_filename,
  1190. remarks=data.remarks,
  1191. sort_order=max_order + 1,
  1192. )
  1193. db.add(item)
  1194. await db.flush()
  1195. await db.refresh(item)
  1196. # Get archive name if linked
  1197. archive_name = None
  1198. if item.archive_id:
  1199. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  1200. archive_name = archive_result.scalar()
  1201. return BOMItemResponse(
  1202. id=item.id,
  1203. project_id=item.project_id,
  1204. name=item.name,
  1205. quantity_needed=item.quantity_needed,
  1206. quantity_acquired=item.quantity_acquired,
  1207. unit_price=item.unit_price,
  1208. sourcing_url=item.sourcing_url,
  1209. archive_id=item.archive_id,
  1210. archive_name=archive_name,
  1211. stl_filename=item.stl_filename,
  1212. remarks=item.remarks,
  1213. sort_order=item.sort_order,
  1214. is_complete=item.quantity_acquired >= item.quantity_needed,
  1215. created_at=item.created_at,
  1216. updated_at=item.updated_at,
  1217. )
  1218. @router.patch("/{project_id}/bom/{item_id}", response_model=BOMItemResponse)
  1219. async def update_bom_item(
  1220. project_id: int,
  1221. item_id: int,
  1222. data: BOMItemUpdate,
  1223. db: AsyncSession = Depends(get_db),
  1224. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  1225. ):
  1226. """Update a BOM item."""
  1227. result = await db.execute(
  1228. select(ProjectBOMItem).where(
  1229. ProjectBOMItem.id == item_id,
  1230. ProjectBOMItem.project_id == project_id,
  1231. )
  1232. )
  1233. item = result.scalar_one_or_none()
  1234. if not item:
  1235. raise HTTPException(status_code=404, detail="BOM item not found")
  1236. if data.name is not None:
  1237. item.name = data.name
  1238. if data.quantity_needed is not None:
  1239. item.quantity_needed = data.quantity_needed
  1240. if data.quantity_acquired is not None:
  1241. item.quantity_acquired = data.quantity_acquired
  1242. if data.unit_price is not None:
  1243. item.unit_price = data.unit_price if data.unit_price != 0 else None
  1244. if data.sourcing_url is not None:
  1245. item.sourcing_url = data.sourcing_url if data.sourcing_url else None
  1246. if data.archive_id is not None:
  1247. item.archive_id = data.archive_id if data.archive_id != 0 else None
  1248. if data.stl_filename is not None:
  1249. item.stl_filename = data.stl_filename if data.stl_filename else None
  1250. if data.remarks is not None:
  1251. item.remarks = data.remarks if data.remarks else None
  1252. await db.flush()
  1253. await db.refresh(item)
  1254. # Get archive name if linked
  1255. archive_name = None
  1256. if item.archive_id:
  1257. archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == item.archive_id))
  1258. archive_name = archive_result.scalar()
  1259. return BOMItemResponse(
  1260. id=item.id,
  1261. project_id=item.project_id,
  1262. name=item.name,
  1263. quantity_needed=item.quantity_needed,
  1264. quantity_acquired=item.quantity_acquired,
  1265. unit_price=item.unit_price,
  1266. sourcing_url=item.sourcing_url,
  1267. archive_id=item.archive_id,
  1268. archive_name=archive_name,
  1269. stl_filename=item.stl_filename,
  1270. remarks=item.remarks,
  1271. sort_order=item.sort_order,
  1272. is_complete=item.quantity_acquired >= item.quantity_needed,
  1273. created_at=item.created_at,
  1274. updated_at=item.updated_at,
  1275. )
  1276. @router.delete("/{project_id}/bom/{item_id}")
  1277. async def delete_bom_item(
  1278. project_id: int,
  1279. item_id: int,
  1280. db: AsyncSession = Depends(get_db),
  1281. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
  1282. ):
  1283. """Delete a BOM item."""
  1284. result = await db.execute(
  1285. select(ProjectBOMItem).where(
  1286. ProjectBOMItem.id == item_id,
  1287. ProjectBOMItem.project_id == project_id,
  1288. )
  1289. )
  1290. item = result.scalar_one_or_none()
  1291. if not item:
  1292. raise HTTPException(status_code=404, detail="BOM item not found")
  1293. await db.delete(item)
  1294. return {"status": "success", "message": "BOM item deleted"}
  1295. @router.post("/{project_id}/create-template", response_model=ProjectResponse)
  1296. async def create_template_from_project(
  1297. project_id: int,
  1298. db: AsyncSession = Depends(get_db),
  1299. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  1300. ):
  1301. """Create a template from an existing project."""
  1302. result = await db.execute(select(Project).where(Project.id == project_id))
  1303. source = result.scalar_one_or_none()
  1304. if not source:
  1305. raise HTTPException(status_code=404, detail="Project not found")
  1306. # Create template
  1307. template = Project(
  1308. name=f"{source.name} (Template)",
  1309. description=source.description,
  1310. color=source.color,
  1311. target_count=source.target_count,
  1312. target_parts_count=source.target_parts_count,
  1313. target_sets=source.target_sets,
  1314. notes=source.notes,
  1315. tags=source.tags,
  1316. priority=source.priority,
  1317. budget=source.budget,
  1318. is_template=True,
  1319. template_source_id=source.id,
  1320. url=source.url,
  1321. )
  1322. db.add(template)
  1323. await db.flush()
  1324. # Copy BOM items
  1325. bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id))
  1326. bom_items = bom_result.scalars().all()
  1327. for item in bom_items:
  1328. new_item = ProjectBOMItem(
  1329. project_id=template.id,
  1330. name=item.name,
  1331. quantity_needed=item.quantity_needed,
  1332. quantity_acquired=0,
  1333. unit_price=item.unit_price,
  1334. sourcing_url=item.sourcing_url,
  1335. stl_filename=item.stl_filename,
  1336. remarks=item.remarks,
  1337. sort_order=item.sort_order,
  1338. )
  1339. db.add(new_item)
  1340. await db.flush()
  1341. await db.refresh(template)
  1342. stats = await compute_project_stats(db, template.id, template.target_count, template.target_parts_count)
  1343. return ProjectResponse(
  1344. id=template.id,
  1345. name=template.name,
  1346. description=template.description,
  1347. color=template.color,
  1348. status=template.status,
  1349. target_count=template.target_count,
  1350. target_parts_count=template.target_parts_count,
  1351. target_sets=template.target_sets,
  1352. notes=template.notes,
  1353. attachments=template.attachments,
  1354. url=template.url,
  1355. cover_image_filename=template.cover_image_filename,
  1356. tags=template.tags,
  1357. due_date=template.due_date,
  1358. priority=template.priority,
  1359. budget=template.budget,
  1360. is_template=template.is_template,
  1361. template_source_id=template.template_source_id,
  1362. parent_id=template.parent_id,
  1363. parent_name=None,
  1364. children=[],
  1365. created_at=template.created_at,
  1366. updated_at=template.updated_at,
  1367. stats=stats,
  1368. )
  1369. # ============ Phase 9: Timeline Endpoint ============
  1370. @router.get("/{project_id}/timeline", response_model=list[TimelineEvent])
  1371. async def get_project_timeline(
  1372. project_id: int,
  1373. limit: int = 50,
  1374. db: AsyncSession = Depends(get_db),
  1375. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  1376. ):
  1377. """Get timeline of events for a project."""
  1378. # Verify project exists
  1379. result = await db.execute(select(Project).where(Project.id == project_id))
  1380. project = result.scalar_one_or_none()
  1381. if not project:
  1382. raise HTTPException(status_code=404, detail="Project not found")
  1383. events = []
  1384. # Project creation event
  1385. events.append(
  1386. TimelineEvent(
  1387. event_type="project_created",
  1388. timestamp=project.created_at,
  1389. title="Project created",
  1390. description=f"Project '{project.name}' was created",
  1391. )
  1392. )
  1393. # Get archives and add events
  1394. archives_result = await db.execute(
  1395. select(PrintArchive)
  1396. .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
  1397. .order_by(PrintArchive.created_at.desc())
  1398. .limit(limit)
  1399. )
  1400. archives = archives_result.scalars().all()
  1401. for archive in archives:
  1402. if archive.status == "completed":
  1403. events.append(
  1404. TimelineEvent(
  1405. event_type="print_completed",
  1406. timestamp=archive.completed_at or archive.created_at,
  1407. title="Print completed",
  1408. description=archive.print_name,
  1409. metadata={
  1410. "archive_id": archive.id,
  1411. "print_time_hours": round((archive.print_time_seconds or 0) / 3600, 2),
  1412. "filament_grams": round(archive.filament_used_grams or 0, 1),
  1413. },
  1414. )
  1415. )
  1416. elif archive.status == "failed":
  1417. events.append(
  1418. TimelineEvent(
  1419. event_type="print_failed",
  1420. timestamp=archive.completed_at or archive.created_at,
  1421. title="Print failed",
  1422. description=archive.print_name,
  1423. metadata={"archive_id": archive.id},
  1424. )
  1425. )
  1426. # Get queue items
  1427. queue_result = await db.execute(
  1428. select(PrintQueueItem)
  1429. .where(PrintQueueItem.project_id == project_id)
  1430. .order_by(PrintQueueItem.created_at.desc())
  1431. .limit(limit)
  1432. )
  1433. queue_items = queue_result.scalars().all()
  1434. for item in queue_items:
  1435. if item.status == "printing":
  1436. events.append(
  1437. TimelineEvent(
  1438. event_type="print_started",
  1439. timestamp=item.started_at or item.created_at,
  1440. title="Print started",
  1441. description=item.print_name,
  1442. metadata={"queue_item_id": item.id},
  1443. )
  1444. )
  1445. elif item.status == "pending":
  1446. events.append(
  1447. TimelineEvent(
  1448. event_type="queued",
  1449. timestamp=item.created_at,
  1450. title="Added to queue",
  1451. description=item.print_name,
  1452. metadata={"queue_item_id": item.id},
  1453. )
  1454. )
  1455. # Sort by timestamp descending
  1456. events.sort(key=lambda e: e.timestamp, reverse=True)
  1457. return events[:limit]
  1458. # ============ Phase 10: Import/Export Endpoints ============
  1459. @router.get("/{project_id}/export")
  1460. async def export_project(
  1461. project_id: int,
  1462. format: str = "zip", # "zip" (with files) or "json" (metadata only)
  1463. db: AsyncSession = Depends(get_db),
  1464. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
  1465. ):
  1466. """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
  1467. result = await db.execute(select(Project).where(Project.id == project_id))
  1468. project = result.scalar_one_or_none()
  1469. if not project:
  1470. raise HTTPException(status_code=404, detail="Project not found")
  1471. # Get BOM items
  1472. bom_result = await db.execute(
  1473. select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)
  1474. )
  1475. bom_items = bom_result.scalars().all()
  1476. bom_export = [
  1477. {
  1478. "name": item.name,
  1479. "quantity_needed": item.quantity_needed,
  1480. "quantity_acquired": item.quantity_acquired,
  1481. "unit_price": item.unit_price,
  1482. "sourcing_url": item.sourcing_url,
  1483. "stl_filename": item.stl_filename,
  1484. "remarks": item.remarks,
  1485. }
  1486. for item in bom_items
  1487. ]
  1488. # Get linked folders and their files
  1489. folders_result = await db.execute(
  1490. select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)
  1491. )
  1492. linked_folders = folders_result.scalars().all()
  1493. folders_export = []
  1494. files_to_include = [] # (archive_path, zip_path)
  1495. for folder in linked_folders:
  1496. # Get files in this folder
  1497. files_result = await db.execute(
  1498. LibraryFile.active().where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
  1499. )
  1500. files = files_result.scalars().all()
  1501. folder_files = []
  1502. for f in files:
  1503. folder_files.append(
  1504. {
  1505. "filename": f.filename,
  1506. "file_type": f.file_type,
  1507. "notes": f.notes,
  1508. }
  1509. )
  1510. # Add file to include in ZIP
  1511. library_dir = get_library_dir()
  1512. file_path = library_dir / f.file_path
  1513. if file_path.exists():
  1514. zip_path = f"files/{folder.name}/{f.filename}"
  1515. files_to_include.append((file_path, zip_path))
  1516. # Also include thumbnail if exists
  1517. if f.thumbnail_path:
  1518. thumb_path = library_dir / f.thumbnail_path
  1519. if thumb_path.exists():
  1520. thumb_zip_path = f"files/{folder.name}/.thumbnails/{f.filename}.png"
  1521. files_to_include.append((thumb_path, thumb_zip_path))
  1522. folders_export.append(
  1523. {
  1524. "name": folder.name,
  1525. "files": folder_files,
  1526. }
  1527. )
  1528. # Build project JSON
  1529. project_data = {
  1530. "name": project.name,
  1531. "description": project.description,
  1532. "color": project.color,
  1533. "status": project.status,
  1534. "target_count": project.target_count,
  1535. "target_parts_count": project.target_parts_count,
  1536. "target_sets": project.target_sets,
  1537. "notes": project.notes,
  1538. "tags": project.tags,
  1539. "due_date": project.due_date.isoformat() if project.due_date else None,
  1540. "priority": project.priority,
  1541. "budget": project.budget,
  1542. "bom_items": bom_export,
  1543. "linked_folders": folders_export,
  1544. }
  1545. # Return JSON if requested (for bulk export)
  1546. if format == "json":
  1547. return project_data
  1548. # Create ZIP in memory
  1549. zip_buffer = io.BytesIO()
  1550. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1551. # Add project.json
  1552. zf.writestr("project.json", json.dumps(project_data, indent=2))
  1553. # Add files
  1554. for file_path, zip_path in files_to_include:
  1555. zf.write(file_path, zip_path)
  1556. zip_buffer.seek(0)
  1557. # Generate filename
  1558. safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in project.name)
  1559. filename = f"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip"
  1560. return StreamingResponse(
  1561. zip_buffer,
  1562. media_type="application/zip",
  1563. headers={"Content-Disposition": build_content_disposition(filename)},
  1564. )
  1565. @router.post("/import", response_model=ProjectResponse)
  1566. async def import_project(
  1567. data: ProjectImport,
  1568. db: AsyncSession = Depends(get_db),
  1569. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  1570. ):
  1571. """Import a project with optional BOM items and linked folders."""
  1572. # Create the project
  1573. project = Project(
  1574. name=data.name,
  1575. description=data.description,
  1576. color=data.color,
  1577. status=data.status,
  1578. target_count=data.target_count,
  1579. target_parts_count=data.target_parts_count,
  1580. target_sets=data.target_sets,
  1581. notes=data.notes,
  1582. tags=data.tags,
  1583. due_date=data.due_date,
  1584. priority=data.priority,
  1585. budget=data.budget,
  1586. )
  1587. db.add(project)
  1588. await db.flush()
  1589. # Create BOM items
  1590. for idx, bom_data in enumerate(data.bom_items):
  1591. bom_item = ProjectBOMItem(
  1592. project_id=project.id,
  1593. name=bom_data.name,
  1594. quantity_needed=bom_data.quantity_needed,
  1595. quantity_acquired=bom_data.quantity_acquired,
  1596. unit_price=bom_data.unit_price,
  1597. sourcing_url=bom_data.sourcing_url,
  1598. stl_filename=bom_data.stl_filename,
  1599. remarks=bom_data.remarks,
  1600. sort_order=idx,
  1601. )
  1602. db.add(bom_item)
  1603. # Create linked folders in library
  1604. for folder_data in data.linked_folders:
  1605. # Check if folder with this name already exists at root level
  1606. existing_result = await db.execute(
  1607. select(LibraryFolder).where(
  1608. LibraryFolder.name == folder_data.name,
  1609. LibraryFolder.parent_id.is_(None),
  1610. )
  1611. )
  1612. existing_folder = existing_result.scalar_one_or_none()
  1613. if existing_folder:
  1614. # Link existing folder to project
  1615. existing_folder.project_id = project.id
  1616. else:
  1617. # Create new folder linked to project
  1618. new_folder = LibraryFolder(
  1619. name=folder_data.name,
  1620. project_id=project.id,
  1621. is_external=False,
  1622. external_readonly=False,
  1623. external_show_hidden=False,
  1624. )
  1625. db.add(new_folder)
  1626. await db.flush()
  1627. await db.refresh(project)
  1628. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  1629. return ProjectResponse(
  1630. id=project.id,
  1631. name=project.name,
  1632. description=project.description,
  1633. color=project.color,
  1634. status=project.status,
  1635. target_count=project.target_count,
  1636. target_parts_count=project.target_parts_count,
  1637. target_sets=project.target_sets,
  1638. notes=project.notes,
  1639. attachments=project.attachments,
  1640. url=project.url,
  1641. cover_image_filename=project.cover_image_filename,
  1642. tags=project.tags,
  1643. due_date=project.due_date,
  1644. priority=project.priority,
  1645. budget=project.budget,
  1646. is_template=project.is_template,
  1647. template_source_id=project.template_source_id,
  1648. parent_id=project.parent_id,
  1649. parent_name=None,
  1650. children=[],
  1651. created_at=project.created_at,
  1652. updated_at=project.updated_at,
  1653. stats=stats,
  1654. )
  1655. @router.post("/import/file", response_model=ProjectResponse)
  1656. async def import_project_file(
  1657. file: UploadFile = File(...),
  1658. db: AsyncSession = Depends(get_db),
  1659. _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
  1660. ):
  1661. """Import a project from a ZIP or JSON file."""
  1662. if not file.filename:
  1663. raise HTTPException(status_code=400, detail="No filename provided")
  1664. # Determine file type
  1665. filename_lower = file.filename.lower()
  1666. content = await file.read()
  1667. if filename_lower.endswith(".zip"):
  1668. # Extract project.json from ZIP
  1669. try:
  1670. with zipfile.ZipFile(io.BytesIO(content)) as zf:
  1671. if "project.json" not in zf.namelist():
  1672. raise HTTPException(status_code=400, detail="ZIP must contain project.json")
  1673. project_json = zf.read("project.json")
  1674. data = json.loads(project_json)
  1675. # Get list of files in the ZIP
  1676. zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith("files/")}
  1677. except zipfile.BadZipFile:
  1678. raise HTTPException(status_code=400, detail="Invalid ZIP file")
  1679. elif filename_lower.endswith(".json"):
  1680. try:
  1681. data = json.loads(content)
  1682. zip_files = {}
  1683. except json.JSONDecodeError:
  1684. raise HTTPException(status_code=400, detail="Invalid JSON file")
  1685. else:
  1686. raise HTTPException(status_code=400, detail="File must be .zip or .json")
  1687. # Create the project
  1688. project = Project(
  1689. name=data.get("name", "Imported Project"),
  1690. description=data.get("description"),
  1691. color=data.get("color"),
  1692. status=data.get("status", "active"),
  1693. target_count=data.get("target_count"),
  1694. target_parts_count=data.get("target_parts_count"),
  1695. target_sets=data.get("target_sets"),
  1696. notes=data.get("notes"),
  1697. tags=data.get("tags"),
  1698. due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
  1699. priority=data.get("priority", 0),
  1700. budget=data.get("budget"),
  1701. )
  1702. db.add(project)
  1703. await db.flush()
  1704. # Create BOM items
  1705. for idx, bom_data in enumerate(data.get("bom_items", [])):
  1706. bom_item = ProjectBOMItem(
  1707. project_id=project.id,
  1708. name=bom_data.get("name", "Unnamed"),
  1709. quantity_needed=bom_data.get("quantity_needed", 1),
  1710. quantity_acquired=bom_data.get("quantity_acquired", 0),
  1711. unit_price=bom_data.get("unit_price"),
  1712. sourcing_url=bom_data.get("sourcing_url"),
  1713. stl_filename=bom_data.get("stl_filename"),
  1714. remarks=bom_data.get("remarks"),
  1715. sort_order=idx,
  1716. )
  1717. db.add(bom_item)
  1718. # Create linked folders and files
  1719. library_dir = get_library_dir()
  1720. for folder_data in data.get("linked_folders", []):
  1721. folder_name = folder_data.get("name")
  1722. if not folder_name:
  1723. continue
  1724. # Containment check on the folder name — refuses absolute paths and
  1725. # ``..`` traversal in ``project.json[linked_folders[*].name]``. The
  1726. # previous code did ``library_dir / folder_name`` directly, which
  1727. # collapses to ``Path(folder_name)`` when folder_name is absolute
  1728. # and lets ``..`` escape after mkdir.
  1729. folder_path = safe_join_under(library_dir, folder_name)
  1730. # Check if folder exists
  1731. existing_result = await db.execute(
  1732. select(LibraryFolder).where(
  1733. LibraryFolder.name == folder_name,
  1734. LibraryFolder.parent_id.is_(None),
  1735. )
  1736. )
  1737. existing_folder = existing_result.scalar_one_or_none()
  1738. if existing_folder:
  1739. # Link existing folder to project
  1740. existing_folder.project_id = project.id
  1741. folder = existing_folder
  1742. else:
  1743. # Create new folder
  1744. folder = LibraryFolder(
  1745. name=folder_name,
  1746. project_id=project.id,
  1747. is_external=False,
  1748. external_readonly=False,
  1749. external_show_hidden=False,
  1750. )
  1751. db.add(folder)
  1752. await db.flush()
  1753. # Create folder on disk
  1754. folder_path.mkdir(parents=True, exist_ok=True)
  1755. # Import files for this folder from ZIP
  1756. folder_prefix = f"files/{folder_name}/"
  1757. for zip_path, file_content in zip_files.items():
  1758. if not zip_path.startswith(folder_prefix):
  1759. continue
  1760. if "/.thumbnails/" in zip_path:
  1761. continue # Skip thumbnails, we'll regenerate them
  1762. relative_path = zip_path[len(folder_prefix) :]
  1763. if not relative_path:
  1764. continue
  1765. # Containment check on the per-entry relative path. ZIP names
  1766. # can carry ``..`` segments by spec; without resolve + parent
  1767. # containment, ``files/<folder>/../../../etc/x`` escapes
  1768. # ``library_dir`` entirely. ``relative_path`` is split into
  1769. # parts because ``safe_join_under`` rejects parts that start
  1770. # with ``/``, and a single combined string would hide an
  1771. # embedded ``..`` segment behind a forward slash.
  1772. file_disk_path = safe_join_under(
  1773. library_dir,
  1774. folder_name,
  1775. *Path(relative_path).parts,
  1776. )
  1777. file_disk_path.parent.mkdir(parents=True, exist_ok=True)
  1778. file_disk_path.write_bytes(file_content)
  1779. # Determine file type
  1780. ext = Path(relative_path).suffix.lower()
  1781. if ext in [".stl", ".3mf", ".obj"]:
  1782. file_type = "model"
  1783. elif ext in [".gcode"]:
  1784. file_type = "gcode"
  1785. elif ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
  1786. file_type = "image"
  1787. else:
  1788. file_type = "other"
  1789. # Create library file record
  1790. lib_file = LibraryFile(
  1791. folder_id=folder.id,
  1792. filename=relative_path,
  1793. file_path=f"{folder_name}/{relative_path}",
  1794. file_type=file_type,
  1795. file_size=len(file_content),
  1796. is_external=False,
  1797. )
  1798. db.add(lib_file)
  1799. await db.flush()
  1800. await db.refresh(project)
  1801. stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
  1802. return ProjectResponse(
  1803. id=project.id,
  1804. name=project.name,
  1805. description=project.description,
  1806. color=project.color,
  1807. status=project.status,
  1808. target_count=project.target_count,
  1809. target_parts_count=project.target_parts_count,
  1810. target_sets=project.target_sets,
  1811. notes=project.notes,
  1812. attachments=project.attachments,
  1813. url=project.url,
  1814. cover_image_filename=project.cover_image_filename,
  1815. tags=project.tags,
  1816. due_date=project.due_date,
  1817. priority=project.priority,
  1818. budget=project.budget,
  1819. is_template=project.is_template,
  1820. template_source_id=project.template_source_id,
  1821. parent_id=project.parent_id,
  1822. parent_name=None,
  1823. children=[],
  1824. created_at=project.created_at,
  1825. updated_at=project.updated_at,
  1826. stats=stats,
  1827. )