archives.py 67 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937
  1. from pathlib import Path
  2. import zipfile
  3. import io
  4. import logging
  5. from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, Query
  6. logger = logging.getLogger(__name__)
  7. from fastapi.responses import FileResponse, Response
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy import select, func
  10. from backend.app.core.config import settings
  11. from backend.app.core.database import get_db
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.filament import Filament
  14. from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats
  15. from backend.app.services.archive import ArchiveService
  16. router = APIRouter(prefix="/archives", tags=["archives"])
  17. def compute_time_accuracy(archive: PrintArchive) -> dict:
  18. """Compute actual print time and accuracy for an archive.
  19. Returns dict with actual_time_seconds and time_accuracy.
  20. time_accuracy = (estimated / actual) * 100
  21. - 100% = perfect estimate
  22. - >100% = print was faster than estimated
  23. - <100% = print took longer than estimated
  24. """
  25. result = {"actual_time_seconds": None, "time_accuracy": None}
  26. if archive.started_at and archive.completed_at and archive.status == "completed":
  27. actual_seconds = int((archive.completed_at - archive.started_at).total_seconds())
  28. if actual_seconds > 0:
  29. result["actual_time_seconds"] = actual_seconds
  30. if archive.print_time_seconds and archive.print_time_seconds > 0:
  31. # Calculate accuracy as percentage
  32. accuracy = (archive.print_time_seconds / actual_seconds) * 100
  33. result["time_accuracy"] = round(accuracy, 1)
  34. return result
  35. def archive_to_response(
  36. archive: PrintArchive,
  37. duplicates: list[dict] | None = None,
  38. duplicate_count: int = 0,
  39. ) -> dict:
  40. """Convert archive model to response dict with computed fields."""
  41. data = {
  42. "id": archive.id,
  43. "printer_id": archive.printer_id,
  44. "project_id": archive.project_id,
  45. "project_name": archive.project.name if archive.project else None,
  46. "filename": archive.filename,
  47. "file_path": archive.file_path,
  48. "file_size": archive.file_size,
  49. "content_hash": archive.content_hash,
  50. "thumbnail_path": archive.thumbnail_path,
  51. "timelapse_path": archive.timelapse_path,
  52. "source_3mf_path": archive.source_3mf_path,
  53. "duplicates": duplicates,
  54. "duplicate_count": duplicate_count if duplicates is None else len(duplicates),
  55. "print_name": archive.print_name,
  56. "print_time_seconds": archive.print_time_seconds,
  57. "filament_used_grams": archive.filament_used_grams,
  58. "filament_type": archive.filament_type,
  59. "filament_color": archive.filament_color,
  60. "layer_height": archive.layer_height,
  61. "total_layers": archive.total_layers,
  62. "nozzle_diameter": archive.nozzle_diameter,
  63. "bed_temperature": archive.bed_temperature,
  64. "nozzle_temperature": archive.nozzle_temperature,
  65. "status": archive.status,
  66. "started_at": archive.started_at,
  67. "completed_at": archive.completed_at,
  68. "extra_data": archive.extra_data,
  69. "makerworld_url": archive.makerworld_url,
  70. "designer": archive.designer,
  71. "is_favorite": archive.is_favorite,
  72. "tags": archive.tags,
  73. "notes": archive.notes,
  74. "cost": archive.cost,
  75. "photos": archive.photos,
  76. "failure_reason": archive.failure_reason,
  77. "created_at": archive.created_at,
  78. }
  79. # Add computed time accuracy fields
  80. accuracy_data = compute_time_accuracy(archive)
  81. data.update(accuracy_data)
  82. return data
  83. @router.get("/", response_model=list[ArchiveResponse])
  84. async def list_archives(
  85. printer_id: int | None = None,
  86. project_id: int | None = None,
  87. limit: int = 50,
  88. offset: int = 0,
  89. db: AsyncSession = Depends(get_db),
  90. ):
  91. """List archived prints."""
  92. service = ArchiveService(db)
  93. archives = await service.list_archives(
  94. printer_id=printer_id,
  95. project_id=project_id,
  96. limit=limit,
  97. offset=offset,
  98. )
  99. # Get set of hashes that have duplicates (efficient single query)
  100. duplicate_hashes = await service.get_duplicate_hashes()
  101. # Mark archives that have duplicates
  102. result = []
  103. for a in archives:
  104. has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False
  105. result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0))
  106. return result
  107. @router.get("/search", response_model=list[ArchiveResponse])
  108. async def search_archives(
  109. q: str = Query(..., min_length=2, description="Search query"),
  110. printer_id: int | None = None,
  111. project_id: int | None = None,
  112. status: str | None = None,
  113. limit: int = 50,
  114. offset: int = 0,
  115. db: AsyncSession = Depends(get_db),
  116. ):
  117. """Full-text search across archives.
  118. Searches print_name, filename, tags, notes, designer, and filament_type fields.
  119. Supports partial matches with wildcards (e.g., 'vor*' matches 'voron').
  120. """
  121. from sqlalchemy import text
  122. from sqlalchemy.orm import selectinload
  123. # Prepare search query - add wildcard for partial matches
  124. search_term = q.strip()
  125. if not search_term.endswith('*'):
  126. search_term = f"{search_term}*"
  127. # Build the FTS query
  128. # Using MATCH for FTS5 full-text search
  129. fts_query = text("""
  130. SELECT rowid FROM archive_fts
  131. WHERE archive_fts MATCH :search_term
  132. ORDER BY rank
  133. LIMIT :limit OFFSET :offset
  134. """)
  135. try:
  136. result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
  137. matched_ids = [row[0] for row in result.fetchall()]
  138. except Exception as e:
  139. logger.warning(f"FTS search failed, falling back to LIKE search: {e}")
  140. # Fallback to LIKE search if FTS fails
  141. like_pattern = f"%{q}%"
  142. query = (
  143. select(PrintArchive)
  144. .options(selectinload(PrintArchive.project))
  145. .where(
  146. (PrintArchive.print_name.ilike(like_pattern)) |
  147. (PrintArchive.filename.ilike(like_pattern)) |
  148. (PrintArchive.tags.ilike(like_pattern)) |
  149. (PrintArchive.notes.ilike(like_pattern)) |
  150. (PrintArchive.designer.ilike(like_pattern)) |
  151. (PrintArchive.filament_type.ilike(like_pattern))
  152. )
  153. .order_by(PrintArchive.created_at.desc())
  154. )
  155. if printer_id:
  156. query = query.where(PrintArchive.printer_id == printer_id)
  157. if project_id:
  158. query = query.where(PrintArchive.project_id == project_id)
  159. if status:
  160. query = query.where(PrintArchive.status == status)
  161. query = query.limit(limit).offset(offset)
  162. result = await db.execute(query)
  163. archives = result.scalars().all()
  164. return [archive_to_response(a) for a in archives]
  165. if not matched_ids:
  166. return []
  167. # Fetch full archive records for matched IDs
  168. query = (
  169. select(PrintArchive)
  170. .options(selectinload(PrintArchive.project))
  171. .where(PrintArchive.id.in_(matched_ids))
  172. )
  173. # Apply additional filters
  174. if printer_id:
  175. query = query.where(PrintArchive.printer_id == printer_id)
  176. if project_id:
  177. query = query.where(PrintArchive.project_id == project_id)
  178. if status:
  179. query = query.where(PrintArchive.status == status)
  180. result = await db.execute(query)
  181. archives_dict = {a.id: a for a in result.scalars().all()}
  182. # Preserve FTS ranking order and apply pagination
  183. ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
  184. paginated = ordered_archives[offset:offset + limit]
  185. return [archive_to_response(a) for a in paginated]
  186. @router.post("/search/rebuild-index")
  187. async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
  188. """Rebuild the full-text search index from existing archives.
  189. Use this if search results seem incomplete or incorrect.
  190. """
  191. from sqlalchemy import text
  192. try:
  193. # Clear and rebuild the FTS index
  194. await db.execute(text("DELETE FROM archive_fts"))
  195. # Repopulate from print_archives
  196. await db.execute(text("""
  197. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  198. SELECT id, print_name, filename, tags, notes, designer, filament_type
  199. FROM print_archives
  200. """))
  201. await db.commit()
  202. # Count entries
  203. result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
  204. count = result.scalar() or 0
  205. return {"message": f"Search index rebuilt with {count} entries"}
  206. except Exception as e:
  207. logger.error(f"Failed to rebuild search index: {e}")
  208. raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
  209. @router.get("/analysis/failures")
  210. async def analyze_failures(
  211. days: int = 30,
  212. printer_id: int | None = None,
  213. project_id: int | None = None,
  214. db: AsyncSession = Depends(get_db),
  215. ):
  216. """Analyze failure patterns across prints.
  217. Returns failure statistics including:
  218. - Overall failure rate
  219. - Failures by reason, filament type, printer
  220. - Time of day distribution
  221. - Recent failures
  222. - Weekly trend
  223. """
  224. from backend.app.services.failure_analysis import FailureAnalysisService
  225. service = FailureAnalysisService(db)
  226. return await service.analyze_failures(
  227. days=days,
  228. printer_id=printer_id,
  229. project_id=project_id,
  230. )
  231. @router.get("/compare")
  232. async def compare_archives(
  233. archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
  234. db: AsyncSession = Depends(get_db),
  235. ):
  236. """Compare multiple archives side by side.
  237. Compares print settings, filament usage, and print times.
  238. Also analyzes correlation between settings and success/failure.
  239. Args:
  240. archive_ids: Comma-separated list of 2-5 archive IDs to compare
  241. """
  242. from backend.app.services.archive_comparison import ArchiveComparisonService
  243. # Parse and validate archive IDs
  244. try:
  245. ids = [int(id.strip()) for id in archive_ids.split(",")]
  246. except ValueError:
  247. raise HTTPException(400, "Invalid archive IDs format")
  248. if len(ids) < 2:
  249. raise HTTPException(400, "At least 2 archives required for comparison")
  250. if len(ids) > 5:
  251. raise HTTPException(400, "Maximum 5 archives can be compared at once")
  252. service = ArchiveComparisonService(db)
  253. try:
  254. return await service.compare_archives(ids)
  255. except ValueError as e:
  256. raise HTTPException(400, str(e))
  257. @router.get("/export")
  258. async def export_archives(
  259. format: str = Query("csv", description="Export format: csv or xlsx"),
  260. fields: str | None = Query(None, description="Comma-separated field names"),
  261. printer_id: int | None = None,
  262. project_id: int | None = None,
  263. status: str | None = None,
  264. date_from: str | None = Query(None, description="Start date (ISO format)"),
  265. date_to: str | None = Query(None, description="End date (ISO format)"),
  266. search: str | None = None,
  267. db: AsyncSession = Depends(get_db),
  268. ):
  269. """Export archives to CSV or Excel format.
  270. Returns a downloadable file with archive data.
  271. """
  272. from datetime import datetime
  273. from fastapi.responses import StreamingResponse
  274. from backend.app.services.export import ExportService
  275. if format not in ("csv", "xlsx"):
  276. raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
  277. # Parse fields
  278. field_list = None
  279. if fields:
  280. field_list = [f.strip() for f in fields.split(",")]
  281. # Parse dates
  282. date_from_dt = None
  283. date_to_dt = None
  284. if date_from:
  285. try:
  286. date_from_dt = datetime.fromisoformat(date_from)
  287. except ValueError:
  288. raise HTTPException(400, "Invalid date_from format")
  289. if date_to:
  290. try:
  291. date_to_dt = datetime.fromisoformat(date_to)
  292. except ValueError:
  293. raise HTTPException(400, "Invalid date_to format")
  294. service = ExportService(db)
  295. try:
  296. file_bytes, filename, content_type = await service.export_archives(
  297. format=format,
  298. fields=field_list,
  299. printer_id=printer_id,
  300. project_id=project_id,
  301. status=status,
  302. date_from=date_from_dt,
  303. date_to=date_to_dt,
  304. search=search,
  305. )
  306. except ImportError as e:
  307. raise HTTPException(500, str(e))
  308. return StreamingResponse(
  309. io.BytesIO(file_bytes),
  310. media_type=content_type,
  311. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  312. )
  313. @router.get("/stats/export")
  314. async def export_stats(
  315. format: str = Query("csv", description="Export format: csv or xlsx"),
  316. days: int = 30,
  317. printer_id: int | None = None,
  318. project_id: int | None = None,
  319. db: AsyncSession = Depends(get_db),
  320. ):
  321. """Export statistics summary to CSV or Excel format."""
  322. from fastapi.responses import StreamingResponse
  323. from backend.app.services.export import ExportService
  324. if format not in ("csv", "xlsx"):
  325. raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
  326. service = ExportService(db)
  327. try:
  328. file_bytes, filename, content_type = await service.export_stats(
  329. format=format,
  330. days=days,
  331. printer_id=printer_id,
  332. project_id=project_id,
  333. )
  334. except ImportError as e:
  335. raise HTTPException(500, str(e))
  336. return StreamingResponse(
  337. io.BytesIO(file_bytes),
  338. media_type=content_type,
  339. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  340. )
  341. @router.get("/stats", response_model=ArchiveStats)
  342. async def get_archive_stats(db: AsyncSession = Depends(get_db)):
  343. """Get statistics across all archives."""
  344. # Total counts
  345. total_result = await db.execute(select(func.count(PrintArchive.id)))
  346. total_prints = total_result.scalar() or 0
  347. successful_result = await db.execute(
  348. select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")
  349. )
  350. successful_prints = successful_result.scalar() or 0
  351. failed_result = await db.execute(
  352. select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")
  353. )
  354. failed_prints = failed_result.scalar() or 0
  355. # Totals
  356. time_result = await db.execute(
  357. select(func.sum(PrintArchive.print_time_seconds))
  358. )
  359. total_time = (time_result.scalar() or 0) / 3600 # Convert to hours
  360. filament_result = await db.execute(
  361. select(func.sum(PrintArchive.filament_used_grams))
  362. )
  363. total_filament = filament_result.scalar() or 0
  364. cost_result = await db.execute(
  365. select(func.sum(PrintArchive.cost))
  366. )
  367. total_cost = cost_result.scalar() or 0
  368. # By filament type (split comma-separated values for multi-material prints)
  369. filament_type_result = await db.execute(
  370. select(PrintArchive.filament_type)
  371. .where(PrintArchive.filament_type.isnot(None))
  372. )
  373. prints_by_filament: dict[str, int] = {}
  374. for (filament_types,) in filament_type_result.all():
  375. # Split by comma and count each type
  376. for ftype in filament_types.split(","):
  377. ftype = ftype.strip()
  378. if ftype:
  379. prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1
  380. # By printer
  381. printer_result = await db.execute(
  382. select(PrintArchive.printer_id, func.count(PrintArchive.id))
  383. .group_by(PrintArchive.printer_id)
  384. )
  385. prints_by_printer = {str(k): v for k, v in printer_result.all()}
  386. # Time accuracy statistics
  387. # Get all completed archives with both estimated and actual times
  388. accuracy_result = await db.execute(
  389. select(PrintArchive)
  390. .where(PrintArchive.status == "completed")
  391. .where(PrintArchive.print_time_seconds.isnot(None))
  392. .where(PrintArchive.started_at.isnot(None))
  393. .where(PrintArchive.completed_at.isnot(None))
  394. )
  395. archives_with_times = list(accuracy_result.scalars().all())
  396. average_accuracy = None
  397. accuracy_by_printer: dict[str, float] = {}
  398. if archives_with_times:
  399. accuracies = []
  400. printer_accuracies: dict[str, list[float]] = {}
  401. for archive in archives_with_times:
  402. acc_data = compute_time_accuracy(archive)
  403. if acc_data["time_accuracy"] is not None:
  404. accuracies.append(acc_data["time_accuracy"])
  405. # Group by printer
  406. printer_key = str(archive.printer_id) if archive.printer_id else "unknown"
  407. if printer_key not in printer_accuracies:
  408. printer_accuracies[printer_key] = []
  409. printer_accuracies[printer_key].append(acc_data["time_accuracy"])
  410. if accuracies:
  411. average_accuracy = round(sum(accuracies) / len(accuracies), 1)
  412. # Calculate per-printer averages
  413. for printer_key, accs in printer_accuracies.items():
  414. accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1)
  415. # Energy totals - check which mode to use
  416. from backend.app.api.routes.settings import get_setting
  417. energy_tracking_mode = await get_setting(db, "energy_tracking_mode") or "total"
  418. energy_cost_per_kwh_str = await get_setting(db, "energy_cost_per_kwh")
  419. energy_cost_per_kwh = float(energy_cost_per_kwh_str) if energy_cost_per_kwh_str else 0.15
  420. if energy_tracking_mode == "total":
  421. # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
  422. from backend.app.models.smart_plug import SmartPlug
  423. from backend.app.services.tasmota import tasmota_service
  424. plugs_result = await db.execute(select(SmartPlug))
  425. plugs = list(plugs_result.scalars().all())
  426. total_energy_kwh = 0.0
  427. for plug in plugs:
  428. energy = await tasmota_service.get_energy(plug)
  429. if energy and energy.get("total") is not None:
  430. total_energy_kwh += energy["total"]
  431. total_energy_kwh = round(total_energy_kwh, 3)
  432. total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
  433. else:
  434. # Print mode: sum up per-print energy from archives
  435. energy_kwh_result = await db.execute(
  436. select(func.sum(PrintArchive.energy_kwh))
  437. )
  438. total_energy_kwh = energy_kwh_result.scalar() or 0
  439. energy_cost_result = await db.execute(
  440. select(func.sum(PrintArchive.energy_cost))
  441. )
  442. total_energy_cost = energy_cost_result.scalar() or 0
  443. return ArchiveStats(
  444. total_prints=total_prints,
  445. successful_prints=successful_prints,
  446. failed_prints=failed_prints,
  447. total_print_time_hours=round(total_time, 1),
  448. total_filament_grams=round(total_filament, 1),
  449. total_cost=round(total_cost, 2),
  450. prints_by_filament_type=prints_by_filament,
  451. prints_by_printer=prints_by_printer,
  452. average_time_accuracy=average_accuracy,
  453. time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
  454. total_energy_kwh=round(total_energy_kwh, 3),
  455. total_energy_cost=round(total_energy_cost, 2),
  456. )
  457. @router.get("/{archive_id}", response_model=ArchiveResponse)
  458. async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
  459. """Get a specific archive."""
  460. service = ArchiveService(db)
  461. archive = await service.get_archive(archive_id)
  462. if not archive:
  463. raise HTTPException(404, "Archive not found")
  464. # Find duplicates
  465. makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
  466. duplicates = await service.find_duplicates(
  467. archive_id=archive.id,
  468. content_hash=archive.content_hash,
  469. print_name=archive.print_name,
  470. makerworld_model_id=makerworld_id,
  471. )
  472. return archive_to_response(archive, duplicates)
  473. @router.get("/{archive_id}/similar")
  474. async def find_similar_archives(
  475. archive_id: int,
  476. limit: int = 10,
  477. db: AsyncSession = Depends(get_db),
  478. ):
  479. """Find archives with similar settings for comparison.
  480. Returns archives that match by:
  481. - Same print name (highest priority)
  482. - Same file content hash
  483. - Same filament type
  484. """
  485. from backend.app.services.archive_comparison import ArchiveComparisonService
  486. service = ArchiveComparisonService(db)
  487. try:
  488. return await service.find_similar_archives(archive_id, limit=limit)
  489. except ValueError as e:
  490. raise HTTPException(404, str(e))
  491. @router.patch("/{archive_id}", response_model=ArchiveResponse)
  492. async def update_archive(
  493. archive_id: int,
  494. update_data: ArchiveUpdate,
  495. db: AsyncSession = Depends(get_db),
  496. ):
  497. """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
  498. from sqlalchemy.orm import selectinload
  499. result = await db.execute(
  500. select(PrintArchive)
  501. .options(selectinload(PrintArchive.project))
  502. .where(PrintArchive.id == archive_id)
  503. )
  504. archive = result.scalar_one_or_none()
  505. if not archive:
  506. raise HTTPException(404, "Archive not found")
  507. for field, value in update_data.model_dump(exclude_unset=True).items():
  508. setattr(archive, field, value)
  509. await db.commit()
  510. # Re-fetch with project relationship loaded after commit
  511. result = await db.execute(
  512. select(PrintArchive)
  513. .options(selectinload(PrintArchive.project))
  514. .where(PrintArchive.id == archive_id)
  515. )
  516. archive = result.scalar_one_or_none()
  517. return archive_to_response(archive)
  518. @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
  519. async def toggle_favorite(
  520. archive_id: int,
  521. db: AsyncSession = Depends(get_db),
  522. ):
  523. """Toggle favorite status for an archive."""
  524. result = await db.execute(
  525. select(PrintArchive).where(PrintArchive.id == archive_id)
  526. )
  527. archive = result.scalar_one_or_none()
  528. if not archive:
  529. raise HTTPException(404, "Archive not found")
  530. archive.is_favorite = not archive.is_favorite
  531. await db.commit()
  532. await db.refresh(archive)
  533. return archive
  534. @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
  535. async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
  536. """Rescan the 3MF file and update metadata."""
  537. from backend.app.services.archive import ThreeMFParser
  538. result = await db.execute(
  539. select(PrintArchive).where(PrintArchive.id == archive_id)
  540. )
  541. archive = result.scalar_one_or_none()
  542. if not archive:
  543. raise HTTPException(404, "Archive not found")
  544. file_path = settings.base_dir / archive.file_path
  545. if not file_path.exists():
  546. raise HTTPException(404, "Archive file not found")
  547. # Parse the 3MF file
  548. parser = ThreeMFParser(file_path)
  549. metadata = parser.parse()
  550. # Update fields from metadata
  551. if metadata.get("filament_type"):
  552. archive.filament_type = metadata["filament_type"]
  553. if metadata.get("filament_color"):
  554. archive.filament_color = metadata["filament_color"]
  555. if metadata.get("print_time_seconds"):
  556. archive.print_time_seconds = metadata["print_time_seconds"]
  557. if metadata.get("filament_used_grams"):
  558. archive.filament_used_grams = metadata["filament_used_grams"]
  559. if metadata.get("layer_height"):
  560. archive.layer_height = metadata["layer_height"]
  561. if metadata.get("nozzle_diameter"):
  562. archive.nozzle_diameter = metadata["nozzle_diameter"]
  563. if metadata.get("bed_temperature"):
  564. archive.bed_temperature = metadata["bed_temperature"]
  565. if metadata.get("nozzle_temperature"):
  566. archive.nozzle_temperature = metadata["nozzle_temperature"]
  567. if metadata.get("makerworld_url"):
  568. archive.makerworld_url = metadata["makerworld_url"]
  569. if metadata.get("designer"):
  570. archive.designer = metadata["designer"]
  571. # Calculate cost based on filament usage and type
  572. if archive.filament_used_grams and archive.filament_type:
  573. primary_type = archive.filament_type.split(",")[0].strip()
  574. filament_result = await db.execute(
  575. select(Filament).where(Filament.type == primary_type).limit(1)
  576. )
  577. filament = filament_result.scalar_one_or_none()
  578. if filament:
  579. archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2)
  580. else:
  581. archive.cost = round((archive.filament_used_grams / 1000) * 25.0, 2)
  582. await db.commit()
  583. await db.refresh(archive)
  584. return archive
  585. @router.post("/recalculate-costs")
  586. async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
  587. """Recalculate costs for all archives based on filament usage and prices."""
  588. result = await db.execute(select(PrintArchive))
  589. archives = list(result.scalars().all())
  590. # Load all filaments for lookup
  591. filament_result = await db.execute(select(Filament))
  592. filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
  593. default_cost_per_kg = 25.0
  594. updated = 0
  595. for archive in archives:
  596. if archive.filament_used_grams and archive.filament_type:
  597. primary_type = archive.filament_type.split(",")[0].strip()
  598. cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
  599. new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2)
  600. if archive.cost != new_cost:
  601. archive.cost = new_cost
  602. updated += 1
  603. await db.commit()
  604. return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
  605. @router.post("/rescan-all")
  606. async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
  607. """Rescan all archives and update their metadata."""
  608. from backend.app.services.archive import ThreeMFParser
  609. result = await db.execute(select(PrintArchive))
  610. archives = list(result.scalars().all())
  611. updated = 0
  612. errors = []
  613. for archive in archives:
  614. try:
  615. file_path = settings.base_dir / archive.file_path
  616. if not file_path.exists():
  617. errors.append({"id": archive.id, "error": "File not found"})
  618. continue
  619. parser = ThreeMFParser(file_path)
  620. metadata = parser.parse()
  621. if metadata.get("filament_type"):
  622. archive.filament_type = metadata["filament_type"]
  623. if metadata.get("filament_color"):
  624. archive.filament_color = metadata["filament_color"]
  625. if metadata.get("print_time_seconds"):
  626. archive.print_time_seconds = metadata["print_time_seconds"]
  627. if metadata.get("filament_used_grams"):
  628. archive.filament_used_grams = metadata["filament_used_grams"]
  629. if metadata.get("layer_height"):
  630. archive.layer_height = metadata["layer_height"]
  631. if metadata.get("nozzle_diameter"):
  632. archive.nozzle_diameter = metadata["nozzle_diameter"]
  633. if metadata.get("makerworld_url"):
  634. archive.makerworld_url = metadata["makerworld_url"]
  635. if metadata.get("designer"):
  636. archive.designer = metadata["designer"]
  637. updated += 1
  638. except Exception as e:
  639. errors.append({"id": archive.id, "error": str(e)})
  640. await db.commit()
  641. return {"updated": updated, "errors": errors}
  642. @router.get("/{archive_id}/duplicates")
  643. async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
  644. """Get duplicates for a specific archive."""
  645. service = ArchiveService(db)
  646. archive = await service.get_archive(archive_id)
  647. if not archive:
  648. raise HTTPException(404, "Archive not found")
  649. makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
  650. duplicates = await service.find_duplicates(
  651. archive_id=archive.id,
  652. content_hash=archive.content_hash,
  653. print_name=archive.print_name,
  654. makerworld_model_id=makerworld_id,
  655. )
  656. return {"duplicates": duplicates, "count": len(duplicates)}
  657. @router.post("/backfill-hashes")
  658. async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
  659. """Compute and store content hashes for all archives missing them."""
  660. result = await db.execute(
  661. select(PrintArchive).where(PrintArchive.content_hash.is_(None))
  662. )
  663. archives = list(result.scalars().all())
  664. updated = 0
  665. errors = []
  666. for archive in archives:
  667. try:
  668. file_path = settings.base_dir / archive.file_path
  669. if not file_path.exists():
  670. errors.append({"id": archive.id, "error": "File not found"})
  671. continue
  672. archive.content_hash = ArchiveService.compute_file_hash(file_path)
  673. updated += 1
  674. except Exception as e:
  675. errors.append({"id": archive.id, "error": str(e)})
  676. await db.commit()
  677. return {"updated": updated, "errors": errors}
  678. @router.delete("/{archive_id}")
  679. async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
  680. """Delete an archive."""
  681. service = ArchiveService(db)
  682. if not await service.delete_archive(archive_id):
  683. raise HTTPException(404, "Archive not found")
  684. return {"status": "deleted"}
  685. @router.get("/{archive_id}/download")
  686. async def download_archive(
  687. archive_id: int,
  688. inline: bool = False,
  689. db: AsyncSession = Depends(get_db),
  690. ):
  691. """Download the 3MF file."""
  692. service = ArchiveService(db)
  693. archive = await service.get_archive(archive_id)
  694. if not archive:
  695. raise HTTPException(404, "Archive not found")
  696. file_path = settings.base_dir / archive.file_path
  697. if not file_path.exists():
  698. raise HTTPException(404, "File not found")
  699. # Use inline disposition to let browser/OS handle file association
  700. content_disposition = "inline" if inline else "attachment"
  701. return FileResponse(
  702. path=file_path,
  703. filename=archive.filename,
  704. media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  705. content_disposition_type=content_disposition,
  706. )
  707. @router.get("/{archive_id}/file/{filename}")
  708. async def download_archive_with_filename(
  709. archive_id: int,
  710. filename: str,
  711. db: AsyncSession = Depends(get_db),
  712. ):
  713. """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
  714. service = ArchiveService(db)
  715. archive = await service.get_archive(archive_id)
  716. if not archive:
  717. raise HTTPException(404, "Archive not found")
  718. file_path = settings.base_dir / archive.file_path
  719. if not file_path.exists():
  720. raise HTTPException(404, "File not found")
  721. return FileResponse(
  722. path=file_path,
  723. filename=archive.filename,
  724. media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  725. )
  726. @router.get("/{archive_id}/thumbnail")
  727. async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
  728. """Get the thumbnail image."""
  729. service = ArchiveService(db)
  730. archive = await service.get_archive(archive_id)
  731. if not archive or not archive.thumbnail_path:
  732. raise HTTPException(404, "Thumbnail not found")
  733. thumb_path = settings.base_dir / archive.thumbnail_path
  734. if not thumb_path.exists():
  735. raise HTTPException(404, "Thumbnail file not found")
  736. return FileResponse(path=thumb_path, media_type="image/png")
  737. @router.get("/{archive_id}/timelapse")
  738. async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
  739. """Get the timelapse video."""
  740. service = ArchiveService(db)
  741. archive = await service.get_archive(archive_id)
  742. if not archive or not archive.timelapse_path:
  743. raise HTTPException(404, "Timelapse not found")
  744. timelapse_path = settings.base_dir / archive.timelapse_path
  745. if not timelapse_path.exists():
  746. raise HTTPException(404, "Timelapse file not found")
  747. return FileResponse(
  748. path=timelapse_path,
  749. media_type="video/mp4",
  750. filename=f"{archive.print_name or 'timelapse'}.mp4",
  751. )
  752. @router.post("/{archive_id}/timelapse/scan")
  753. async def scan_timelapse(
  754. archive_id: int,
  755. db: AsyncSession = Depends(get_db),
  756. ):
  757. """Scan printer for timelapse matching this archive and attach it."""
  758. from backend.app.models.printer import Printer
  759. from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async
  760. service = ArchiveService(db)
  761. archive = await service.get_archive(archive_id)
  762. if not archive:
  763. raise HTTPException(404, "Archive not found")
  764. if archive.timelapse_path:
  765. return {"status": "exists", "message": "Timelapse already attached"}
  766. if not archive.printer_id:
  767. raise HTTPException(400, "Archive has no associated printer")
  768. # Get printer
  769. result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
  770. printer = result.scalar_one_or_none()
  771. if not printer:
  772. raise HTTPException(404, "Printer not found")
  773. # Get base name from archive filename (without .3mf extension)
  774. base_name = Path(archive.filename).stem
  775. # Scan timelapse directory on printer
  776. # Try both /timelapse and /timelapse/video (different printer models use different paths)
  777. files = []
  778. for timelapse_path in ["/timelapse", "/timelapse/video"]:
  779. try:
  780. files = await list_files_async(printer.ip_address, printer.access_code, timelapse_path)
  781. if files:
  782. break
  783. except Exception:
  784. continue
  785. if not files:
  786. raise HTTPException(500, "Failed to connect to printer or no timelapse directory found")
  787. # Look for matching timelapse
  788. matching_file = None
  789. mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")]
  790. # Strategy 1: Match by print name in filename
  791. for f in mp4_files:
  792. fname = f.get("name", "")
  793. if base_name.lower() in fname.lower():
  794. matching_file = f
  795. break
  796. # Strategy 2: Match by timestamp proximity
  797. # Bambu timelapse filename uses the print START time (when recording began)
  798. if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
  799. import re
  800. from datetime import datetime, timedelta
  801. # Prefer started_at since video filename is the print start time
  802. # Fall back to completed_at or created_at if started_at is not available
  803. archive_start = archive.started_at
  804. archive_end = archive.completed_at or archive.created_at
  805. best_match = None
  806. best_diff = timedelta(hours=24) # Max 24 hour difference
  807. for f in mp4_files:
  808. fname = f.get("name", "")
  809. # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4"
  810. match = re.search(r'(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})', fname)
  811. if match:
  812. try:
  813. file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S")
  814. # Try multiple timezone offsets since printer timezone can vary
  815. # Common cases: local time (0), CST/UTC+8 (+8), or UTC (-local offset)
  816. for hour_offset in [0, 8, -8, 7, -7, 1, -1]:
  817. adjusted_file_time = file_time - timedelta(hours=hour_offset)
  818. # Check against start time (video filename = print start)
  819. if archive_start:
  820. diff = abs(adjusted_file_time - archive_start)
  821. if diff < best_diff:
  822. best_diff = diff
  823. best_match = f
  824. logger.debug(
  825. f"Timelapse match candidate: {fname} with offset {hour_offset}h, "
  826. f"diff from start: {diff}"
  827. )
  828. # Also check against end time with a buffer
  829. # (video timestamp should be BEFORE completion time)
  830. if archive_end:
  831. # The video timestamp should be within the print duration before completion
  832. if adjusted_file_time < archive_end:
  833. diff = archive_end - adjusted_file_time
  834. # Reasonable print duration: up to 48 hours
  835. if diff < timedelta(hours=48) and diff < best_diff:
  836. best_diff = diff
  837. best_match = f
  838. logger.debug(
  839. f"Timelapse match candidate (from end): {fname} with offset {hour_offset}h, "
  840. f"diff: {diff}"
  841. )
  842. except ValueError:
  843. continue
  844. # Accept match within 4 hours (more lenient for timezone issues)
  845. if best_match and best_diff < timedelta(hours=4):
  846. matching_file = best_match
  847. logger.info(f"Matched timelapse by timestamp: {best_match.get('name')} (diff: {best_diff})")
  848. # Strategy 3: Use file modification time from FTP listing
  849. # This handles cases where printer's filename timestamp is wrong but file mtime is correct
  850. if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
  851. from datetime import datetime, timedelta
  852. archive_start = archive.started_at
  853. archive_end = archive.completed_at or archive.created_at
  854. best_match = None
  855. best_diff = timedelta(hours=24)
  856. for f in mp4_files:
  857. mtime = f.get("mtime")
  858. if mtime:
  859. # Timelapse file should be modified during or shortly after the print
  860. # The mtime should be close to completion time (video finishes when print ends)
  861. if archive_end:
  862. diff = abs(mtime - archive_end)
  863. if diff < best_diff:
  864. best_diff = diff
  865. best_match = f
  866. logger.debug(
  867. f"Timelapse mtime match candidate: {f.get('name')}, "
  868. f"mtime: {mtime}, diff from end: {diff}"
  869. )
  870. if best_match and best_diff < timedelta(hours=2):
  871. matching_file = best_match
  872. logger.info(f"Matched timelapse by file mtime: {best_match.get('name')} (diff: {best_diff})")
  873. # Strategy 4: If only one timelapse exists and archive was recently completed, use it
  874. # This handles cases where printer clock is wrong or timezone issues exist
  875. if not matching_file and len(mp4_files) == 1:
  876. from datetime import datetime, timedelta
  877. archive_completed = archive.completed_at or archive.created_at
  878. if archive_completed:
  879. time_since_completion = datetime.now() - archive_completed
  880. # If archive was completed within the last hour, assume the single timelapse is for it
  881. if time_since_completion < timedelta(hours=1):
  882. matching_file = mp4_files[0]
  883. logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
  884. # Note: We intentionally don't use a "most recent file" fallback because
  885. # we can't verify if timelapse was actually enabled for this print.
  886. # Instead, return the list of available files for manual selection.
  887. if not matching_file:
  888. # Return available files for manual selection
  889. available_files = [
  890. {
  891. "name": f.get("name"),
  892. "path": f.get("path"),
  893. "size": f.get("size"),
  894. "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
  895. }
  896. for f in mp4_files
  897. ]
  898. # Sort by mtime descending (most recent first)
  899. available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
  900. return {
  901. "status": "not_found",
  902. "message": "No matching timelapse found - please select manually",
  903. "available_files": available_files,
  904. }
  905. # Download the timelapse - use the full path from the file listing
  906. remote_path = matching_file.get('path') or f"/timelapse/{matching_file['name']}"
  907. timelapse_data = await download_file_bytes_async(
  908. printer.ip_address, printer.access_code, remote_path
  909. )
  910. if not timelapse_data:
  911. raise HTTPException(500, "Failed to download timelapse")
  912. # Attach timelapse to archive
  913. success = await service.attach_timelapse(
  914. archive_id, timelapse_data, matching_file["name"]
  915. )
  916. if not success:
  917. raise HTTPException(500, "Failed to attach timelapse")
  918. return {
  919. "status": "attached",
  920. "message": f"Timelapse '{matching_file['name']}' attached successfully",
  921. "filename": matching_file["name"],
  922. }
  923. @router.post("/{archive_id}/timelapse/select")
  924. async def select_timelapse(
  925. archive_id: int,
  926. filename: str = Query(..., description="Timelapse filename to attach"),
  927. db: AsyncSession = Depends(get_db),
  928. ):
  929. """Manually select a timelapse from the printer to attach."""
  930. from backend.app.models.printer import Printer
  931. from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async
  932. service = ArchiveService(db)
  933. archive = await service.get_archive(archive_id)
  934. if not archive:
  935. raise HTTPException(404, "Archive not found")
  936. if not archive.printer_id:
  937. raise HTTPException(400, "Archive has no associated printer")
  938. result = await db.execute(
  939. select(Printer).where(Printer.id == archive.printer_id)
  940. )
  941. printer = result.scalar_one_or_none()
  942. if not printer:
  943. raise HTTPException(404, "Printer not found")
  944. # Find the file on the printer
  945. files = []
  946. remote_path = None
  947. for timelapse_dir in ["/timelapse", "/timelapse/video"]:
  948. try:
  949. files = await list_files_async(printer.ip_address, printer.access_code, timelapse_dir)
  950. for f in files:
  951. if f.get("name") == filename:
  952. remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
  953. break
  954. if remote_path:
  955. break
  956. except Exception:
  957. continue
  958. if not remote_path:
  959. raise HTTPException(404, f"Timelapse '{filename}' not found on printer")
  960. # Download and attach
  961. timelapse_data = await download_file_bytes_async(
  962. printer.ip_address, printer.access_code, remote_path
  963. )
  964. if not timelapse_data:
  965. raise HTTPException(500, "Failed to download timelapse")
  966. success = await service.attach_timelapse(archive_id, timelapse_data, filename)
  967. if not success:
  968. raise HTTPException(500, "Failed to attach timelapse")
  969. return {
  970. "status": "attached",
  971. "message": f"Timelapse '{filename}' attached successfully",
  972. "filename": filename,
  973. }
  974. @router.post("/{archive_id}/timelapse/upload")
  975. async def upload_timelapse(
  976. archive_id: int,
  977. file: UploadFile = File(...),
  978. db: AsyncSession = Depends(get_db),
  979. ):
  980. """Manually upload a timelapse video to an archive."""
  981. service = ArchiveService(db)
  982. archive = await service.get_archive(archive_id)
  983. if not archive:
  984. raise HTTPException(404, "Archive not found")
  985. if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
  986. raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)")
  987. content = await file.read()
  988. success = await service.attach_timelapse(archive_id, content, file.filename)
  989. if not success:
  990. raise HTTPException(500, "Failed to attach timelapse")
  991. return {"status": "attached", "filename": file.filename}
  992. # ============================================
  993. # Photo Endpoints
  994. # ============================================
  995. @router.post("/{archive_id}/photos")
  996. async def upload_photo(
  997. archive_id: int,
  998. file: UploadFile = File(...),
  999. db: AsyncSession = Depends(get_db),
  1000. ):
  1001. """Upload a photo of the printed result."""
  1002. result = await db.execute(
  1003. select(PrintArchive).where(PrintArchive.id == archive_id)
  1004. )
  1005. archive = result.scalar_one_or_none()
  1006. if not archive:
  1007. raise HTTPException(404, "Archive not found")
  1008. if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
  1009. raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
  1010. # Get archive directory
  1011. file_path = settings.base_dir / archive.file_path
  1012. archive_dir = file_path.parent
  1013. photos_dir = archive_dir / "photos"
  1014. photos_dir.mkdir(exist_ok=True)
  1015. # Generate unique filename
  1016. import uuid
  1017. ext = Path(file.filename).suffix.lower()
  1018. photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
  1019. photo_path = photos_dir / photo_filename
  1020. # Save file
  1021. content = await file.read()
  1022. photo_path.write_bytes(content)
  1023. # Update archive photos list (create new list to trigger SQLAlchemy change detection)
  1024. photos = list(archive.photos or [])
  1025. photos.append(photo_filename)
  1026. archive.photos = photos
  1027. await db.commit()
  1028. await db.refresh(archive)
  1029. return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos}
  1030. @router.get("/{archive_id}/photos/{filename}")
  1031. async def get_photo(
  1032. archive_id: int,
  1033. filename: str,
  1034. db: AsyncSession = Depends(get_db),
  1035. ):
  1036. """Get a specific photo."""
  1037. result = await db.execute(
  1038. select(PrintArchive).where(PrintArchive.id == archive_id)
  1039. )
  1040. archive = result.scalar_one_or_none()
  1041. if not archive:
  1042. raise HTTPException(404, "Archive not found")
  1043. file_path = settings.base_dir / archive.file_path
  1044. photo_path = file_path.parent / "photos" / filename
  1045. if not photo_path.exists():
  1046. raise HTTPException(404, "Photo not found")
  1047. # Determine media type
  1048. ext = Path(filename).suffix.lower()
  1049. media_types = {
  1050. ".jpg": "image/jpeg",
  1051. ".jpeg": "image/jpeg",
  1052. ".png": "image/png",
  1053. ".webp": "image/webp",
  1054. }
  1055. media_type = media_types.get(ext, "image/jpeg")
  1056. return FileResponse(path=photo_path, media_type=media_type)
  1057. @router.delete("/{archive_id}/photos/{filename}")
  1058. async def delete_photo(
  1059. archive_id: int,
  1060. filename: str,
  1061. db: AsyncSession = Depends(get_db),
  1062. ):
  1063. """Delete a photo."""
  1064. result = await db.execute(
  1065. select(PrintArchive).where(PrintArchive.id == archive_id)
  1066. )
  1067. archive = result.scalar_one_or_none()
  1068. if not archive:
  1069. raise HTTPException(404, "Archive not found")
  1070. if not archive.photos or filename not in archive.photos:
  1071. raise HTTPException(404, "Photo not found")
  1072. # Delete file
  1073. file_path = settings.base_dir / archive.file_path
  1074. photo_path = file_path.parent / "photos" / filename
  1075. if photo_path.exists():
  1076. photo_path.unlink()
  1077. # Update archive photos list
  1078. photos = [p for p in archive.photos if p != filename]
  1079. archive.photos = photos if photos else None
  1080. await db.commit()
  1081. return {"status": "deleted", "photos": archive.photos}
  1082. # ============================================
  1083. # QR Code Endpoint
  1084. # ============================================
  1085. @router.get("/{archive_id}/qrcode")
  1086. async def get_qrcode(
  1087. archive_id: int,
  1088. request: Request,
  1089. size: int = 200,
  1090. db: AsyncSession = Depends(get_db),
  1091. ):
  1092. """Generate a QR code that links to this archive."""
  1093. import qrcode
  1094. from qrcode.image.styledpil import StyledPilImage
  1095. result = await db.execute(
  1096. select(PrintArchive).where(PrintArchive.id == archive_id)
  1097. )
  1098. archive = result.scalar_one_or_none()
  1099. if not archive:
  1100. raise HTTPException(404, "Archive not found")
  1101. # Build URL to archive detail page
  1102. base_url = str(request.base_url).rstrip('/')
  1103. archive_url = f"{base_url}/archives?id={archive_id}"
  1104. # Generate QR code
  1105. qr = qrcode.QRCode(
  1106. version=1,
  1107. error_correction=qrcode.constants.ERROR_CORRECT_M,
  1108. box_size=10,
  1109. border=2,
  1110. )
  1111. qr.add_data(archive_url)
  1112. qr.make(fit=True)
  1113. img = qr.make_image(fill_color="black", back_color="white")
  1114. # Resize if needed
  1115. if size != 200:
  1116. img = img.resize((size, size))
  1117. # Convert to bytes
  1118. buffer = io.BytesIO()
  1119. img.save(buffer, format="PNG")
  1120. buffer.seek(0)
  1121. return Response(
  1122. content=buffer.getvalue(),
  1123. media_type="image/png",
  1124. headers={
  1125. "Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"'
  1126. }
  1127. )
  1128. @router.get("/{archive_id}/capabilities")
  1129. async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
  1130. """Check what viewing capabilities are available for this 3MF file."""
  1131. import json
  1132. import re
  1133. service = ArchiveService(db)
  1134. archive = await service.get_archive(archive_id)
  1135. if not archive:
  1136. raise HTTPException(404, "Archive not found")
  1137. file_path = settings.base_dir / archive.file_path
  1138. if not file_path.exists():
  1139. raise HTTPException(404, "File not found")
  1140. has_model = False
  1141. has_gcode = False
  1142. build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size
  1143. try:
  1144. with zipfile.ZipFile(file_path, 'r') as zf:
  1145. names = zf.namelist()
  1146. # Check for G-code
  1147. has_gcode = any(n.startswith('Metadata/') and n.endswith('.gcode') for n in names)
  1148. # Check for 3D model - need to look for actual mesh data
  1149. for name in names:
  1150. if name.endswith('.model'):
  1151. try:
  1152. content = zf.read(name).decode('utf-8')
  1153. # Check if this model file contains actual mesh vertices
  1154. if '<vertex' in content or '<mesh' in content:
  1155. has_model = True
  1156. break
  1157. except Exception:
  1158. pass
  1159. # Extract build volume from project settings
  1160. if 'Metadata/project_settings.config' in names:
  1161. try:
  1162. config_content = zf.read('Metadata/project_settings.config').decode('utf-8')
  1163. config_data = json.loads(config_content)
  1164. # Parse printable_area: ['0x0', '256x0', '256x256', '0x256']
  1165. printable_area = config_data.get('printable_area', [])
  1166. if printable_area and len(printable_area) >= 3:
  1167. # Get max X and Y from the corner coordinates
  1168. max_x = 0
  1169. max_y = 0
  1170. for coord in printable_area:
  1171. if 'x' in coord:
  1172. parts = coord.split('x')
  1173. if len(parts) == 2:
  1174. try:
  1175. x, y = int(parts[0]), int(parts[1])
  1176. max_x = max(max_x, x)
  1177. max_y = max(max_y, y)
  1178. except ValueError:
  1179. pass
  1180. if max_x > 0 and max_y > 0:
  1181. build_volume["x"] = max_x
  1182. build_volume["y"] = max_y
  1183. # Parse printable_height
  1184. printable_height = config_data.get('printable_height')
  1185. if printable_height:
  1186. try:
  1187. build_volume["z"] = int(printable_height)
  1188. except (ValueError, TypeError):
  1189. pass
  1190. except Exception:
  1191. pass
  1192. except zipfile.BadZipFile:
  1193. raise HTTPException(400, "Invalid 3MF file")
  1194. return {
  1195. "has_model": has_model,
  1196. "has_gcode": has_gcode,
  1197. "build_volume": build_volume,
  1198. }
  1199. @router.get("/{archive_id}/gcode")
  1200. async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
  1201. """Extract and return G-code from the 3MF file."""
  1202. service = ArchiveService(db)
  1203. archive = await service.get_archive(archive_id)
  1204. if not archive:
  1205. raise HTTPException(404, "Archive not found")
  1206. file_path = settings.base_dir / archive.file_path
  1207. if not file_path.exists():
  1208. raise HTTPException(404, "File not found")
  1209. try:
  1210. with zipfile.ZipFile(file_path, 'r') as zf:
  1211. # Bambu 3MF files store G-code in Metadata/plate_X.gcode
  1212. gcode_files = [n for n in zf.namelist() if n.startswith('Metadata/') and n.endswith('.gcode')]
  1213. if not gcode_files:
  1214. raise HTTPException(
  1215. 404,
  1216. "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio."
  1217. )
  1218. # Get the first plate's G-code (usually plate_1.gcode)
  1219. gcode_content = zf.read(gcode_files[0]).decode('utf-8')
  1220. return Response(content=gcode_content, media_type="text/plain")
  1221. except zipfile.BadZipFile:
  1222. raise HTTPException(400, "Invalid 3MF file")
  1223. except HTTPException:
  1224. raise
  1225. except Exception as e:
  1226. raise HTTPException(500, f"Error extracting G-code: {str(e)}")
  1227. @router.post("/upload")
  1228. async def upload_archive(
  1229. file: UploadFile = File(...),
  1230. printer_id: int | None = None,
  1231. db: AsyncSession = Depends(get_db),
  1232. ):
  1233. """Manually upload a 3MF file to archive."""
  1234. if not file.filename or not file.filename.endswith(".3mf"):
  1235. raise HTTPException(400, "File must be a .3mf file")
  1236. # Save uploaded file temporarily
  1237. temp_path = settings.archive_dir / "temp" / file.filename
  1238. temp_path.parent.mkdir(parents=True, exist_ok=True)
  1239. try:
  1240. content = await file.read()
  1241. temp_path.write_bytes(content)
  1242. service = ArchiveService(db)
  1243. archive = await service.archive_print(
  1244. printer_id=printer_id,
  1245. source_file=temp_path,
  1246. )
  1247. if not archive:
  1248. raise HTTPException(400, "Failed to archive file")
  1249. return ArchiveResponse.model_validate(archive)
  1250. finally:
  1251. if temp_path.exists():
  1252. temp_path.unlink()
  1253. @router.post("/upload-bulk")
  1254. async def upload_archives_bulk(
  1255. files: list[UploadFile] = File(...),
  1256. printer_id: int | None = None,
  1257. db: AsyncSession = Depends(get_db),
  1258. ):
  1259. """Bulk upload multiple 3MF files to archive."""
  1260. results = []
  1261. errors = []
  1262. for file in files:
  1263. if not file.filename or not file.filename.endswith(".3mf"):
  1264. errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"})
  1265. continue
  1266. temp_path = settings.archive_dir / "temp" / file.filename
  1267. temp_path.parent.mkdir(parents=True, exist_ok=True)
  1268. try:
  1269. content = await file.read()
  1270. temp_path.write_bytes(content)
  1271. service = ArchiveService(db)
  1272. archive = await service.archive_print(
  1273. printer_id=printer_id,
  1274. source_file=temp_path,
  1275. )
  1276. if archive:
  1277. results.append({
  1278. "filename": file.filename,
  1279. "id": archive.id,
  1280. "status": "success",
  1281. })
  1282. else:
  1283. errors.append({"filename": file.filename, "error": "Failed to process"})
  1284. except Exception as e:
  1285. errors.append({"filename": file.filename, "error": str(e)})
  1286. finally:
  1287. if temp_path.exists():
  1288. temp_path.unlink()
  1289. return {
  1290. "uploaded": len(results),
  1291. "failed": len(errors),
  1292. "results": results,
  1293. "errors": errors,
  1294. }
  1295. @router.post("/{archive_id}/reprint")
  1296. async def reprint_archive(
  1297. archive_id: int,
  1298. printer_id: int,
  1299. db: AsyncSession = Depends(get_db),
  1300. ):
  1301. """Send an archived 3MF file to a printer and start printing."""
  1302. from backend.app.models.printer import Printer
  1303. from backend.app.services.bambu_ftp import upload_file_async
  1304. from backend.app.services.printer_manager import printer_manager
  1305. from backend.app.main import register_expected_print
  1306. # Get archive
  1307. service = ArchiveService(db)
  1308. archive = await service.get_archive(archive_id)
  1309. if not archive:
  1310. raise HTTPException(404, "Archive not found")
  1311. # Get printer
  1312. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  1313. printer = result.scalar_one_or_none()
  1314. if not printer:
  1315. raise HTTPException(404, "Printer not found")
  1316. # Check printer is connected
  1317. if not printer_manager.is_connected(printer_id):
  1318. raise HTTPException(400, "Printer is not connected")
  1319. # Get the 3MF file path
  1320. file_path = settings.base_dir / archive.file_path
  1321. if not file_path.exists():
  1322. raise HTTPException(404, "Archive file not found")
  1323. # Upload file to printer via FTP
  1324. remote_filename = archive.filename
  1325. remote_path = f"/cache/{remote_filename}"
  1326. uploaded = await upload_file_async(
  1327. printer.ip_address,
  1328. printer.access_code,
  1329. file_path,
  1330. remote_path,
  1331. )
  1332. if not uploaded:
  1333. raise HTTPException(500, "Failed to upload file to printer")
  1334. # Register this as an expected print so we don't create a duplicate archive
  1335. register_expected_print(printer_id, remote_filename, archive_id)
  1336. # Start the print
  1337. started = printer_manager.start_print(printer_id, remote_filename)
  1338. if not started:
  1339. raise HTTPException(500, "Failed to start print")
  1340. return {
  1341. "status": "printing",
  1342. "printer_id": printer_id,
  1343. "archive_id": archive_id,
  1344. "filename": archive.filename,
  1345. }
  1346. # =============================================================================
  1347. # Project Page API
  1348. # =============================================================================
  1349. @router.get("/{archive_id}/project-page")
  1350. async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
  1351. """Get the project page data from the 3MF file."""
  1352. from backend.app.services.archive import ProjectPageParser
  1353. from backend.app.schemas.archive import ProjectPageResponse
  1354. service = ArchiveService(db)
  1355. archive = await service.get_archive(archive_id)
  1356. if not archive:
  1357. raise HTTPException(404, "Archive not found")
  1358. file_path = settings.base_dir / archive.file_path
  1359. if not file_path.exists():
  1360. raise HTTPException(404, "Archive file not found")
  1361. parser = ProjectPageParser(file_path)
  1362. data = parser.parse(archive_id)
  1363. return ProjectPageResponse(**data)
  1364. @router.patch("/{archive_id}/project-page")
  1365. async def update_project_page(
  1366. archive_id: int,
  1367. update_data: dict,
  1368. db: AsyncSession = Depends(get_db),
  1369. ):
  1370. """Update project page metadata in the 3MF file."""
  1371. from backend.app.services.archive import ProjectPageParser
  1372. service = ArchiveService(db)
  1373. archive = await service.get_archive(archive_id)
  1374. if not archive:
  1375. raise HTTPException(404, "Archive not found")
  1376. file_path = settings.base_dir / archive.file_path
  1377. if not file_path.exists():
  1378. raise HTTPException(404, "Archive file not found")
  1379. parser = ProjectPageParser(file_path)
  1380. success = parser.update_metadata(update_data)
  1381. if not success:
  1382. raise HTTPException(500, "Failed to update project page")
  1383. # Return updated data
  1384. data = parser.parse(archive_id)
  1385. return data
  1386. @router.get("/{archive_id}/project-image/{image_path:path}")
  1387. async def get_project_image(
  1388. archive_id: int,
  1389. image_path: str,
  1390. db: AsyncSession = Depends(get_db),
  1391. ):
  1392. """Get an image from the 3MF project page."""
  1393. from backend.app.services.archive import ProjectPageParser
  1394. service = ArchiveService(db)
  1395. archive = await service.get_archive(archive_id)
  1396. if not archive:
  1397. raise HTTPException(404, "Archive not found")
  1398. file_path = settings.base_dir / archive.file_path
  1399. if not file_path.exists():
  1400. raise HTTPException(404, "Archive file not found")
  1401. parser = ProjectPageParser(file_path)
  1402. result = parser.get_image(image_path)
  1403. if not result:
  1404. raise HTTPException(404, "Image not found in 3MF file")
  1405. image_data, content_type = result
  1406. return Response(
  1407. content=image_data,
  1408. media_type=content_type,
  1409. headers={"Cache-Control": "max-age=3600"},
  1410. )
  1411. # =============================================================================
  1412. # Source 3MF API (Original Project Files)
  1413. # =============================================================================
  1414. @router.post("/{archive_id}/source")
  1415. async def upload_source_3mf(
  1416. archive_id: int,
  1417. file: UploadFile = File(...),
  1418. db: AsyncSession = Depends(get_db),
  1419. ):
  1420. """Upload the original source 3MF project file for an archive."""
  1421. result = await db.execute(
  1422. select(PrintArchive).where(PrintArchive.id == archive_id)
  1423. )
  1424. archive = result.scalar_one_or_none()
  1425. if not archive:
  1426. raise HTTPException(404, "Archive not found")
  1427. if not file.filename or not file.filename.endswith(".3mf"):
  1428. raise HTTPException(400, "File must be a .3mf file")
  1429. # Get archive directory and create source subdirectory
  1430. file_path = settings.base_dir / archive.file_path
  1431. archive_dir = file_path.parent
  1432. source_dir = archive_dir / "source"
  1433. source_dir.mkdir(exist_ok=True)
  1434. # Delete old source file if exists
  1435. if archive.source_3mf_path:
  1436. old_source_path = settings.base_dir / archive.source_3mf_path
  1437. if old_source_path.exists():
  1438. old_source_path.unlink()
  1439. # Save the source 3MF file - preserve original filename
  1440. source_filename = file.filename
  1441. source_path = source_dir / source_filename
  1442. content = await file.read()
  1443. source_path.write_bytes(content)
  1444. # Update archive with source path (relative to base_dir)
  1445. archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
  1446. await db.commit()
  1447. await db.refresh(archive)
  1448. return {
  1449. "status": "uploaded",
  1450. "source_3mf_path": archive.source_3mf_path,
  1451. "filename": source_filename,
  1452. }
  1453. @router.get("/{archive_id}/source")
  1454. async def download_source_3mf(
  1455. archive_id: int,
  1456. db: AsyncSession = Depends(get_db),
  1457. ):
  1458. """Download the source 3MF project file."""
  1459. result = await db.execute(
  1460. select(PrintArchive).where(PrintArchive.id == archive_id)
  1461. )
  1462. archive = result.scalar_one_or_none()
  1463. if not archive:
  1464. raise HTTPException(404, "Archive not found")
  1465. if not archive.source_3mf_path:
  1466. raise HTTPException(404, "No source 3MF attached to this archive")
  1467. source_path = settings.base_dir / archive.source_3mf_path
  1468. if not source_path.exists():
  1469. raise HTTPException(404, "Source 3MF file not found on disk")
  1470. # Use the actual filename from the path
  1471. filename = source_path.name
  1472. return FileResponse(
  1473. path=source_path,
  1474. filename=filename,
  1475. media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  1476. )
  1477. @router.get("/{archive_id}/source/{filename}")
  1478. async def download_source_3mf_for_slicer(
  1479. archive_id: int,
  1480. filename: str,
  1481. db: AsyncSession = Depends(get_db),
  1482. ):
  1483. """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
  1484. result = await db.execute(
  1485. select(PrintArchive).where(PrintArchive.id == archive_id)
  1486. )
  1487. archive = result.scalar_one_or_none()
  1488. if not archive:
  1489. raise HTTPException(404, "Archive not found")
  1490. if not archive.source_3mf_path:
  1491. raise HTTPException(404, "No source 3MF attached to this archive")
  1492. source_path = settings.base_dir / archive.source_3mf_path
  1493. if not source_path.exists():
  1494. raise HTTPException(404, "Source 3MF file not found on disk")
  1495. return FileResponse(
  1496. path=source_path,
  1497. filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
  1498. media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
  1499. )
  1500. @router.post("/upload-source")
  1501. async def upload_source_3mf_by_name(
  1502. file: UploadFile = File(...),
  1503. print_name: str = Query(None, description="Match archive by print name"),
  1504. db: AsyncSession = Depends(get_db),
  1505. ):
  1506. """Upload source 3MF and match to archive by print name.
  1507. This endpoint is designed for slicer post-processing scripts.
  1508. It finds the most recent archive matching the print name and attaches the source.
  1509. """
  1510. if not file.filename or not file.filename.endswith(".3mf"):
  1511. raise HTTPException(400, "File must be a .3mf file")
  1512. # Derive print name from filename if not provided
  1513. if not print_name:
  1514. # Remove .3mf extension and common suffixes
  1515. print_name = file.filename.rsplit('.3mf', 1)[0]
  1516. # Remove _source suffix if present
  1517. if print_name.endswith('_source'):
  1518. print_name = print_name[:-7]
  1519. # Find matching archive - try exact match first, then fuzzy
  1520. result = await db.execute(
  1521. select(PrintArchive)
  1522. .where(PrintArchive.print_name == print_name)
  1523. .order_by(PrintArchive.created_at.desc())
  1524. .limit(1)
  1525. )
  1526. archive = result.scalar_one_or_none()
  1527. if not archive:
  1528. # Try matching filename without .gcode.3mf
  1529. result = await db.execute(
  1530. select(PrintArchive)
  1531. .where(PrintArchive.filename.like(f"{print_name}%"))
  1532. .order_by(PrintArchive.created_at.desc())
  1533. .limit(1)
  1534. )
  1535. archive = result.scalar_one_or_none()
  1536. if not archive:
  1537. # Try case-insensitive partial match on print_name
  1538. result = await db.execute(
  1539. select(PrintArchive)
  1540. .where(PrintArchive.print_name.ilike(f"%{print_name}%"))
  1541. .order_by(PrintArchive.created_at.desc())
  1542. .limit(1)
  1543. )
  1544. archive = result.scalar_one_or_none()
  1545. if not archive:
  1546. raise HTTPException(404, f"No archive found matching '{print_name}'")
  1547. # Get archive directory and create source subdirectory
  1548. file_path = settings.base_dir / archive.file_path
  1549. archive_dir = file_path.parent
  1550. source_dir = archive_dir / "source"
  1551. source_dir.mkdir(exist_ok=True)
  1552. # Delete old source file if exists
  1553. if archive.source_3mf_path:
  1554. old_source_path = settings.base_dir / archive.source_3mf_path
  1555. if old_source_path.exists():
  1556. old_source_path.unlink()
  1557. # Save the source 3MF file - preserve original filename
  1558. source_filename = file.filename
  1559. source_path = source_dir / source_filename
  1560. content = await file.read()
  1561. source_path.write_bytes(content)
  1562. # Update archive with source path
  1563. archive.source_3mf_path = str(source_path.relative_to(settings.base_dir))
  1564. await db.commit()
  1565. await db.refresh(archive)
  1566. return {
  1567. "status": "uploaded",
  1568. "archive_id": archive.id,
  1569. "archive_name": archive.print_name or archive.filename,
  1570. "source_3mf_path": archive.source_3mf_path,
  1571. "filename": source_filename,
  1572. }
  1573. @router.delete("/{archive_id}/source")
  1574. async def delete_source_3mf(
  1575. archive_id: int,
  1576. db: AsyncSession = Depends(get_db),
  1577. ):
  1578. """Delete the source 3MF project file from an archive."""
  1579. result = await db.execute(
  1580. select(PrintArchive).where(PrintArchive.id == archive_id)
  1581. )
  1582. archive = result.scalar_one_or_none()
  1583. if not archive:
  1584. raise HTTPException(404, "Archive not found")
  1585. if not archive.source_3mf_path:
  1586. raise HTTPException(404, "No source 3MF attached to this archive")
  1587. # Delete the file
  1588. source_path = settings.base_dir / archive.source_3mf_path
  1589. if source_path.exists():
  1590. source_path.unlink()
  1591. # Clear the path in database
  1592. archive.source_3mf_path = None
  1593. await db.commit()
  1594. return {"status": "deleted"}