inventory.py 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821
  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, Field, field_validator
  7. from sqlalchemy import delete, func, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.core.auth import (
  11. RequireAnyPermissionIfAuthEnabled,
  12. RequirePermissionIfAuthEnabled,
  13. require_auth_if_enabled,
  14. )
  15. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.websocket import ws_manager
  19. from backend.app.models.ams_label import AmsLabel
  20. from backend.app.models.color_catalog import ColorCatalogEntry
  21. from backend.app.models.spool import Spool
  22. from backend.app.models.spool_assignment import SpoolAssignment
  23. from backend.app.models.spool_catalog import SpoolCatalogEntry
  24. from backend.app.models.spool_k_profile import SpoolKProfile
  25. from backend.app.models.user import User
  26. from backend.app.schemas.spool import (
  27. SpoolAssignmentCreate,
  28. SpoolAssignmentResponse,
  29. SpoolBulkCreate,
  30. SpoolCreate,
  31. SpoolKProfileBase,
  32. SpoolKProfileResponse,
  33. SpoolResponse,
  34. SpoolUpdate,
  35. normalize_effect_type,
  36. normalize_extra_colors,
  37. )
  38. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  39. from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
  40. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  41. logger = logging.getLogger(__name__)
  42. router = APIRouter(prefix="/inventory", tags=["inventory"])
  43. # Material temperature defaults (nozzle min/max)
  44. MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
  45. "PLA": (190, 230),
  46. "PETG": (220, 260),
  47. "ABS": (240, 270),
  48. "ASA": (240, 270),
  49. "TPU": (200, 240),
  50. "PA": (260, 290),
  51. "PC": (250, 280),
  52. "PVA": (190, 210),
  53. "PLA-CF": (210, 240),
  54. "PETG-CF": (240, 270),
  55. "PA-CF": (270, 300),
  56. }
  57. # FilamentColors.xyz API
  58. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  59. # Generic Bambu filament IDs by material — fallback when no specific
  60. # preset is resolvable. Keep aligned with the inline table in
  61. # apply_spool_to_slot_via_mqtt below; both paths must produce the same
  62. # value for a given material.
  63. _GENERIC_FILAMENT_IDS: dict[str, str] = {
  64. "PLA": "GFL99",
  65. "PETG": "GFG99",
  66. "ABS": "GFB99",
  67. "ASA": "GFB98",
  68. "PC": "GFC99",
  69. "PA": "GFN99",
  70. "NYLON": "GFN99",
  71. "TPU": "GFU99",
  72. "PVA": "GFS99",
  73. "HIPS": "GFS98",
  74. "PLA-CF": "GFL98",
  75. "PETG-CF": "GFG98",
  76. "PA-CF": "GFN98",
  77. "PETG HF": "GFG96",
  78. }
  79. async def apply_spool_to_slot_via_mqtt(
  80. *,
  81. db: AsyncSession,
  82. current_user: User | None,
  83. spool: Spool,
  84. printer_id: int,
  85. ams_id: int,
  86. tray_id: int,
  87. current_tray_info_idx: str = "",
  88. current_tray_type: str = "",
  89. ) -> bool:
  90. """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
  91. Shared by `assign_spool` (initial assign for a loaded slot) and
  92. `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
  93. empty → loaded). Returns True when MQTT commands were published, False if
  94. no client was available or setup failed mid-way.
  95. `current_tray_info_idx` / `current_tray_type` describe the live tray state
  96. used as fallback hints when the spool's slicer_filament can't be resolved.
  97. Caller should not pass these for the empty-slot re-fire path (they'll be
  98. the freshly-loaded values, which is the intended fallback).
  99. """
  100. from backend.app.services.printer_manager import printer_manager
  101. client = printer_manager.get_client(printer_id)
  102. if client is None:
  103. return False
  104. state = printer_manager.get_status(printer_id)
  105. tray_type = spool.material
  106. tray_sub_brands = (
  107. f"{spool.brand} {spool.material} {spool.subtype}".strip()
  108. if spool.brand
  109. else f"{spool.material} {spool.subtype}"
  110. if spool.subtype
  111. else spool.material
  112. )
  113. tray_color = spool.rgba or "FFFFFFFF"
  114. _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
  115. tray_info_idx = ""
  116. setting_id = ""
  117. sf = spool.slicer_filament or ""
  118. if sf:
  119. base_sf = sf.split("_")[0] if "_" in sf else sf
  120. if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
  121. setting_id = base_sf
  122. try:
  123. from backend.app.api.routes.cloud import build_authenticated_cloud
  124. cloud = await build_authenticated_cloud(db, current_user)
  125. if cloud is not None and cloud.is_authenticated:
  126. try:
  127. detail = await cloud.get_setting_detail(base_sf)
  128. if detail.get("filament_id"):
  129. tray_info_idx = detail["filament_id"]
  130. cloud_name = detail.get("name", "")
  131. if cloud_name:
  132. tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  133. elif detail.get("base_id"):
  134. bid = detail["base_id"].split("_")[0]
  135. if bid.startswith("GFS") and len(bid) >= 5:
  136. tray_info_idx = f"GF{bid[3:]}"
  137. else:
  138. tray_info_idx = bid
  139. finally:
  140. await cloud.close()
  141. elif cloud is not None:
  142. await cloud.close()
  143. except Exception as e:
  144. logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
  145. if not tray_info_idx:
  146. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  147. elif base_sf.startswith("GF"):
  148. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  149. else:
  150. try:
  151. local_id = int(sf)
  152. from backend.app.models.local_preset import LocalPreset as LP
  153. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  154. lp = lp_result.scalar_one_or_none()
  155. if lp:
  156. mat = (spool.material or lp.filament_type or "").upper().strip()
  157. tray_info_idx = (
  158. _GENERIC_FILAMENT_IDS.get(mat)
  159. or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
  160. or ""
  161. )
  162. if lp.name:
  163. tray_sub_brands = lp.name.split("@")[0].strip()
  164. except (ValueError, TypeError):
  165. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  166. if tray_info_idx and spool.slicer_filament_name:
  167. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  168. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  169. if expected_name and expected_name != spool.slicer_filament_name:
  170. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  171. if fname == spool.slicer_filament_name:
  172. tray_info_idx = fid
  173. setting_id = filament_id_to_setting_id(fid)
  174. break
  175. if not tray_info_idx:
  176. if (
  177. current_tray_info_idx
  178. and current_tray_info_idx not in _generic_id_values
  179. and current_tray_type
  180. and current_tray_type.upper() == tray_type.upper()
  181. ):
  182. tray_info_idx = current_tray_info_idx
  183. elif tray_type:
  184. material = tray_type.upper().strip()
  185. generic = (
  186. _GENERIC_FILAMENT_IDS.get(material)
  187. or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  188. or ""
  189. )
  190. if generic:
  191. tray_info_idx = generic
  192. temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
  193. if spool.nozzle_temp_min is not None:
  194. temp_min = spool.nozzle_temp_min
  195. if spool.nozzle_temp_max is not None:
  196. temp_max = spool.nozzle_temp_max
  197. nozzle_diameter = "0.4"
  198. if state and state.nozzles:
  199. nd = state.nozzles[0].nozzle_diameter
  200. if nd:
  201. nozzle_diameter = nd
  202. slot_extruder = None
  203. if state and state.ams_extruder_map:
  204. if ams_id == 255:
  205. slot_extruder = 1 - tray_id # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
  206. else:
  207. slot_extruder = state.ams_extruder_map.get(str(ams_id))
  208. # Prefer exact extruder match, fall back to extruder-agnostic kp for the
  209. # same nozzle. Hard-skipping on mismatch silently drops valid stored
  210. # profiles when the AMS-extruder mapping has shifted.
  211. exact_kp = None
  212. fallback_kp = None
  213. for kp in spool.k_profiles:
  214. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
  215. continue
  216. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  217. exact_kp = kp
  218. break
  219. if fallback_kp is None:
  220. fallback_kp = kp
  221. matching_kp = exact_kp or fallback_kp
  222. # Resolve the printer-side calibration entry by looking up the cali_idx
  223. # in state.kprofiles. The printer keys its calibration table by
  224. # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
  225. # filament_id must match the kp's. PFUS-prefix cloud user presets are
  226. # rejected by the slicer in tray_info_idx; the printer-reported
  227. # filament_id is typically a P-prefix local preset which is valid.
  228. printer_kp = None
  229. if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
  230. for pkp in state.kprofiles:
  231. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  232. printer_kp = pkp
  233. break
  234. effective_tray_info_idx = tray_info_idx
  235. effective_setting_id = setting_id
  236. if printer_kp and printer_kp.filament_id:
  237. effective_tray_info_idx = printer_kp.filament_id
  238. target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
  239. matching_kp.setting_id if matching_kp else None
  240. )
  241. if target_setting_id:
  242. effective_setting_id = target_setting_id
  243. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  244. logger.info(
  245. "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
  246. tray_info_idx,
  247. effective_tray_info_idx,
  248. setting_id,
  249. effective_setting_id,
  250. "printer" if printer_kp else "stored",
  251. )
  252. client.ams_set_filament_setting(
  253. ams_id=ams_id,
  254. tray_id=tray_id,
  255. tray_info_idx=effective_tray_info_idx,
  256. tray_type=tray_type,
  257. tray_sub_brands=tray_sub_brands,
  258. tray_color=tray_color,
  259. nozzle_temp_min=temp_min,
  260. nozzle_temp_max=temp_max,
  261. setting_id=effective_setting_id,
  262. )
  263. if matching_kp and matching_kp.cali_idx is not None:
  264. # filament_id for cali_sel must match the preset under which the kp
  265. # was registered. Priority: live printer kp > stored kp.setting_id >
  266. # spool.slicer_filament > realigned tray_info_idx.
  267. if printer_kp and printer_kp.filament_id:
  268. cali_filament_id = printer_kp.filament_id
  269. elif matching_kp.setting_id:
  270. cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
  271. else:
  272. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  273. client.extrusion_cali_sel(
  274. ams_id=ams_id,
  275. tray_id=tray_id,
  276. cali_idx=matching_kp.cali_idx,
  277. filament_id=cali_filament_id,
  278. nozzle_diameter=nozzle_diameter,
  279. )
  280. # Persist slot preset mapping for UI display (preset_name on hover card).
  281. try:
  282. from backend.app.models.slot_preset import SlotPresetMapping
  283. preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
  284. preset_source = "cloud"
  285. if sf:
  286. base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
  287. try:
  288. int(base_sf_mapping)
  289. preset_id_to_save = f"local_{base_sf_mapping}"
  290. preset_source = "local"
  291. except (ValueError, TypeError):
  292. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
  293. else:
  294. preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
  295. if preset_id_to_save:
  296. existing_mapping = await db.execute(
  297. select(SlotPresetMapping).where(
  298. SlotPresetMapping.printer_id == printer_id,
  299. SlotPresetMapping.ams_id == ams_id,
  300. SlotPresetMapping.tray_id == tray_id,
  301. )
  302. )
  303. mapping = existing_mapping.scalar_one_or_none()
  304. if mapping:
  305. mapping.preset_id = preset_id_to_save
  306. mapping.preset_name = preset_name
  307. mapping.preset_source = preset_source
  308. else:
  309. mapping = SlotPresetMapping(
  310. printer_id=printer_id,
  311. ams_id=ams_id,
  312. tray_id=tray_id,
  313. preset_id=preset_id_to_save,
  314. preset_name=preset_name,
  315. preset_source=preset_source,
  316. )
  317. db.add(mapping)
  318. await db.commit()
  319. except Exception as e:
  320. logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
  321. logger.info(
  322. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  323. ams_id,
  324. tray_id,
  325. spool.id,
  326. printer_id,
  327. )
  328. return True
  329. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  330. class CatalogEntryResponse(BaseModel):
  331. id: int
  332. name: str
  333. weight: int
  334. is_default: bool
  335. class Config:
  336. from_attributes = True
  337. class CatalogEntryCreate(BaseModel):
  338. name: str
  339. weight: int
  340. class CatalogEntryUpdate(BaseModel):
  341. name: str
  342. weight: int
  343. class BulkDeleteIdsRequest(BaseModel):
  344. ids: list[int]
  345. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  346. class ColorEntryResponse(BaseModel):
  347. id: int
  348. manufacturer: str
  349. color_name: str
  350. hex_color: str
  351. material: str | None
  352. is_default: bool
  353. extra_colors: str | None = None
  354. effect_type: str | None = None
  355. class Config:
  356. from_attributes = True
  357. _HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
  358. class ColorEntryCreate(BaseModel):
  359. manufacturer: str
  360. color_name: str
  361. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  362. material: str | None = None
  363. extra_colors: str | None = None
  364. effect_type: str | None = None
  365. @field_validator("extra_colors")
  366. @classmethod
  367. def _validate_extra_colors(cls, v: str | None) -> str | None:
  368. return normalize_extra_colors(v)
  369. @field_validator("effect_type")
  370. @classmethod
  371. def _validate_effect_type(cls, v: str | None) -> str | None:
  372. return normalize_effect_type(v)
  373. class ColorEntryUpdate(BaseModel):
  374. manufacturer: str
  375. color_name: str
  376. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  377. material: str | None = None
  378. extra_colors: str | None = None
  379. effect_type: str | None = None
  380. @field_validator("extra_colors")
  381. @classmethod
  382. def _validate_extra_colors(cls, v: str | None) -> str | None:
  383. return normalize_extra_colors(v)
  384. @field_validator("effect_type")
  385. @classmethod
  386. def _validate_effect_type(cls, v: str | None) -> str | None:
  387. return normalize_effect_type(v)
  388. class ColorLookupResult(BaseModel):
  389. found: bool
  390. hex_color: str | None = None
  391. material: str | None = None
  392. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  393. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  394. async def get_spool_catalog(
  395. db: AsyncSession = Depends(get_db),
  396. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  397. ):
  398. """Get all spool catalog entries."""
  399. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  400. return list(result.scalars().all())
  401. @router.post("/catalog", response_model=CatalogEntryResponse)
  402. async def add_catalog_entry(
  403. entry: CatalogEntryCreate,
  404. db: AsyncSession = Depends(get_db),
  405. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  406. ):
  407. """Add a new spool catalog entry."""
  408. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  409. db.add(row)
  410. await db.commit()
  411. await db.refresh(row)
  412. return row
  413. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  414. async def update_catalog_entry(
  415. entry_id: int,
  416. entry: CatalogEntryUpdate,
  417. db: AsyncSession = Depends(get_db),
  418. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  419. ):
  420. """Update a spool catalog entry."""
  421. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  422. row = result.scalar_one_or_none()
  423. if not row:
  424. raise HTTPException(404, "Entry not found")
  425. row.name = entry.name
  426. row.weight = entry.weight
  427. await db.commit()
  428. await db.refresh(row)
  429. return row
  430. @router.delete("/catalog/{entry_id}")
  431. async def delete_catalog_entry(
  432. entry_id: int,
  433. db: AsyncSession = Depends(get_db),
  434. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  435. ):
  436. """Delete a spool catalog entry."""
  437. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  438. row = result.scalar_one_or_none()
  439. if not row:
  440. raise HTTPException(404, "Entry not found")
  441. await db.delete(row)
  442. await db.commit()
  443. return {"status": "deleted"}
  444. @router.post("/catalog/bulk-delete")
  445. async def bulk_delete_catalog_entries(
  446. data: BulkDeleteIdsRequest,
  447. db: AsyncSession = Depends(get_db),
  448. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  449. ):
  450. """Delete multiple spool catalog entries by ID."""
  451. if not data.ids:
  452. return {"deleted": 0}
  453. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  454. rows = result.scalars().all()
  455. for row in rows:
  456. await db.delete(row)
  457. await db.commit()
  458. return {"deleted": len(rows)}
  459. @router.post("/catalog/reset")
  460. async def reset_spool_catalog(
  461. db: AsyncSession = Depends(get_db),
  462. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  463. ):
  464. """Reset spool catalog to defaults."""
  465. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  466. # Delete all
  467. result = await db.execute(select(SpoolCatalogEntry))
  468. for row in result.scalars().all():
  469. await db.delete(row)
  470. # Re-seed defaults
  471. for name, weight in DEFAULT_SPOOL_CATALOG:
  472. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  473. await db.commit()
  474. return {"status": "reset"}
  475. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  476. @router.get("/colors", response_model=list[ColorEntryResponse])
  477. async def get_color_catalog(
  478. db: AsyncSession = Depends(get_db),
  479. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  480. ):
  481. """Get all color catalog entries."""
  482. result = await db.execute(
  483. select(ColorCatalogEntry).order_by(
  484. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  485. )
  486. )
  487. return list(result.scalars().all())
  488. @router.get("/colors/map")
  489. async def get_color_name_map(
  490. db: AsyncSession = Depends(get_db),
  491. _: User | None = Depends(require_auth_if_enabled),
  492. ):
  493. """Compact {hex: name} map for frontend color-name resolution.
  494. Not gated on INVENTORY_READ — every page that renders a spool color needs
  495. this, including read-only views available to users without inventory access.
  496. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  497. share the same hex (different materials or manufacturers), Bambu Lab wins,
  498. then default entries, then the first encountered.
  499. """
  500. result = await db.execute(
  501. select(
  502. ColorCatalogEntry.hex_color,
  503. ColorCatalogEntry.color_name,
  504. ColorCatalogEntry.manufacturer,
  505. ColorCatalogEntry.is_default,
  506. )
  507. )
  508. mapping: dict[str, tuple[str, int]] = {} # hex → (name, priority); higher priority wins
  509. for hex_color, color_name, manufacturer, is_default in result.all():
  510. if not hex_color or not color_name:
  511. continue
  512. key = hex_color.lstrip("#").lower()[:6]
  513. if len(key) != 6:
  514. continue
  515. priority = 0
  516. if manufacturer and manufacturer.strip().lower() == "bambu lab":
  517. priority += 2
  518. if is_default:
  519. priority += 1
  520. existing = mapping.get(key)
  521. if existing is None or priority > existing[1]:
  522. mapping[key] = (color_name, priority)
  523. return {"colors": {k: v[0] for k, v in mapping.items()}}
  524. @router.post("/colors", response_model=ColorEntryResponse)
  525. async def add_color_entry(
  526. entry: ColorEntryCreate,
  527. db: AsyncSession = Depends(get_db),
  528. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  529. ):
  530. """Add a new color catalog entry."""
  531. row = ColorCatalogEntry(
  532. manufacturer=entry.manufacturer,
  533. color_name=entry.color_name,
  534. hex_color=entry.hex_color,
  535. material=entry.material,
  536. is_default=False,
  537. extra_colors=entry.extra_colors,
  538. effect_type=entry.effect_type,
  539. )
  540. db.add(row)
  541. await db.commit()
  542. await db.refresh(row)
  543. return row
  544. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  545. async def update_color_entry(
  546. entry_id: int,
  547. entry: ColorEntryUpdate,
  548. db: AsyncSession = Depends(get_db),
  549. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  550. ):
  551. """Update a color catalog entry."""
  552. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  553. row = result.scalar_one_or_none()
  554. if not row:
  555. raise HTTPException(404, "Entry not found")
  556. row.manufacturer = entry.manufacturer
  557. row.color_name = entry.color_name
  558. row.hex_color = entry.hex_color
  559. row.material = entry.material
  560. row.extra_colors = entry.extra_colors
  561. row.effect_type = entry.effect_type
  562. await db.commit()
  563. await db.refresh(row)
  564. return row
  565. @router.delete("/colors/{entry_id}")
  566. async def delete_color_entry(
  567. entry_id: int,
  568. db: AsyncSession = Depends(get_db),
  569. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  570. ):
  571. """Delete a color catalog entry."""
  572. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  573. row = result.scalar_one_or_none()
  574. if not row:
  575. raise HTTPException(404, "Entry not found")
  576. await db.delete(row)
  577. await db.commit()
  578. return {"status": "deleted"}
  579. @router.post("/colors/bulk-delete")
  580. async def bulk_delete_color_entries(
  581. data: BulkDeleteIdsRequest,
  582. db: AsyncSession = Depends(get_db),
  583. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  584. ):
  585. """Delete multiple color catalog entries by ID."""
  586. if not data.ids:
  587. return {"deleted": 0}
  588. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  589. rows = result.scalars().all()
  590. for row in rows:
  591. await db.delete(row)
  592. await db.commit()
  593. return {"deleted": len(rows)}
  594. @router.post("/colors/reset")
  595. async def reset_color_catalog(
  596. db: AsyncSession = Depends(get_db),
  597. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  598. ):
  599. """Reset color catalog to defaults."""
  600. result = await db.execute(select(ColorCatalogEntry))
  601. for row in result.scalars().all():
  602. await db.delete(row)
  603. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  604. db.add(
  605. ColorCatalogEntry(
  606. manufacturer=manufacturer,
  607. color_name=color_name,
  608. hex_color=hex_color,
  609. material=material,
  610. is_default=True,
  611. )
  612. )
  613. await db.commit()
  614. return {"status": "reset"}
  615. @router.get("/colors/lookup", response_model=ColorLookupResult)
  616. async def lookup_color(
  617. manufacturer: str,
  618. color_name: str,
  619. material: str | None = None,
  620. db: AsyncSession = Depends(get_db),
  621. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  622. ):
  623. """Look up a color by manufacturer and color name."""
  624. query = select(ColorCatalogEntry).where(
  625. ColorCatalogEntry.manufacturer == manufacturer,
  626. ColorCatalogEntry.color_name == color_name,
  627. )
  628. if material:
  629. query = query.where(ColorCatalogEntry.material == material)
  630. query = query.limit(1)
  631. result = await db.execute(query)
  632. row = result.scalar_one_or_none()
  633. if row:
  634. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  635. return ColorLookupResult(found=False)
  636. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  637. async def search_colors(
  638. manufacturer: str | None = None,
  639. material: str | None = None,
  640. db: AsyncSession = Depends(get_db),
  641. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  642. ):
  643. """Search colors by manufacturer and/or material."""
  644. query = select(ColorCatalogEntry)
  645. if manufacturer:
  646. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  647. if material:
  648. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  649. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  650. result = await db.execute(query)
  651. return list(result.scalars().all())
  652. @router.post("/colors/sync")
  653. async def sync_from_filamentcolors(
  654. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  655. ):
  656. """Sync colors from FilamentColors.xyz API with progress streaming."""
  657. async def generate():
  658. from backend.app.core.database import async_session
  659. added = 0
  660. skipped = 0
  661. total_fetched = 0
  662. total_available = 0
  663. try:
  664. async with httpx.AsyncClient(timeout=120.0) as client:
  665. page = 1
  666. while True:
  667. response = await client.get(
  668. f"{FILAMENT_COLORS_API}/swatch/",
  669. params={"page": page},
  670. )
  671. response.raise_for_status()
  672. data = response.json()
  673. total_available = data.get("count", total_available)
  674. results = data.get("results", [])
  675. if not results:
  676. break
  677. async with async_session() as db:
  678. for swatch in results:
  679. total_fetched += 1
  680. manufacturer_data = swatch.get("manufacturer")
  681. manufacturer_name = (
  682. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  683. )
  684. filament_type_data = swatch.get("filament_type")
  685. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  686. color_name_val = swatch.get("color_name", "")
  687. hex_color_val = swatch.get("hex_color", "")
  688. if not manufacturer_name or not color_name_val or not hex_color_val:
  689. skipped += 1
  690. continue
  691. if not hex_color_val.startswith("#"):
  692. hex_color_val = f"#{hex_color_val}"
  693. # Check if entry already exists
  694. existing = await db.execute(
  695. select(ColorCatalogEntry)
  696. .where(
  697. ColorCatalogEntry.manufacturer == manufacturer_name,
  698. ColorCatalogEntry.color_name == color_name_val,
  699. ColorCatalogEntry.material == mat,
  700. )
  701. .limit(1)
  702. )
  703. if existing.scalar_one_or_none():
  704. skipped += 1
  705. else:
  706. db.add(
  707. ColorCatalogEntry(
  708. manufacturer=manufacturer_name,
  709. color_name=color_name_val,
  710. hex_color=hex_color_val.upper(),
  711. material=mat,
  712. is_default=False,
  713. )
  714. )
  715. added += 1
  716. await db.commit()
  717. progress = {
  718. "type": "progress",
  719. "added": added,
  720. "skipped": skipped,
  721. "total_fetched": total_fetched,
  722. "total_available": total_available,
  723. }
  724. yield f"data: {json.dumps(progress)}\n\n"
  725. if not data.get("next") or total_fetched >= total_available:
  726. break
  727. page += 1
  728. result = {
  729. "type": "complete",
  730. "added": added,
  731. "skipped": skipped,
  732. "total_fetched": total_fetched,
  733. "total_available": total_available,
  734. }
  735. yield f"data: {json.dumps(result)}\n\n"
  736. except httpx.HTTPError as e:
  737. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  738. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  739. except Exception as e:
  740. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  741. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  742. return StreamingResponse(generate(), media_type="text/event-stream")
  743. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  744. @router.get("/spools", response_model=list[SpoolResponse])
  745. async def list_spools(
  746. include_archived: bool = False,
  747. db: AsyncSession = Depends(get_db),
  748. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  749. ):
  750. """List all spools, excluding archived by default."""
  751. query = select(Spool).options(selectinload(Spool.k_profiles))
  752. if not include_archived:
  753. query = query.where(Spool.archived_at.is_(None))
  754. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  755. result = await db.execute(query)
  756. return list(result.scalars().all())
  757. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  758. async def get_spool(
  759. spool_id: int,
  760. db: AsyncSession = Depends(get_db),
  761. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  762. ):
  763. """Get a single spool with k_profiles."""
  764. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  765. spool = result.scalar_one_or_none()
  766. if not spool:
  767. raise HTTPException(404, "Spool not found")
  768. return spool
  769. @router.post("/spools", response_model=SpoolResponse)
  770. async def create_spool(
  771. spool_data: SpoolCreate,
  772. db: AsyncSession = Depends(get_db),
  773. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  774. ):
  775. """Create a new spool."""
  776. spool = Spool(**spool_data.model_dump())
  777. db.add(spool)
  778. await db.commit()
  779. await db.refresh(spool)
  780. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  781. await ws_manager.broadcast({"type": "inventory_changed"})
  782. return result.scalar_one()
  783. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  784. async def bulk_create_spools(
  785. data: SpoolBulkCreate,
  786. db: AsyncSession = Depends(get_db),
  787. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  788. ):
  789. """Create multiple identical spools."""
  790. spools = []
  791. for _ in range(data.quantity):
  792. spool = Spool(**data.spool.model_dump())
  793. db.add(spool)
  794. spools.append(spool)
  795. await db.commit()
  796. ids = [s.id for s in spools]
  797. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
  798. await ws_manager.broadcast({"type": "inventory_changed"})
  799. return list(result.scalars().all())
  800. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  801. async def update_spool(
  802. spool_id: int,
  803. spool_data: SpoolUpdate,
  804. db: AsyncSession = Depends(get_db),
  805. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  806. ):
  807. """Update a spool."""
  808. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  809. spool = result.scalar_one_or_none()
  810. if not spool:
  811. raise HTTPException(404, "Spool not found")
  812. update_data = spool_data.model_dump(exclude_unset=True)
  813. # Auto-lock weight when user explicitly sets weight_used
  814. if "weight_used" in update_data and "weight_locked" not in update_data:
  815. update_data["weight_locked"] = True
  816. for field, value in update_data.items():
  817. setattr(spool, field, value)
  818. await db.commit()
  819. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  820. await ws_manager.broadcast({"type": "inventory_changed"})
  821. return result.scalar_one()
  822. @router.delete("/spools/{spool_id}")
  823. async def delete_spool(
  824. spool_id: int,
  825. db: AsyncSession = Depends(get_db),
  826. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  827. ):
  828. """Hard delete a spool."""
  829. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  830. spool = result.scalar_one_or_none()
  831. if not spool:
  832. raise HTTPException(404, "Spool not found")
  833. await db.delete(spool)
  834. await db.commit()
  835. await ws_manager.broadcast({"type": "inventory_changed"})
  836. return {"status": "deleted"}
  837. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  838. async def archive_spool(
  839. spool_id: int,
  840. db: AsyncSession = Depends(get_db),
  841. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  842. ):
  843. """Soft-delete a spool by setting archived_at."""
  844. from datetime import datetime, timezone
  845. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  846. spool = result.scalar_one_or_none()
  847. if not spool:
  848. raise HTTPException(404, "Spool not found")
  849. spool.archived_at = datetime.now(timezone.utc)
  850. await db.commit()
  851. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  852. await ws_manager.broadcast({"type": "inventory_changed"})
  853. return result.scalar_one()
  854. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  855. async def restore_spool(
  856. spool_id: int,
  857. db: AsyncSession = Depends(get_db),
  858. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  859. ):
  860. """Restore an archived spool."""
  861. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  862. spool = result.scalar_one_or_none()
  863. if not spool:
  864. raise HTTPException(404, "Spool not found")
  865. spool.archived_at = None
  866. await db.commit()
  867. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  868. await ws_manager.broadcast({"type": "inventory_changed"})
  869. return result.scalar_one()
  870. # ── K-Profiles ───────────────────────────────────────────────────────────────
  871. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  872. async def list_k_profiles(
  873. spool_id: int,
  874. db: AsyncSession = Depends(get_db),
  875. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  876. ):
  877. """List K-profiles for a spool."""
  878. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  879. return list(result.scalars().all())
  880. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  881. async def replace_k_profiles(
  882. spool_id: int,
  883. profiles: list[SpoolKProfileBase],
  884. db: AsyncSession = Depends(get_db),
  885. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  886. ):
  887. """Replace all K-profiles for a spool (batch save)."""
  888. # Verify spool exists
  889. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  890. if not result.scalar_one_or_none():
  891. raise HTTPException(404, "Spool not found")
  892. # Delete existing
  893. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  894. for old in existing.scalars().all():
  895. await db.delete(old)
  896. # Create new
  897. new_profiles = []
  898. for p in profiles:
  899. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  900. db.add(kp)
  901. new_profiles.append(kp)
  902. await db.commit()
  903. for kp in new_profiles:
  904. await db.refresh(kp)
  905. return new_profiles
  906. # ── Spool Assignments ────────────────────────────────────────────────────────
  907. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  908. async def list_assignments(
  909. printer_id: int | None = None,
  910. db: AsyncSession = Depends(get_db),
  911. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  912. ):
  913. """List spool assignments, optionally filtered by printer."""
  914. from backend.app.services.printer_manager import printer_manager
  915. query = select(SpoolAssignment).options(
  916. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  917. selectinload(SpoolAssignment.printer),
  918. )
  919. if printer_id is not None:
  920. query = query.where(SpoolAssignment.printer_id == printer_id)
  921. result = await db.execute(query)
  922. assignments = list(result.scalars().all())
  923. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  924. # Fetch all statuses in one call rather than one get_status() call per printer.
  925. serial_map: dict[tuple[int, int], str] = {}
  926. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  927. all_statuses = printer_manager.get_all_statuses()
  928. for pid in seen_printer_ids:
  929. state = all_statuses.get(pid)
  930. if state and state.raw_data:
  931. for ams_unit in state.raw_data.get("ams", []):
  932. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  933. if sn:
  934. try:
  935. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  936. except (ValueError, TypeError):
  937. continue
  938. # Fetch all relevant AMS labels keyed by serial number
  939. all_serials = set(serial_map.values())
  940. # Also include synthetic fallback keys for assignments without a known serial
  941. synthetic_keys: dict[str, tuple[int, int]] = {}
  942. for a in assignments:
  943. if (a.printer_id, a.ams_id) not in serial_map:
  944. synthetic = f"p{a.printer_id}a{a.ams_id}"
  945. synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
  946. all_serials.add(synthetic)
  947. label_by_serial: dict[str, str] = {}
  948. if all_serials:
  949. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  950. for lbl in lbl_result.scalars().all():
  951. label_by_serial[lbl.ams_serial_number] = lbl.label
  952. # Build response objects, attaching ams_label where available
  953. responses: list[SpoolAssignmentResponse] = []
  954. for a in assignments:
  955. resp = SpoolAssignmentResponse.model_validate(a)
  956. sn = serial_map.get((a.printer_id, a.ams_id))
  957. if sn and sn in label_by_serial:
  958. resp.ams_label = label_by_serial[sn]
  959. elif not sn:
  960. synthetic = f"p{a.printer_id}a{a.ams_id}"
  961. resp.ams_label = label_by_serial.get(synthetic)
  962. responses.append(resp)
  963. return responses
  964. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  965. async def assign_spool(
  966. data: SpoolAssignmentCreate,
  967. db: AsyncSession = Depends(get_db),
  968. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  969. ):
  970. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  971. from backend.app.services.printer_manager import printer_manager
  972. # 1. Validate spool exists and is not archived
  973. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  974. spool = result.scalar_one_or_none()
  975. if not spool:
  976. raise HTTPException(404, "Spool not found")
  977. if spool.archived_at:
  978. raise HTTPException(400, "Cannot assign an archived spool")
  979. # 2. Get current AMS tray state for fingerprint + existing filament ID
  980. fingerprint_color = None
  981. fingerprint_type = None
  982. current_tray_info_idx = ""
  983. state = printer_manager.get_status(data.printer_id)
  984. if state and state.raw_data:
  985. if data.ams_id == 255:
  986. # External slot: look up tray from vt_tray by global ID
  987. vt_tray = state.raw_data.get("vt_tray") or []
  988. ext_id = data.tray_id + 254 # 0→254, 1→255
  989. for vt in vt_tray:
  990. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  991. fingerprint_color = vt.get("tray_color", "")
  992. fingerprint_type = vt.get("tray_type", "")
  993. current_tray_info_idx = vt.get("tray_info_idx", "")
  994. break
  995. else:
  996. ams_data = state.raw_data.get("ams", {})
  997. ams_list = (
  998. ams_data.get("ams", [])
  999. if isinstance(ams_data, dict)
  1000. else ams_data
  1001. if isinstance(ams_data, list)
  1002. else []
  1003. )
  1004. tray = _find_tray_in_ams_data(
  1005. ams_list,
  1006. data.ams_id,
  1007. data.tray_id,
  1008. )
  1009. if tray:
  1010. fingerprint_color = tray.get("tray_color", "")
  1011. fingerprint_type = tray.get("tray_type", "")
  1012. current_tray_info_idx = tray.get("tray_info_idx", "")
  1013. # 3. Upsert assignment (replace if same printer+ams+tray)
  1014. existing = await db.execute(
  1015. select(SpoolAssignment).where(
  1016. SpoolAssignment.printer_id == data.printer_id,
  1017. SpoolAssignment.ams_id == data.ams_id,
  1018. SpoolAssignment.tray_id == data.tray_id,
  1019. )
  1020. )
  1021. old = existing.scalar_one_or_none()
  1022. if old:
  1023. await db.delete(old)
  1024. await db.flush()
  1025. assignment = SpoolAssignment(
  1026. spool_id=data.spool_id,
  1027. printer_id=data.printer_id,
  1028. ams_id=data.ams_id,
  1029. tray_id=data.tray_id,
  1030. fingerprint_color=fingerprint_color,
  1031. fingerprint_type=fingerprint_type,
  1032. )
  1033. db.add(assignment)
  1034. await db.commit()
  1035. await db.refresh(assignment)
  1036. # 4. Auto-configure AMS slot via MQTT.
  1037. #
  1038. # Skip the publish entirely when the target slot is empty: Bambu firmware
  1039. # silently drops ams_filament_setting / extrusion_cali_sel for unloaded
  1040. # slots (there is no filament context for the cali_idx to attach to). The
  1041. # SpoolAssignment row is preserved with an empty fingerprint_type, which
  1042. # acts as the "pending config" marker — when the spool is physically
  1043. # inserted later, on_ams_change re-fires the full configuration. This is
  1044. # the SpoolBuddy primary workflow: weigh-then-assign before insertion.
  1045. slot_is_empty = not (fingerprint_type and fingerprint_type.strip())
  1046. configured = False
  1047. pending_config = slot_is_empty
  1048. if not slot_is_empty:
  1049. try:
  1050. configured = await apply_spool_to_slot_via_mqtt(
  1051. db=db,
  1052. current_user=current_user,
  1053. spool=spool,
  1054. printer_id=data.printer_id,
  1055. ams_id=data.ams_id,
  1056. tray_id=data.tray_id,
  1057. current_tray_info_idx=current_tray_info_idx,
  1058. current_tray_type=fingerprint_type or "",
  1059. )
  1060. except Exception as e:
  1061. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  1062. # Return assignment with spool data
  1063. result = await db.execute(
  1064. select(SpoolAssignment)
  1065. .options(
  1066. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1067. selectinload(SpoolAssignment.printer),
  1068. )
  1069. .where(SpoolAssignment.id == assignment.id)
  1070. )
  1071. resp = result.scalar_one()
  1072. response = SpoolAssignmentResponse.model_validate(resp)
  1073. response.configured = configured
  1074. response.pending_config = pending_config
  1075. if pending_config:
  1076. logger.info(
  1077. "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
  1078. spool.id,
  1079. data.printer_id,
  1080. data.ams_id,
  1081. data.tray_id,
  1082. )
  1083. await ws_manager.broadcast(
  1084. {
  1085. "type": "spool_assignment_changed",
  1086. "printer_id": data.printer_id,
  1087. "ams_id": data.ams_id,
  1088. "tray_id": data.tray_id,
  1089. }
  1090. )
  1091. return response
  1092. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  1093. async def unassign_spool(
  1094. printer_id: int,
  1095. ams_id: int,
  1096. tray_id: int,
  1097. db: AsyncSession = Depends(get_db),
  1098. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1099. ):
  1100. """Unassign a spool from an AMS slot."""
  1101. result = await db.execute(
  1102. select(SpoolAssignment).where(
  1103. SpoolAssignment.printer_id == printer_id,
  1104. SpoolAssignment.ams_id == ams_id,
  1105. SpoolAssignment.tray_id == tray_id,
  1106. )
  1107. )
  1108. assignment = result.scalar_one_or_none()
  1109. if not assignment:
  1110. raise HTTPException(404, "Assignment not found")
  1111. await db.delete(assignment)
  1112. await db.commit()
  1113. await ws_manager.broadcast(
  1114. {
  1115. "type": "spool_assignment_changed",
  1116. "printer_id": printer_id,
  1117. "ams_id": ams_id,
  1118. "tray_id": tray_id,
  1119. }
  1120. )
  1121. return {"status": "deleted"}
  1122. # ── Tag Linking ───────────────────────────────────────────────────────────────
  1123. class LinkTagRequest(BaseModel):
  1124. tag_uid: str | None = None
  1125. tray_uuid: str | None = None
  1126. tag_type: str | None = None
  1127. data_origin: str | None = "nfc_link"
  1128. def _validate_tag_input(
  1129. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  1130. ) -> None:
  1131. if raw_value is None:
  1132. return
  1133. raw = str(raw_value).strip()
  1134. if not raw:
  1135. return
  1136. if normalized_value is None:
  1137. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  1138. if len(normalized_value) % 2 != 0:
  1139. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  1140. if exact_len is not None and len(normalized_value) != exact_len:
  1141. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  1142. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  1143. async def link_tag_to_spool(
  1144. spool_id: int,
  1145. data: LinkTagRequest,
  1146. db: AsyncSession = Depends(get_db),
  1147. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1148. ):
  1149. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  1150. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1151. spool = result.scalar_one_or_none()
  1152. if not spool:
  1153. raise HTTPException(404, "Spool not found")
  1154. if spool.archived_at:
  1155. raise HTTPException(400, "Cannot link tag to archived spool")
  1156. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  1157. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  1158. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  1159. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  1160. # Check for conflicts: tag already linked to another active spool
  1161. if normalized_tag_uid:
  1162. conflict = await db.execute(
  1163. select(Spool).where(
  1164. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1165. Spool.id != spool_id,
  1166. Spool.archived_at.is_(None),
  1167. )
  1168. )
  1169. if conflict.scalar_one_or_none():
  1170. raise HTTPException(409, "Tag UID already linked to another active spool")
  1171. # Auto-clear from archived spools (tag recycling)
  1172. archived_with_tag = await db.execute(
  1173. select(Spool).where(
  1174. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1175. Spool.id != spool_id,
  1176. Spool.archived_at.is_not(None),
  1177. )
  1178. )
  1179. for old_spool in archived_with_tag.scalars().all():
  1180. old_spool.tag_uid = None
  1181. if normalized_tray_uuid:
  1182. conflict = await db.execute(
  1183. select(Spool).where(
  1184. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1185. Spool.id != spool_id,
  1186. Spool.archived_at.is_(None),
  1187. )
  1188. )
  1189. if conflict.scalar_one_or_none():
  1190. raise HTTPException(409, "Tray UUID already linked to another active spool")
  1191. archived_with_uuid = await db.execute(
  1192. select(Spool).where(
  1193. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1194. Spool.id != spool_id,
  1195. Spool.archived_at.is_not(None),
  1196. )
  1197. )
  1198. for old_spool in archived_with_uuid.scalars().all():
  1199. old_spool.tray_uuid = None
  1200. if data.tag_uid is not None:
  1201. spool.tag_uid = normalized_tag_uid
  1202. if data.tray_uuid is not None:
  1203. spool.tray_uuid = normalized_tray_uuid
  1204. if data.tag_type is not None:
  1205. spool.tag_type = data.tag_type
  1206. if data.data_origin is not None:
  1207. spool.data_origin = data.data_origin
  1208. await db.commit()
  1209. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1210. return result.scalar_one()
  1211. # ── Usage History ─────────────────────────────────────────────────────────────
  1212. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  1213. async def get_spool_usage_history(
  1214. spool_id: int,
  1215. limit: int = 50,
  1216. db: AsyncSession = Depends(get_db),
  1217. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1218. ):
  1219. """Get usage history for a specific spool."""
  1220. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1221. # Verify spool exists
  1222. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1223. if not spool_result.scalar_one_or_none():
  1224. raise HTTPException(404, "Spool not found")
  1225. result = await db.execute(
  1226. select(SpoolUsageHistory)
  1227. .where(SpoolUsageHistory.spool_id == spool_id)
  1228. .order_by(SpoolUsageHistory.created_at.desc())
  1229. .limit(limit)
  1230. )
  1231. return list(result.scalars().all())
  1232. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  1233. async def get_all_usage_history(
  1234. limit: int = 100,
  1235. printer_id: int | None = None,
  1236. db: AsyncSession = Depends(get_db),
  1237. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1238. ):
  1239. """Get global usage history, optionally filtered by printer."""
  1240. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1241. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  1242. if printer_id is not None:
  1243. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  1244. result = await db.execute(query)
  1245. return list(result.scalars().all())
  1246. @router.delete("/spools/{spool_id}/usage")
  1247. async def clear_spool_usage_history(
  1248. spool_id: int,
  1249. db: AsyncSession = Depends(get_db),
  1250. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1251. ):
  1252. """Clear usage history for a spool."""
  1253. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1254. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  1255. for row in result.scalars().all():
  1256. await db.delete(row)
  1257. await db.commit()
  1258. return {"status": "cleared"}
  1259. # ── AMS Weight Sync ──────────────────────────────────────────────────────────
  1260. @router.post("/sync-ams-weights")
  1261. async def sync_weights_from_ams(
  1262. db: AsyncSession = Depends(get_db),
  1263. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1264. ):
  1265. """Force-sync spool weight_used from live AMS remain% data.
  1266. Overwrites the database weight_used for every assigned spool using the
  1267. current AMS remain% from connected printers. This is a manual recovery
  1268. tool — it bypasses the normal "only increase" guard.
  1269. """
  1270. from backend.app.services.printer_manager import printer_manager
  1271. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  1272. assignments = list(result.scalars().all())
  1273. logger.info("AMS weight sync: found %d assignments", len(assignments))
  1274. synced = 0
  1275. skipped = 0
  1276. for assignment in assignments:
  1277. spool = assignment.spool
  1278. if not spool:
  1279. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  1280. skipped += 1
  1281. continue
  1282. if spool.weight_locked:
  1283. logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
  1284. skipped += 1
  1285. continue
  1286. state = printer_manager.get_status(assignment.printer_id)
  1287. if not state or not state.raw_data:
  1288. logger.info(
  1289. "AMS weight sync: printer %d not connected, skipping spool %d",
  1290. assignment.printer_id,
  1291. spool.id,
  1292. )
  1293. skipped += 1
  1294. continue
  1295. ams_raw = state.raw_data.get("ams", [])
  1296. if isinstance(ams_raw, dict):
  1297. ams_raw = ams_raw.get("ams", [])
  1298. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  1299. if not tray:
  1300. logger.info(
  1301. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1302. spool.id,
  1303. assignment.printer_id,
  1304. assignment.ams_id,
  1305. assignment.tray_id,
  1306. )
  1307. skipped += 1
  1308. continue
  1309. remain_raw = tray.get("remain")
  1310. if remain_raw is None:
  1311. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  1312. skipped += 1
  1313. continue
  1314. try:
  1315. remain_val = int(remain_raw)
  1316. except (TypeError, ValueError):
  1317. skipped += 1
  1318. continue
  1319. if remain_val < 0 or remain_val > 100:
  1320. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  1321. skipped += 1
  1322. continue
  1323. lw = spool.label_weight or 1000
  1324. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  1325. old_used = spool.weight_used or 0
  1326. if round(old_used, 1) != new_used:
  1327. logger.info(
  1328. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  1329. spool.id,
  1330. old_used,
  1331. new_used,
  1332. remain_val,
  1333. )
  1334. spool.weight_used = new_used
  1335. synced += 1
  1336. else:
  1337. skipped += 1
  1338. await db.commit()
  1339. return {"synced": synced, "skipped": skipped}
  1340. # ── Helpers ──────────────────────────────────────────────────────────────────
  1341. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  1342. """Find a specific tray in the AMS data structure."""
  1343. if not ams_data:
  1344. return None
  1345. for ams_unit in ams_data:
  1346. if int(ams_unit.get("id", -1)) != ams_id:
  1347. continue
  1348. for tray in ams_unit.get("tray", []):
  1349. if int(tray.get("id", -1)) == tray_id:
  1350. return tray
  1351. return None
  1352. # ── Filament SKU Settings (reorder forecasting) ───────────────────────────────
  1353. class FilamentSkuSettingsResponse(BaseModel):
  1354. id: int
  1355. material: str
  1356. subtype: str | None
  1357. brand: str | None
  1358. lead_time_days: int
  1359. safety_margin_value: int
  1360. safety_margin_unit: str
  1361. alerts_snoozed: bool = False
  1362. class Config:
  1363. from_attributes = True
  1364. class FilamentSkuSettingsUpsert(BaseModel):
  1365. material: str
  1366. subtype: str | None = None
  1367. brand: str | None = None
  1368. lead_time_days: int = 0
  1369. safety_margin_value: int = 14
  1370. safety_margin_unit: str = "days"
  1371. alerts_snoozed: bool = False
  1372. @router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
  1373. async def list_sku_settings(
  1374. db: AsyncSession = Depends(get_db),
  1375. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  1376. ):
  1377. """List all filament SKU reorder settings."""
  1378. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  1379. result = await db.execute(
  1380. select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
  1381. )
  1382. return list(result.scalars().all())
  1383. @router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
  1384. async def upsert_sku_settings(
  1385. data: FilamentSkuSettingsUpsert,
  1386. db: AsyncSession = Depends(get_db),
  1387. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1388. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1389. ),
  1390. ):
  1391. """Create or update reorder settings for a filament SKU (material/subtype/brand)."""
  1392. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  1393. result = await db.execute(
  1394. select(FilamentSkuSettings).where(
  1395. FilamentSkuSettings.material == data.material,
  1396. FilamentSkuSettings.subtype == data.subtype,
  1397. FilamentSkuSettings.brand == data.brand,
  1398. )
  1399. )
  1400. row = result.scalar_one_or_none()
  1401. if row:
  1402. row.lead_time_days = data.lead_time_days
  1403. row.safety_margin_value = data.safety_margin_value
  1404. row.safety_margin_unit = data.safety_margin_unit
  1405. row.alerts_snoozed = data.alerts_snoozed
  1406. else:
  1407. row = FilamentSkuSettings(
  1408. material=data.material,
  1409. subtype=data.subtype,
  1410. brand=data.brand,
  1411. lead_time_days=data.lead_time_days,
  1412. safety_margin_value=data.safety_margin_value,
  1413. safety_margin_unit=data.safety_margin_unit,
  1414. alerts_snoozed=data.alerts_snoozed,
  1415. )
  1416. db.add(row)
  1417. await db.commit()
  1418. await db.refresh(row)
  1419. return row
  1420. # ── Shopping List ─────────────────────────────────────────────────────────────
  1421. class ShoppingListItemResponse(BaseModel):
  1422. id: int
  1423. material: str
  1424. subtype: str | None
  1425. brand: str | None
  1426. quantity_spools: int
  1427. note: str | None
  1428. status: str
  1429. purchased_at: str | None
  1430. added_at: str
  1431. class Config:
  1432. from_attributes = True
  1433. class ShoppingListItemCreate(BaseModel):
  1434. material: str
  1435. subtype: str | None = None
  1436. brand: str | None = None
  1437. quantity_spools: int = 1
  1438. note: str | None = None
  1439. class ShoppingListItemStatusUpdate(BaseModel):
  1440. status: str # pending | purchased | received
  1441. @router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
  1442. async def get_shopping_list(
  1443. db: AsyncSession = Depends(get_db),
  1444. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  1445. ):
  1446. """Get the filament shopping list."""
  1447. from backend.app.models.shopping_list import ShoppingListItem
  1448. result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
  1449. items = result.scalars().all()
  1450. return [
  1451. ShoppingListItemResponse(
  1452. id=i.id,
  1453. material=i.material,
  1454. subtype=i.subtype,
  1455. brand=i.brand,
  1456. quantity_spools=i.quantity_spools,
  1457. note=i.note,
  1458. status=i.status or "pending",
  1459. purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
  1460. added_at=i.added_at.isoformat() if i.added_at else "",
  1461. )
  1462. for i in items
  1463. ]
  1464. @router.post("/shopping-list", response_model=ShoppingListItemResponse)
  1465. async def add_to_shopping_list(
  1466. data: ShoppingListItemCreate,
  1467. db: AsyncSession = Depends(get_db),
  1468. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1469. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1470. ),
  1471. ):
  1472. """Add a filament SKU to the shopping list."""
  1473. from backend.app.models.shopping_list import ShoppingListItem
  1474. item = ShoppingListItem(
  1475. material=data.material,
  1476. subtype=data.subtype,
  1477. brand=data.brand,
  1478. quantity_spools=data.quantity_spools,
  1479. note=data.note,
  1480. )
  1481. db.add(item)
  1482. await db.commit()
  1483. await db.refresh(item)
  1484. return ShoppingListItemResponse(
  1485. id=item.id,
  1486. material=item.material,
  1487. subtype=item.subtype,
  1488. brand=item.brand,
  1489. quantity_spools=item.quantity_spools,
  1490. note=item.note,
  1491. status=item.status or "pending",
  1492. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  1493. added_at=item.added_at.isoformat() if item.added_at else "",
  1494. )
  1495. @router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
  1496. async def update_shopping_list_status(
  1497. item_id: int,
  1498. data: ShoppingListItemStatusUpdate,
  1499. db: AsyncSession = Depends(get_db),
  1500. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1501. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1502. ),
  1503. ):
  1504. """Update the purchase status of a shopping list item."""
  1505. from datetime import datetime, timezone
  1506. from backend.app.models.shopping_list import ShoppingListItem
  1507. if data.status not in ("pending", "purchased", "received"):
  1508. raise HTTPException(400, "Invalid status")
  1509. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  1510. item = result.scalar_one_or_none()
  1511. if not item:
  1512. raise HTTPException(404, "Item not found")
  1513. item.status = data.status
  1514. if data.status in ("purchased", "received") and item.purchased_at is None:
  1515. item.purchased_at = datetime.now(timezone.utc)
  1516. elif data.status == "pending":
  1517. item.purchased_at = None
  1518. await db.commit()
  1519. await db.refresh(item)
  1520. return ShoppingListItemResponse(
  1521. id=item.id,
  1522. material=item.material,
  1523. subtype=item.subtype,
  1524. brand=item.brand,
  1525. quantity_spools=item.quantity_spools,
  1526. note=item.note,
  1527. status=item.status or "pending",
  1528. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  1529. added_at=item.added_at.isoformat() if item.added_at else "",
  1530. )
  1531. @router.delete("/shopping-list/{item_id}")
  1532. async def remove_from_shopping_list(
  1533. item_id: int,
  1534. db: AsyncSession = Depends(get_db),
  1535. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1536. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1537. ),
  1538. ):
  1539. """Remove a single item from the shopping list."""
  1540. from backend.app.models.shopping_list import ShoppingListItem
  1541. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  1542. item = result.scalar_one_or_none()
  1543. if not item:
  1544. raise HTTPException(404, "Item not found")
  1545. await db.delete(item)
  1546. await db.commit()
  1547. return {"status": "deleted"}
  1548. @router.delete("/shopping-list")
  1549. async def clear_shopping_list(
  1550. db: AsyncSession = Depends(get_db),
  1551. _: User | None = RequireAnyPermissionIfAuthEnabled(
  1552. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  1553. ),
  1554. ):
  1555. """Clear all items from the shopping list."""
  1556. from backend.app.models.shopping_list import ShoppingListItem
  1557. result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
  1558. deleted = len(result.fetchall())
  1559. await db.commit()
  1560. return {"deleted": deleted}