projects.py 85 KB

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