inventory.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970
  1. import json
  2. import logging
  3. import httpx
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from fastapi.responses import StreamingResponse
  6. from pydantic import BaseModel
  7. from sqlalchemy import func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  11. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  12. from backend.app.core.database import get_db
  13. from backend.app.core.permissions import Permission
  14. from backend.app.models.color_catalog import ColorCatalogEntry
  15. from backend.app.models.spool import Spool
  16. from backend.app.models.spool_assignment import SpoolAssignment
  17. from backend.app.models.spool_catalog import SpoolCatalogEntry
  18. from backend.app.models.spool_k_profile import SpoolKProfile
  19. from backend.app.models.user import User
  20. from backend.app.schemas.spool import (
  21. SpoolAssignmentCreate,
  22. SpoolAssignmentResponse,
  23. SpoolCreate,
  24. SpoolKProfileBase,
  25. SpoolKProfileResponse,
  26. SpoolResponse,
  27. SpoolUpdate,
  28. )
  29. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  30. logger = logging.getLogger(__name__)
  31. router = APIRouter(prefix="/inventory", tags=["inventory"])
  32. # Material temperature defaults (nozzle min/max)
  33. MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
  34. "PLA": (190, 230),
  35. "PETG": (220, 260),
  36. "ABS": (240, 270),
  37. "ASA": (240, 270),
  38. "TPU": (200, 240),
  39. "PA": (260, 290),
  40. "PC": (250, 280),
  41. "PVA": (190, 210),
  42. "PLA-CF": (210, 240),
  43. "PETG-CF": (240, 270),
  44. "PA-CF": (270, 300),
  45. }
  46. # FilamentColors.xyz API
  47. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  48. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  49. class CatalogEntryResponse(BaseModel):
  50. id: int
  51. name: str
  52. weight: int
  53. is_default: bool
  54. class Config:
  55. from_attributes = True
  56. class CatalogEntryCreate(BaseModel):
  57. name: str
  58. weight: int
  59. class CatalogEntryUpdate(BaseModel):
  60. name: str
  61. weight: int
  62. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  63. class ColorEntryResponse(BaseModel):
  64. id: int
  65. manufacturer: str
  66. color_name: str
  67. hex_color: str
  68. material: str | None
  69. is_default: bool
  70. class Config:
  71. from_attributes = True
  72. class ColorEntryCreate(BaseModel):
  73. manufacturer: str
  74. color_name: str
  75. hex_color: str
  76. material: str | None = None
  77. class ColorEntryUpdate(BaseModel):
  78. manufacturer: str
  79. color_name: str
  80. hex_color: str
  81. material: str | None = None
  82. class ColorLookupResult(BaseModel):
  83. found: bool
  84. hex_color: str | None = None
  85. material: str | None = None
  86. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  87. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  88. async def get_spool_catalog(
  89. db: AsyncSession = Depends(get_db),
  90. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  91. ):
  92. """Get all spool catalog entries."""
  93. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  94. return list(result.scalars().all())
  95. @router.post("/catalog", response_model=CatalogEntryResponse)
  96. async def add_catalog_entry(
  97. entry: CatalogEntryCreate,
  98. db: AsyncSession = Depends(get_db),
  99. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  100. ):
  101. """Add a new spool catalog entry."""
  102. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  103. db.add(row)
  104. await db.commit()
  105. await db.refresh(row)
  106. return row
  107. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  108. async def update_catalog_entry(
  109. entry_id: int,
  110. entry: CatalogEntryUpdate,
  111. db: AsyncSession = Depends(get_db),
  112. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  113. ):
  114. """Update a spool catalog entry."""
  115. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  116. row = result.scalar_one_or_none()
  117. if not row:
  118. raise HTTPException(404, "Entry not found")
  119. row.name = entry.name
  120. row.weight = entry.weight
  121. await db.commit()
  122. await db.refresh(row)
  123. return row
  124. @router.delete("/catalog/{entry_id}")
  125. async def delete_catalog_entry(
  126. entry_id: int,
  127. db: AsyncSession = Depends(get_db),
  128. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  129. ):
  130. """Delete a spool catalog entry."""
  131. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  132. row = result.scalar_one_or_none()
  133. if not row:
  134. raise HTTPException(404, "Entry not found")
  135. await db.delete(row)
  136. await db.commit()
  137. return {"status": "deleted"}
  138. @router.post("/catalog/reset")
  139. async def reset_spool_catalog(
  140. db: AsyncSession = Depends(get_db),
  141. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  142. ):
  143. """Reset spool catalog to defaults."""
  144. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  145. # Delete all
  146. result = await db.execute(select(SpoolCatalogEntry))
  147. for row in result.scalars().all():
  148. await db.delete(row)
  149. # Re-seed defaults
  150. for name, weight in DEFAULT_SPOOL_CATALOG:
  151. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  152. await db.commit()
  153. return {"status": "reset"}
  154. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  155. @router.get("/colors", response_model=list[ColorEntryResponse])
  156. async def get_color_catalog(
  157. db: AsyncSession = Depends(get_db),
  158. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  159. ):
  160. """Get all color catalog entries."""
  161. result = await db.execute(
  162. select(ColorCatalogEntry).order_by(
  163. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  164. )
  165. )
  166. return list(result.scalars().all())
  167. @router.post("/colors", response_model=ColorEntryResponse)
  168. async def add_color_entry(
  169. entry: ColorEntryCreate,
  170. db: AsyncSession = Depends(get_db),
  171. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  172. ):
  173. """Add a new color catalog entry."""
  174. row = ColorCatalogEntry(
  175. manufacturer=entry.manufacturer,
  176. color_name=entry.color_name,
  177. hex_color=entry.hex_color,
  178. material=entry.material,
  179. is_default=False,
  180. )
  181. db.add(row)
  182. await db.commit()
  183. await db.refresh(row)
  184. return row
  185. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  186. async def update_color_entry(
  187. entry_id: int,
  188. entry: ColorEntryUpdate,
  189. db: AsyncSession = Depends(get_db),
  190. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  191. ):
  192. """Update a color catalog entry."""
  193. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  194. row = result.scalar_one_or_none()
  195. if not row:
  196. raise HTTPException(404, "Entry not found")
  197. row.manufacturer = entry.manufacturer
  198. row.color_name = entry.color_name
  199. row.hex_color = entry.hex_color
  200. row.material = entry.material
  201. await db.commit()
  202. await db.refresh(row)
  203. return row
  204. @router.delete("/colors/{entry_id}")
  205. async def delete_color_entry(
  206. entry_id: int,
  207. db: AsyncSession = Depends(get_db),
  208. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  209. ):
  210. """Delete a color catalog entry."""
  211. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  212. row = result.scalar_one_or_none()
  213. if not row:
  214. raise HTTPException(404, "Entry not found")
  215. await db.delete(row)
  216. await db.commit()
  217. return {"status": "deleted"}
  218. @router.post("/colors/reset")
  219. async def reset_color_catalog(
  220. db: AsyncSession = Depends(get_db),
  221. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  222. ):
  223. """Reset color catalog to defaults."""
  224. result = await db.execute(select(ColorCatalogEntry))
  225. for row in result.scalars().all():
  226. await db.delete(row)
  227. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  228. db.add(
  229. ColorCatalogEntry(
  230. manufacturer=manufacturer,
  231. color_name=color_name,
  232. hex_color=hex_color,
  233. material=material,
  234. is_default=True,
  235. )
  236. )
  237. await db.commit()
  238. return {"status": "reset"}
  239. @router.get("/colors/lookup", response_model=ColorLookupResult)
  240. async def lookup_color(
  241. manufacturer: str,
  242. color_name: str,
  243. material: str | None = None,
  244. db: AsyncSession = Depends(get_db),
  245. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  246. ):
  247. """Look up a color by manufacturer and color name."""
  248. query = select(ColorCatalogEntry).where(
  249. ColorCatalogEntry.manufacturer == manufacturer,
  250. ColorCatalogEntry.color_name == color_name,
  251. )
  252. if material:
  253. query = query.where(ColorCatalogEntry.material == material)
  254. query = query.limit(1)
  255. result = await db.execute(query)
  256. row = result.scalar_one_or_none()
  257. if row:
  258. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  259. return ColorLookupResult(found=False)
  260. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  261. async def search_colors(
  262. manufacturer: str | None = None,
  263. material: str | None = None,
  264. db: AsyncSession = Depends(get_db),
  265. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  266. ):
  267. """Search colors by manufacturer and/or material."""
  268. query = select(ColorCatalogEntry)
  269. if manufacturer:
  270. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  271. if material:
  272. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  273. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  274. result = await db.execute(query)
  275. return list(result.scalars().all())
  276. @router.post("/colors/sync")
  277. async def sync_from_filamentcolors(
  278. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  279. ):
  280. """Sync colors from FilamentColors.xyz API with progress streaming."""
  281. async def generate():
  282. from backend.app.core.database import async_session
  283. added = 0
  284. skipped = 0
  285. total_fetched = 0
  286. total_available = 0
  287. try:
  288. async with httpx.AsyncClient(timeout=120.0) as client:
  289. page = 1
  290. while True:
  291. response = await client.get(
  292. f"{FILAMENT_COLORS_API}/swatch/",
  293. params={"page": page},
  294. )
  295. response.raise_for_status()
  296. data = response.json()
  297. total_available = data.get("count", total_available)
  298. results = data.get("results", [])
  299. if not results:
  300. break
  301. async with async_session() as db:
  302. for swatch in results:
  303. total_fetched += 1
  304. manufacturer_data = swatch.get("manufacturer")
  305. manufacturer_name = (
  306. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  307. )
  308. filament_type_data = swatch.get("filament_type")
  309. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  310. color_name_val = swatch.get("color_name", "")
  311. hex_color_val = swatch.get("hex_color", "")
  312. if not manufacturer_name or not color_name_val or not hex_color_val:
  313. skipped += 1
  314. continue
  315. if not hex_color_val.startswith("#"):
  316. hex_color_val = f"#{hex_color_val}"
  317. # Check if entry already exists
  318. existing = await db.execute(
  319. select(ColorCatalogEntry)
  320. .where(
  321. ColorCatalogEntry.manufacturer == manufacturer_name,
  322. ColorCatalogEntry.color_name == color_name_val,
  323. ColorCatalogEntry.material == mat,
  324. )
  325. .limit(1)
  326. )
  327. if existing.scalar_one_or_none():
  328. skipped += 1
  329. else:
  330. db.add(
  331. ColorCatalogEntry(
  332. manufacturer=manufacturer_name,
  333. color_name=color_name_val,
  334. hex_color=hex_color_val.upper(),
  335. material=mat,
  336. is_default=False,
  337. )
  338. )
  339. added += 1
  340. await db.commit()
  341. progress = {
  342. "type": "progress",
  343. "added": added,
  344. "skipped": skipped,
  345. "total_fetched": total_fetched,
  346. "total_available": total_available,
  347. }
  348. yield f"data: {json.dumps(progress)}\n\n"
  349. if not data.get("next") or total_fetched >= total_available:
  350. break
  351. page += 1
  352. result = {
  353. "type": "complete",
  354. "added": added,
  355. "skipped": skipped,
  356. "total_fetched": total_fetched,
  357. "total_available": total_available,
  358. }
  359. yield f"data: {json.dumps(result)}\n\n"
  360. except httpx.HTTPError as e:
  361. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  362. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  363. except Exception as e:
  364. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  365. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  366. return StreamingResponse(generate(), media_type="text/event-stream")
  367. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  368. @router.get("/spools", response_model=list[SpoolResponse])
  369. async def list_spools(
  370. include_archived: bool = False,
  371. db: AsyncSession = Depends(get_db),
  372. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  373. ):
  374. """List all spools, excluding archived by default."""
  375. query = select(Spool).options(selectinload(Spool.k_profiles))
  376. if not include_archived:
  377. query = query.where(Spool.archived_at.is_(None))
  378. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  379. result = await db.execute(query)
  380. return list(result.scalars().all())
  381. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  382. async def get_spool(
  383. spool_id: int,
  384. db: AsyncSession = Depends(get_db),
  385. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  386. ):
  387. """Get a single spool with k_profiles."""
  388. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  389. spool = result.scalar_one_or_none()
  390. if not spool:
  391. raise HTTPException(404, "Spool not found")
  392. return spool
  393. @router.post("/spools", response_model=SpoolResponse)
  394. async def create_spool(
  395. spool_data: SpoolCreate,
  396. db: AsyncSession = Depends(get_db),
  397. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  398. ):
  399. """Create a new spool."""
  400. spool = Spool(**spool_data.model_dump())
  401. db.add(spool)
  402. await db.commit()
  403. await db.refresh(spool)
  404. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  405. return result.scalar_one()
  406. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  407. async def update_spool(
  408. spool_id: int,
  409. spool_data: SpoolUpdate,
  410. db: AsyncSession = Depends(get_db),
  411. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  412. ):
  413. """Update a spool."""
  414. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  415. spool = result.scalar_one_or_none()
  416. if not spool:
  417. raise HTTPException(404, "Spool not found")
  418. for field, value in spool_data.model_dump(exclude_unset=True).items():
  419. setattr(spool, field, value)
  420. await db.commit()
  421. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  422. return result.scalar_one()
  423. @router.delete("/spools/{spool_id}")
  424. async def delete_spool(
  425. spool_id: int,
  426. db: AsyncSession = Depends(get_db),
  427. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  428. ):
  429. """Hard delete a spool."""
  430. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  431. spool = result.scalar_one_or_none()
  432. if not spool:
  433. raise HTTPException(404, "Spool not found")
  434. await db.delete(spool)
  435. await db.commit()
  436. return {"status": "deleted"}
  437. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  438. async def archive_spool(
  439. spool_id: int,
  440. db: AsyncSession = Depends(get_db),
  441. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  442. ):
  443. """Soft-delete a spool by setting archived_at."""
  444. from datetime import datetime, timezone
  445. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  446. spool = result.scalar_one_or_none()
  447. if not spool:
  448. raise HTTPException(404, "Spool not found")
  449. spool.archived_at = datetime.now(timezone.utc)
  450. await db.commit()
  451. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  452. return result.scalar_one()
  453. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  454. async def restore_spool(
  455. spool_id: int,
  456. db: AsyncSession = Depends(get_db),
  457. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  458. ):
  459. """Restore an archived spool."""
  460. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  461. spool = result.scalar_one_or_none()
  462. if not spool:
  463. raise HTTPException(404, "Spool not found")
  464. spool.archived_at = None
  465. await db.commit()
  466. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  467. return result.scalar_one()
  468. # ── K-Profiles ───────────────────────────────────────────────────────────────
  469. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  470. async def list_k_profiles(
  471. spool_id: int,
  472. db: AsyncSession = Depends(get_db),
  473. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  474. ):
  475. """List K-profiles for a spool."""
  476. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  477. return list(result.scalars().all())
  478. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  479. async def replace_k_profiles(
  480. spool_id: int,
  481. profiles: list[SpoolKProfileBase],
  482. db: AsyncSession = Depends(get_db),
  483. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  484. ):
  485. """Replace all K-profiles for a spool (batch save)."""
  486. # Verify spool exists
  487. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  488. if not result.scalar_one_or_none():
  489. raise HTTPException(404, "Spool not found")
  490. # Delete existing
  491. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  492. for old in existing.scalars().all():
  493. await db.delete(old)
  494. # Create new
  495. new_profiles = []
  496. for p in profiles:
  497. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  498. db.add(kp)
  499. new_profiles.append(kp)
  500. await db.commit()
  501. for kp in new_profiles:
  502. await db.refresh(kp)
  503. return new_profiles
  504. # ── Spool Assignments ────────────────────────────────────────────────────────
  505. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  506. async def list_assignments(
  507. printer_id: int | None = None,
  508. db: AsyncSession = Depends(get_db),
  509. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  510. ):
  511. """List spool assignments, optionally filtered by printer."""
  512. query = select(SpoolAssignment).options(
  513. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  514. selectinload(SpoolAssignment.printer),
  515. )
  516. if printer_id is not None:
  517. query = query.where(SpoolAssignment.printer_id == printer_id)
  518. result = await db.execute(query)
  519. return list(result.scalars().all())
  520. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  521. async def assign_spool(
  522. data: SpoolAssignmentCreate,
  523. db: AsyncSession = Depends(get_db),
  524. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  525. ):
  526. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  527. from backend.app.services.printer_manager import printer_manager
  528. # 1. Validate spool exists and is not archived
  529. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  530. spool = result.scalar_one_or_none()
  531. if not spool:
  532. raise HTTPException(404, "Spool not found")
  533. if spool.archived_at:
  534. raise HTTPException(400, "Cannot assign an archived spool")
  535. # 2. Get current AMS tray state for fingerprint
  536. fingerprint_color = None
  537. fingerprint_type = None
  538. state = printer_manager.get_status(data.printer_id)
  539. if state and state.raw_data:
  540. if data.ams_id == 255:
  541. # External slot: look up tray from vt_tray by global ID
  542. vt_tray = state.raw_data.get("vt_tray") or []
  543. ext_id = data.tray_id + 254 # 0→254, 1→255
  544. for vt in vt_tray:
  545. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  546. fingerprint_color = vt.get("tray_color", "")
  547. fingerprint_type = vt.get("tray_type", "")
  548. break
  549. else:
  550. ams_data = state.raw_data.get("ams", {})
  551. ams_list = (
  552. ams_data.get("ams", [])
  553. if isinstance(ams_data, dict)
  554. else ams_data
  555. if isinstance(ams_data, list)
  556. else []
  557. )
  558. tray = _find_tray_in_ams_data(
  559. ams_list,
  560. data.ams_id,
  561. data.tray_id,
  562. )
  563. if tray:
  564. fingerprint_color = tray.get("tray_color", "")
  565. fingerprint_type = tray.get("tray_type", "")
  566. # 3. Upsert assignment (replace if same printer+ams+tray)
  567. existing = await db.execute(
  568. select(SpoolAssignment).where(
  569. SpoolAssignment.printer_id == data.printer_id,
  570. SpoolAssignment.ams_id == data.ams_id,
  571. SpoolAssignment.tray_id == data.tray_id,
  572. )
  573. )
  574. old = existing.scalar_one_or_none()
  575. if old:
  576. await db.delete(old)
  577. await db.flush()
  578. assignment = SpoolAssignment(
  579. spool_id=data.spool_id,
  580. printer_id=data.printer_id,
  581. ams_id=data.ams_id,
  582. tray_id=data.tray_id,
  583. fingerprint_color=fingerprint_color,
  584. fingerprint_type=fingerprint_type,
  585. )
  586. db.add(assignment)
  587. await db.commit()
  588. await db.refresh(assignment)
  589. # 4. Auto-configure AMS slot via MQTT
  590. configured = False
  591. try:
  592. client = printer_manager.get_client(data.printer_id)
  593. if client:
  594. # Build filament setting from spool data
  595. tray_type = spool.material
  596. tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
  597. tray_color = spool.rgba or "FFFFFFFF"
  598. tray_info_idx = spool.slicer_filament or ""
  599. setting_id = ""
  600. # Temperature: use spool overrides if set, else material defaults
  601. temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
  602. if spool.nozzle_temp_min is not None:
  603. temp_min = spool.nozzle_temp_min
  604. if spool.nozzle_temp_max is not None:
  605. temp_max = spool.nozzle_temp_max
  606. # a. Set filament setting
  607. client.ams_set_filament_setting(
  608. ams_id=data.ams_id,
  609. tray_id=data.tray_id,
  610. tray_info_idx=tray_info_idx,
  611. tray_type=tray_type,
  612. tray_sub_brands=tray_sub_brands,
  613. tray_color=tray_color,
  614. nozzle_temp_min=temp_min,
  615. nozzle_temp_max=temp_max,
  616. setting_id=setting_id,
  617. )
  618. # b. Look up K-profile for this spool + printer + nozzle + extruder
  619. nozzle_diameter = "0.4"
  620. if state and state.nozzles:
  621. nd = state.nozzles[0].nozzle_diameter
  622. if nd:
  623. nozzle_diameter = nd
  624. # Determine slot's extruder from ams_extruder_map
  625. slot_extruder = None
  626. if state and state.ams_extruder_map:
  627. if data.ams_id == 255:
  628. # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  629. slot_extruder = 1 - data.tray_id # 0→1, 1→0
  630. else:
  631. slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
  632. matching_kp = None
  633. for kp in spool.k_profiles:
  634. if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
  635. if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
  636. continue
  637. matching_kp = kp
  638. break
  639. if matching_kp and matching_kp.cali_idx is not None:
  640. client.extrusion_cali_sel(
  641. ams_id=data.ams_id,
  642. tray_id=data.tray_id,
  643. cali_idx=matching_kp.cali_idx,
  644. filament_id=tray_info_idx,
  645. nozzle_diameter=nozzle_diameter,
  646. )
  647. configured = True
  648. logger.info(
  649. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  650. data.ams_id,
  651. data.tray_id,
  652. spool.id,
  653. data.printer_id,
  654. )
  655. except Exception as e:
  656. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  657. # Return assignment with spool data
  658. result = await db.execute(
  659. select(SpoolAssignment)
  660. .options(
  661. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  662. selectinload(SpoolAssignment.printer),
  663. )
  664. .where(SpoolAssignment.id == assignment.id)
  665. )
  666. resp = result.scalar_one()
  667. response = SpoolAssignmentResponse.model_validate(resp)
  668. response.configured = configured
  669. return response
  670. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  671. async def unassign_spool(
  672. printer_id: int,
  673. ams_id: int,
  674. tray_id: int,
  675. db: AsyncSession = Depends(get_db),
  676. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  677. ):
  678. """Unassign a spool from an AMS slot."""
  679. result = await db.execute(
  680. select(SpoolAssignment).where(
  681. SpoolAssignment.printer_id == printer_id,
  682. SpoolAssignment.ams_id == ams_id,
  683. SpoolAssignment.tray_id == tray_id,
  684. )
  685. )
  686. assignment = result.scalar_one_or_none()
  687. if not assignment:
  688. raise HTTPException(404, "Assignment not found")
  689. await db.delete(assignment)
  690. await db.commit()
  691. return {"status": "deleted"}
  692. # ── Tag Linking ───────────────────────────────────────────────────────────────
  693. class LinkTagRequest(BaseModel):
  694. tag_uid: str | None = None
  695. tray_uuid: str | None = None
  696. tag_type: str | None = None
  697. data_origin: str | None = "nfc_link"
  698. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  699. async def link_tag_to_spool(
  700. spool_id: int,
  701. data: LinkTagRequest,
  702. db: AsyncSession = Depends(get_db),
  703. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  704. ):
  705. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  706. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  707. spool = result.scalar_one_or_none()
  708. if not spool:
  709. raise HTTPException(404, "Spool not found")
  710. if spool.archived_at:
  711. raise HTTPException(400, "Cannot link tag to archived spool")
  712. # Check for conflicts: tag already linked to another active spool
  713. if data.tag_uid:
  714. conflict = await db.execute(
  715. select(Spool).where(
  716. Spool.tag_uid == data.tag_uid,
  717. Spool.id != spool_id,
  718. Spool.archived_at.is_(None),
  719. )
  720. )
  721. if conflict.scalar_one_or_none():
  722. raise HTTPException(409, "Tag UID already linked to another active spool")
  723. # Auto-clear from archived spools (tag recycling)
  724. archived_with_tag = await db.execute(
  725. select(Spool).where(
  726. Spool.tag_uid == data.tag_uid,
  727. Spool.id != spool_id,
  728. Spool.archived_at.is_not(None),
  729. )
  730. )
  731. for old_spool in archived_with_tag.scalars().all():
  732. old_spool.tag_uid = None
  733. if data.tray_uuid:
  734. conflict = await db.execute(
  735. select(Spool).where(
  736. Spool.tray_uuid == data.tray_uuid,
  737. Spool.id != spool_id,
  738. Spool.archived_at.is_(None),
  739. )
  740. )
  741. if conflict.scalar_one_or_none():
  742. raise HTTPException(409, "Tray UUID already linked to another active spool")
  743. archived_with_uuid = await db.execute(
  744. select(Spool).where(
  745. Spool.tray_uuid == data.tray_uuid,
  746. Spool.id != spool_id,
  747. Spool.archived_at.is_not(None),
  748. )
  749. )
  750. for old_spool in archived_with_uuid.scalars().all():
  751. old_spool.tray_uuid = None
  752. if data.tag_uid is not None:
  753. spool.tag_uid = data.tag_uid
  754. if data.tray_uuid is not None:
  755. spool.tray_uuid = data.tray_uuid
  756. if data.tag_type is not None:
  757. spool.tag_type = data.tag_type
  758. if data.data_origin is not None:
  759. spool.data_origin = data.data_origin
  760. await db.commit()
  761. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  762. return result.scalar_one()
  763. # ── Usage History ─────────────────────────────────────────────────────────────
  764. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  765. async def get_spool_usage_history(
  766. spool_id: int,
  767. limit: int = 50,
  768. db: AsyncSession = Depends(get_db),
  769. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  770. ):
  771. """Get usage history for a specific spool."""
  772. from backend.app.models.spool_usage_history import SpoolUsageHistory
  773. # Verify spool exists
  774. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  775. if not spool_result.scalar_one_or_none():
  776. raise HTTPException(404, "Spool not found")
  777. result = await db.execute(
  778. select(SpoolUsageHistory)
  779. .where(SpoolUsageHistory.spool_id == spool_id)
  780. .order_by(SpoolUsageHistory.created_at.desc())
  781. .limit(limit)
  782. )
  783. return list(result.scalars().all())
  784. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  785. async def get_all_usage_history(
  786. limit: int = 100,
  787. printer_id: int | None = None,
  788. db: AsyncSession = Depends(get_db),
  789. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  790. ):
  791. """Get global usage history, optionally filtered by printer."""
  792. from backend.app.models.spool_usage_history import SpoolUsageHistory
  793. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  794. if printer_id is not None:
  795. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  796. result = await db.execute(query)
  797. return list(result.scalars().all())
  798. @router.delete("/spools/{spool_id}/usage")
  799. async def clear_spool_usage_history(
  800. spool_id: int,
  801. db: AsyncSession = Depends(get_db),
  802. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  803. ):
  804. """Clear usage history for a spool."""
  805. from backend.app.models.spool_usage_history import SpoolUsageHistory
  806. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  807. for row in result.scalars().all():
  808. await db.delete(row)
  809. await db.commit()
  810. return {"status": "cleared"}
  811. # ── Helpers ──────────────────────────────────────────────────────────────────
  812. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  813. """Find a specific tray in the AMS data structure."""
  814. if not ams_data:
  815. return None
  816. for ams_unit in ams_data:
  817. if int(ams_unit.get("id", -1)) != ams_id:
  818. continue
  819. for tray in ams_unit.get("tray", []):
  820. if int(tray.get("id", -1)) == tray_id:
  821. return tray
  822. return None