inventory.py 103 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670
  1. import json
  2. import logging
  3. import httpx
  4. from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
  5. from fastapi.responses import Response, StreamingResponse
  6. from pydantic import BaseModel, Field, field_validator
  7. from sqlalchemy import delete, func, select
  8. from sqlalchemy.exc import IntegrityError
  9. from sqlalchemy.ext.asyncio import AsyncSession
  10. from sqlalchemy.orm import selectinload
  11. from backend.app.core.auth import (
  12. RequireAnyPermissionIfAuthEnabled,
  13. RequirePermissionIfAuthEnabled,
  14. require_auth_if_enabled,
  15. )
  16. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
  17. from backend.app.core.database import get_db
  18. from backend.app.core.permissions import Permission
  19. from backend.app.core.websocket import ws_manager
  20. from backend.app.models.ams_label import AmsLabel
  21. from backend.app.models.color_catalog import ColorCatalogEntry
  22. from backend.app.models.location import Location
  23. from backend.app.models.settings import Settings
  24. from backend.app.models.spool import Spool
  25. from backend.app.models.spool_assignment import SpoolAssignment
  26. from backend.app.models.spool_catalog import SpoolCatalogEntry
  27. from backend.app.models.spool_filament_preset import SpoolFilamentPreset
  28. from backend.app.models.spool_k_profile import SpoolKProfile
  29. from backend.app.models.user import User
  30. from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
  31. from backend.app.schemas.spool import (
  32. SpoolAssignmentCreate,
  33. SpoolAssignmentResponse,
  34. SpoolBulkCreate,
  35. SpoolCreate,
  36. SpoolFilamentPresetBase,
  37. SpoolFilamentPresetResponse,
  38. SpoolKProfileBase,
  39. SpoolKProfileResponse,
  40. SpoolResponse,
  41. SpoolUpdate,
  42. normalize_effect_type,
  43. normalize_extra_colors,
  44. )
  45. from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
  46. from backend.app.services.location_service import (
  47. DUPLICATE_LOCATION_NAME,
  48. assign_location_name,
  49. count_internal_spools_at_location,
  50. get_location_by_id,
  51. get_location_by_name,
  52. location_name_key,
  53. prepare_internal_spool_payload,
  54. rename_location as rename_location_record,
  55. )
  56. from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
  57. from backend.app.services.slot_nozzle import resolve_slot_nozzle
  58. from backend.app.services.spool_csv import (
  59. MAX_CSV_IMPORT_BYTES,
  60. ImportPreview,
  61. ImportResult,
  62. parse_and_validate,
  63. serialize,
  64. )
  65. from backend.app.services.spool_filament_preset import resolve_spool_preset
  66. from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
  67. from backend.app.utils.filament_ids import (
  68. GENERIC_FILAMENT_IDS,
  69. filament_id_to_setting_id,
  70. normalize_slicer_filament,
  71. )
  72. from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
  73. from backend.app.utils.natural_sort import natural_sort_key
  74. from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
  75. logger = logging.getLogger(__name__)
  76. _GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
  77. router = APIRouter(prefix="/inventory", tags=["inventory"])
  78. # Bounded read size for the CSV import body so a chunked upload with no
  79. # Content-Length can't stream past the cap into memory before we notice.
  80. _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
  81. # FilamentColors.xyz API
  82. FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
  83. async def apply_spool_to_slot_via_mqtt(
  84. *,
  85. db: AsyncSession,
  86. current_user: User | None,
  87. spool: Spool,
  88. printer_id: int,
  89. ams_id: int,
  90. tray_id: int,
  91. current_tray_info_idx: str = "",
  92. current_tray_type: str = "",
  93. ) -> bool:
  94. """Publish ams_filament_setting + extrusion_cali_sel for a spool on a slot.
  95. Shared by `assign_spool` (initial assign for a loaded slot) and
  96. `on_ams_change` (re-fire when a SpoolBuddy-pre-assigned slot transitions
  97. empty → loaded). Returns True when MQTT commands were published, False if
  98. no client was available or setup failed mid-way.
  99. `current_tray_info_idx` / `current_tray_type` describe the live tray state
  100. used as fallback hints when the spool's slicer_filament can't be resolved.
  101. Caller should not pass these for the empty-slot re-fire path (they'll be
  102. the freshly-loaded values, which is the intended fallback).
  103. """
  104. from backend.app.services.printer_manager import printer_manager
  105. client = printer_manager.get_client(printer_id)
  106. if client is None:
  107. return False
  108. state = printer_manager.get_status(printer_id)
  109. # The slot carries the material type; the product line the material column
  110. # may actually hold ("PLA+", "HTPLA") stays in tray_sub_brands below, which
  111. # is where Bambu puts it too (issue #2902).
  112. tray_type = printer_filament_type(spool.material)
  113. # Join only the parts that exist. The previous shape interpolated
  114. # `spool.subtype` into the branded string without checking it, so a spool
  115. # with a brand and no subtype went to the printer as
  116. # "Sunlu PLA Matte None" -- the string "None", on the wire (#2987). The
  117. # unbranded branch guarded subtype; the branded one did not.
  118. tray_sub_brands = " ".join(p for p in (spool.brand, spool.material, spool.subtype) if p) or spool.material
  119. tray_color = spool.rgba or "FFFFFFFF"
  120. _generic_id_values = _GENERIC_ID_VALUES
  121. # Which nozzle this slot feeds, and how wide it is. One resolution shared
  122. # with every other path that configures a slot (see services.slot_nozzle),
  123. # and used twice below -- for the spool's per-model preset override and for
  124. # its K profile -- so the two lookups cannot answer for different nozzles.
  125. slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
  126. nozzle_diameter = slot_nozzle.diameter
  127. # A cloud or Orca preset is bound to a printer MODEL ("@BBL X1C"), so the
  128. # spool's single slicer_filament stops being right the moment the same
  129. # spool is used on a second model. resolve_spool_preset returns the
  130. # spool's own value unless the user has set an override for this model,
  131. # so a spool nobody has configured behaves exactly as it did before.
  132. slot_slicer_filament, slot_slicer_filament_name = await resolve_spool_preset(
  133. db,
  134. spool_id=spool.id,
  135. printer_model=printer_manager.get_model(printer_id),
  136. nozzle_diameter=nozzle_diameter,
  137. fallback_filament=spool.slicer_filament,
  138. fallback_name=spool.slicer_filament_name,
  139. )
  140. # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
  141. # the Spoolman-mode route via this helper (#1713). The helper handles
  142. # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
  143. # the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
  144. # sanitization. When it returns an empty tray_info_idx the local
  145. # current-tray-state + generic-material fallback below rescues the slot.
  146. tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
  147. db=db,
  148. current_user=current_user,
  149. slicer_filament=slot_slicer_filament,
  150. slicer_filament_name=slot_slicer_filament_name,
  151. material=spool.material,
  152. )
  153. if sub_brand_override:
  154. tray_sub_brands = sub_brand_override
  155. # A preset says what its material is; the reduction above only infers it
  156. # from whatever wording the spool's material column happens to carry. When
  157. # the spool has a preset, its answer wins (issue #2902, @doncaruana).
  158. if type_override:
  159. tray_type = printer_filament_type(type_override)
  160. if not tray_info_idx:
  161. if (
  162. current_tray_info_idx
  163. and current_tray_info_idx not in _generic_id_values
  164. and not current_tray_info_idx.startswith("PFUS")
  165. and not current_tray_info_idx.startswith("PFCN")
  166. # Shares the resolver's reading of what counts as a material
  167. # name, product lines included: a slot written by a Bambuddy from
  168. # before #2902 can be holding "PLA+" in this field, and reusing
  169. # that would carry the bad id forward instead of replacing it.
  170. and not is_material_name(current_tray_info_idx)
  171. and current_tray_type
  172. and current_tray_type.upper() == tray_type.upper()
  173. ):
  174. tray_info_idx = current_tray_info_idx
  175. elif tray_type:
  176. # The spool's own wording is tried first and the reduced type only
  177. # as a further fallback, so a material that already resolves keeps
  178. # resolving to the same id: "PETG HF" has its own generic preset
  179. # (GFG96) that reducing it to "PETG" would trade away for GFG99.
  180. material = (spool.material or "").upper().strip()
  181. generic = (
  182. GENERIC_FILAMENT_IDS.get(material)
  183. or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
  184. or GENERIC_FILAMENT_IDS.get(tray_type.upper())
  185. or ""
  186. )
  187. if generic:
  188. tray_info_idx = generic
  189. # Ensure setting_id is always derivable from tray_info_idx. The local-preset
  190. # path above sets tray_info_idx to a generic ID (e.g. "GFL99") but leaves
  191. # setting_id empty — without this fallback the slicer gets a half-configured
  192. # slot (filament id without setting id) and shows empty fields in the slot
  193. # detail modal.
  194. if tray_info_idx and not setting_id:
  195. setting_id = filament_id_to_setting_id(tray_info_idx)
  196. # Same order as the generic-id lookup above: the spool's own wording wins,
  197. # the reduced type rescues what it does not cover. Without the second
  198. # lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
  199. temp_min, temp_max = nozzle_temp_range(spool.material, tray_type)
  200. if spool.nozzle_temp_min is not None:
  201. temp_min = spool.nozzle_temp_min
  202. if spool.nozzle_temp_max is not None:
  203. temp_max = spool.nozzle_temp_max
  204. slot_extruder = slot_nozzle.extruder
  205. # Prefer exact extruder match, fall back to extruder-agnostic kp for the
  206. # same nozzle. Hard-skipping on mismatch silently drops valid stored
  207. # profiles when the AMS-extruder mapping has shifted.
  208. exact_kp = None
  209. fallback_kp = None
  210. for kp in spool.k_profiles:
  211. if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
  212. continue
  213. # A profile measured on a high-flow nozzle is not a fact about a
  214. # standard one. Rows with no stored flow -- everything saved before
  215. # this, and everything from a printer whose table declares none --
  216. # still match, see SlotNozzle.flow_matches.
  217. if not slot_nozzle.flow_matches(kp.nozzle_type):
  218. continue
  219. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  220. exact_kp = kp
  221. break
  222. if fallback_kp is None:
  223. fallback_kp = kp
  224. matching_kp = exact_kp or fallback_kp
  225. # Resolve the printer-side calibration entry by looking up the cali_idx
  226. # in state.kprofiles. The printer keys its calibration table by
  227. # (filament_id, cali_idx) — for the cali_idx to stick, the slot's
  228. # filament_id must match the kp's. PFUS-prefix cloud user presets are
  229. # rejected by the slicer in tray_info_idx; the printer-reported
  230. # filament_id is typically a P-prefix local preset which is valid.
  231. printer_kp = None
  232. if matching_kp and matching_kp.cali_idx is not None and state and getattr(state, "kprofiles", None):
  233. for pkp in state.kprofiles:
  234. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  235. printer_kp = pkp
  236. break
  237. effective_tray_info_idx = tray_info_idx
  238. effective_setting_id = setting_id
  239. if printer_kp and printer_kp.filament_id:
  240. effective_tray_info_idx = printer_kp.filament_id
  241. target_setting_id = (printer_kp.setting_id if printer_kp else None) or (
  242. matching_kp.setting_id if matching_kp else None
  243. )
  244. if target_setting_id:
  245. effective_setting_id = target_setting_id
  246. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  247. logger.info(
  248. "Spool assign: realigning tray_info_idx %r → %r, setting_id %r → %r (source=%s)",
  249. tray_info_idx,
  250. effective_tray_info_idx,
  251. setting_id,
  252. effective_setting_id,
  253. "printer" if printer_kp else "stored",
  254. )
  255. client.ams_set_filament_setting(
  256. ams_id=ams_id,
  257. tray_id=tray_id,
  258. tray_info_idx=effective_tray_info_idx,
  259. tray_type=tray_type,
  260. tray_sub_brands=tray_sub_brands,
  261. tray_color=tray_color,
  262. nozzle_temp_min=temp_min,
  263. nozzle_temp_max=temp_max,
  264. setting_id=effective_setting_id,
  265. )
  266. if matching_kp and matching_kp.cali_idx is not None:
  267. # filament_id for cali_sel must match the preset under which the kp
  268. # was registered. Priority: live printer kp > stored kp.setting_id >
  269. # spool.slicer_filament > realigned tray_info_idx.
  270. if printer_kp and printer_kp.filament_id:
  271. cali_filament_id = printer_kp.filament_id
  272. elif matching_kp.setting_id:
  273. cali_filament_id = normalize_slicer_filament(matching_kp.setting_id)[0] or matching_kp.setting_id
  274. else:
  275. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  276. client.extrusion_cali_sel(
  277. ams_id=ams_id,
  278. tray_id=tray_id,
  279. cali_idx=matching_kp.cali_idx,
  280. filament_id=cali_filament_id,
  281. nozzle_diameter=nozzle_diameter,
  282. )
  283. else:
  284. # No stored K-profile for this spool — always reset the slot to Default
  285. # K (cali_idx=-1). The live cali_idx on the slot belongs to whatever
  286. # filament was there before, so preserving it would apply the wrong
  287. # filament's calibration to the new spool. Default K is the firmware's
  288. # documented "no specific profile" value (see BambuClient.extrusion_cali_sel
  289. # docstring).
  290. cali_filament_id = spool.slicer_filament or effective_tray_info_idx
  291. client.extrusion_cali_sel(
  292. ams_id=ams_id,
  293. tray_id=tray_id,
  294. cali_idx=-1,
  295. filament_id=cali_filament_id,
  296. nozzle_diameter=nozzle_diameter,
  297. )
  298. logger.info(
  299. "No stored K-profile for spool %d — reset slot to Default K (cali_idx=-1)",
  300. spool.id,
  301. )
  302. # Register a read-back verification so the next AMS pushes can confirm the
  303. # tray actually accepted this assignment (#2582). We record the same
  304. # effective filament id we pushed plus the cali_idx we selected (or -1 for
  305. # the Default-K reset above), and the client fires on_assignment_verified
  306. # on match/timeout. Colour is informational only — the match keys on the
  307. # filament id the slicer echoes back.
  308. verify_cali_idx = matching_kp.cali_idx if (matching_kp and matching_kp.cali_idx is not None) else -1
  309. client.register_assignment_verification(
  310. ams_id=ams_id,
  311. tray_id=tray_id,
  312. tray_info_idx=effective_tray_info_idx,
  313. tray_color=tray_color,
  314. cali_idx=verify_cali_idx,
  315. )
  316. # Persist slot preset mapping for UI display (preset_name on hover card).
  317. # Shared with the RFID auto-assign path — both must keep this row in sync
  318. # with the currently-assigned spool, otherwise the slot card surfaces the
  319. # previous spool's preset name (the PrintersPage display chain consults
  320. # slot_preset_mappings.preset_name first).
  321. from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
  322. await upsert_slot_preset_for_spool(
  323. db=db,
  324. spool=spool,
  325. printer_id=printer_id,
  326. ams_id=ams_id,
  327. tray_id=tray_id,
  328. tray_info_idx=tray_info_idx,
  329. tray_sub_brands=tray_sub_brands,
  330. tray_type=tray_type,
  331. setting_id=setting_id,
  332. )
  333. logger.info(
  334. "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
  335. ams_id,
  336. tray_id,
  337. spool.id,
  338. printer_id,
  339. )
  340. return True
  341. # ── Spool Catalog Schemas ──────────────────────────────────────────────────
  342. class CatalogEntryResponse(BaseModel):
  343. id: int
  344. name: str
  345. weight: int
  346. is_default: bool
  347. class Config:
  348. from_attributes = True
  349. class CatalogEntryCreate(BaseModel):
  350. name: str
  351. weight: int
  352. class CatalogEntryUpdate(BaseModel):
  353. name: str
  354. weight: int
  355. class BulkDeleteIdsRequest(BaseModel):
  356. ids: list[int]
  357. # ── Color Catalog Schemas ──────────────────────────────────────────────────
  358. class ColorEntryResponse(BaseModel):
  359. id: int
  360. manufacturer: str
  361. color_name: str
  362. hex_color: str
  363. material: str | None
  364. is_default: bool
  365. extra_colors: str | None = None
  366. effect_type: str | None = None
  367. class Config:
  368. from_attributes = True
  369. _HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
  370. class ColorEntryCreate(BaseModel):
  371. manufacturer: str
  372. color_name: str
  373. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  374. material: str | None = None
  375. extra_colors: str | None = None
  376. effect_type: str | None = None
  377. @field_validator("extra_colors")
  378. @classmethod
  379. def _validate_extra_colors(cls, v: str | None) -> str | None:
  380. return normalize_extra_colors(v)
  381. @field_validator("effect_type")
  382. @classmethod
  383. def _validate_effect_type(cls, v: str | None) -> str | None:
  384. return normalize_effect_type(v)
  385. class ColorEntryUpdate(BaseModel):
  386. manufacturer: str
  387. color_name: str
  388. hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
  389. material: str | None = None
  390. extra_colors: str | None = None
  391. effect_type: str | None = None
  392. @field_validator("extra_colors")
  393. @classmethod
  394. def _validate_extra_colors(cls, v: str | None) -> str | None:
  395. return normalize_extra_colors(v)
  396. @field_validator("effect_type")
  397. @classmethod
  398. def _validate_effect_type(cls, v: str | None) -> str | None:
  399. return normalize_effect_type(v)
  400. class ColorLookupResult(BaseModel):
  401. found: bool
  402. hex_color: str | None = None
  403. material: str | None = None
  404. class ColorByMaterialResult(BaseModel):
  405. color_name: str | None = None
  406. # ── Spool Catalog CRUD ─────────────────────────────────────────────────────
  407. @router.get("/catalog", response_model=list[CatalogEntryResponse])
  408. async def get_spool_catalog(
  409. db: AsyncSession = Depends(get_db),
  410. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  411. ):
  412. """Get all spool catalog entries."""
  413. result = await db.execute(select(SpoolCatalogEntry).order_by(SpoolCatalogEntry.name))
  414. return list(result.scalars().all())
  415. @router.post("/catalog", response_model=CatalogEntryResponse)
  416. async def add_catalog_entry(
  417. entry: CatalogEntryCreate,
  418. db: AsyncSession = Depends(get_db),
  419. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  420. ):
  421. """Add a new spool catalog entry."""
  422. row = SpoolCatalogEntry(name=entry.name, weight=entry.weight, is_default=False)
  423. db.add(row)
  424. await db.commit()
  425. await db.refresh(row)
  426. return row
  427. @router.put("/catalog/{entry_id}", response_model=CatalogEntryResponse)
  428. async def update_catalog_entry(
  429. entry_id: int,
  430. entry: CatalogEntryUpdate,
  431. db: AsyncSession = Depends(get_db),
  432. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  433. ):
  434. """Update a spool catalog entry."""
  435. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  436. row = result.scalar_one_or_none()
  437. if not row:
  438. raise HTTPException(404, "Entry not found")
  439. row.name = entry.name
  440. row.weight = entry.weight
  441. await db.commit()
  442. await db.refresh(row)
  443. return row
  444. @router.delete("/catalog/{entry_id}")
  445. async def delete_catalog_entry(
  446. entry_id: int,
  447. db: AsyncSession = Depends(get_db),
  448. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  449. ):
  450. """Delete a spool catalog entry."""
  451. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id == entry_id))
  452. row = result.scalar_one_or_none()
  453. if not row:
  454. raise HTTPException(404, "Entry not found")
  455. await db.delete(row)
  456. await db.commit()
  457. return {"status": "deleted"}
  458. @router.post("/catalog/bulk-delete")
  459. async def bulk_delete_catalog_entries(
  460. data: BulkDeleteIdsRequest,
  461. db: AsyncSession = Depends(get_db),
  462. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  463. ):
  464. """Delete multiple spool catalog entries by ID."""
  465. if not data.ids:
  466. return {"deleted": 0}
  467. result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.id.in_(data.ids)))
  468. rows = result.scalars().all()
  469. for row in rows:
  470. await db.delete(row)
  471. await db.commit()
  472. return {"deleted": len(rows)}
  473. @router.post("/catalog/reset")
  474. async def reset_spool_catalog(
  475. db: AsyncSession = Depends(get_db),
  476. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  477. ):
  478. """Reset spool catalog to defaults."""
  479. await db.execute(select(SpoolCatalogEntry)) # ensure table loaded
  480. # Delete all
  481. result = await db.execute(select(SpoolCatalogEntry))
  482. for row in result.scalars().all():
  483. await db.delete(row)
  484. # Re-seed defaults
  485. for name, weight in DEFAULT_SPOOL_CATALOG:
  486. db.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  487. await db.commit()
  488. return {"status": "reset"}
  489. # ── Storage Locations (#1004) ───────────────────────────────────────────────
  490. async def _load_settings_map(db: AsyncSession) -> dict[str, str]:
  491. result = await db.execute(select(Settings))
  492. return {s.key: s.value for s in result.scalars().all()}
  493. def _spoolman_is_enabled(settings: dict[str, str]) -> bool:
  494. return settings.get("spoolman_enabled", "false").lower() == "true"
  495. async def _ensure_spoolman_client(settings: dict[str, str]) -> SpoolmanClient | None:
  496. if not _spoolman_is_enabled(settings):
  497. return None
  498. url = settings.get("spoolman_url", "").strip()
  499. if not url:
  500. return None
  501. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  502. try:
  503. assert_safe_spoolman_url(url)
  504. except ValueError:
  505. return None
  506. client = await get_spoolman_client()
  507. if not client or client.base_url != url.rstrip("/"):
  508. client = await init_spoolman_client(url)
  509. return client
  510. async def _spool_counts_for_locations(
  511. db: AsyncSession,
  512. locations: list[Location],
  513. settings: dict[str, str],
  514. ) -> dict[int, int]:
  515. if _spoolman_is_enabled(settings):
  516. client = await _ensure_spoolman_client(settings)
  517. if client:
  518. try:
  519. spools = await client.get_all_spools(allow_archived=False)
  520. except Exception:
  521. logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
  522. else:
  523. # Use the canonical key helper so this matches what the
  524. # migration backfill, Location.name_key, and every other
  525. # codepath store as the case-insensitive lookup key. Plain
  526. # str.lower() drifts for non-ASCII (Turkish ı/İ, German ß)
  527. # and caused mismatched delete-block counts in Spoolman mode.
  528. by_key: dict[str, int] = {}
  529. for spool in spools:
  530. raw = spool.get("location")
  531. if not raw or not isinstance(raw, str) or not raw.strip():
  532. continue
  533. try:
  534. key = location_name_key(raw)
  535. except ValueError:
  536. continue
  537. by_key[key] = by_key.get(key, 0) + 1
  538. return {loc.id: by_key.get(loc.name_key, 0) for loc in locations}
  539. counts: dict[int, int] = {}
  540. for loc in locations:
  541. counts[loc.id] = await count_internal_spools_at_location(db, loc.id)
  542. return counts
  543. def _location_to_response(location: Location, spool_count: int) -> LocationResponse:
  544. return LocationResponse(
  545. id=location.id,
  546. name=location.name,
  547. identifier=location.identifier,
  548. spool_count=spool_count,
  549. created_at=location.created_at,
  550. updated_at=location.updated_at,
  551. )
  552. @router.get("/locations", response_model=list[LocationResponse])
  553. async def list_locations(
  554. db: AsyncSession = Depends(get_db),
  555. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  556. ):
  557. """List all storage locations with spool counts."""
  558. settings = await _load_settings_map(db)
  559. result = await db.execute(select(Location))
  560. # Sorted in Python, not SQL: "Drybox 2" belongs before "Drybox 10", and
  561. # ORDER BY name gives the opposite (plain lexicographic) order.
  562. locations = sorted(result.scalars().all(), key=lambda loc: natural_sort_key(loc.name))
  563. counts = await _spool_counts_for_locations(db, locations, settings)
  564. return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
  565. @router.post("/locations", response_model=LocationResponse, status_code=201)
  566. async def create_location(
  567. data: LocationCreate,
  568. db: AsyncSession = Depends(get_db),
  569. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  570. ):
  571. """Create a storage location."""
  572. existing = await get_location_by_name(db, data.name)
  573. if existing:
  574. raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME)
  575. location = Location(identifier=data.identifier)
  576. assign_location_name(location, data.name)
  577. db.add(location)
  578. try:
  579. await db.commit()
  580. except IntegrityError as exc:
  581. await db.rollback()
  582. raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
  583. await db.refresh(location)
  584. await ws_manager.broadcast({"type": "inventory_changed"})
  585. return _location_to_response(location, 0)
  586. @router.patch("/locations/{location_id}", response_model=LocationResponse)
  587. async def update_location(
  588. location_id: int,
  589. data: LocationUpdate,
  590. db: AsyncSession = Depends(get_db),
  591. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  592. ):
  593. """Update a storage location (rename propagates to assigned spools)."""
  594. location = await get_location_by_id(db, location_id)
  595. if not location:
  596. raise HTTPException(status_code=404, detail="Location not found")
  597. old_name = location.name
  598. if data.identifier is not None:
  599. location.identifier = data.identifier or None
  600. if data.name is not None and data.name != old_name:
  601. try:
  602. await rename_location_record(db, location, data.name)
  603. except ValueError as exc:
  604. raise HTTPException(status_code=409, detail=str(exc)) from exc
  605. # Cascade to Spoolman BEFORE the local commit so a Spoolman failure
  606. # rolls back the local rename instead of leaving the catalog and
  607. # Spoolman's per-spool `location` field permanently diverged. Without
  608. # this ordering, a partial failure makes the next location-sync recreate
  609. # the old name as a duplicate catalog row (#1505 review blocker).
  610. settings = await _load_settings_map(db)
  611. client = await _ensure_spoolman_client(settings)
  612. if client:
  613. try:
  614. await client.rename_location(old_name, location.name)
  615. except Exception as exc:
  616. logger.warning(
  617. "Spoolman location rename failed for %s -> %s: %s",
  618. old_name,
  619. location.name,
  620. exc,
  621. )
  622. await db.rollback()
  623. raise HTTPException(
  624. status_code=502,
  625. detail="Spoolman rename failed; local rename rolled back",
  626. ) from exc
  627. try:
  628. await db.commit()
  629. except IntegrityError as exc:
  630. await db.rollback()
  631. raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
  632. await db.refresh(location)
  633. settings = await _load_settings_map(db)
  634. counts = await _spool_counts_for_locations(db, [location], settings)
  635. await ws_manager.broadcast({"type": "inventory_changed"})
  636. return _location_to_response(location, counts.get(location.id, 0))
  637. @router.delete("/locations/{location_id}")
  638. async def delete_location(
  639. location_id: int,
  640. db: AsyncSession = Depends(get_db),
  641. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  642. ):
  643. """Delete a storage location when no spools are assigned."""
  644. location = await get_location_by_id(db, location_id)
  645. if not location:
  646. raise HTTPException(status_code=404, detail="Location not found")
  647. settings = await _load_settings_map(db)
  648. counts = await _spool_counts_for_locations(db, [location], settings)
  649. if counts.get(location.id, 0) > 0:
  650. raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
  651. await db.delete(location)
  652. await db.commit()
  653. await ws_manager.broadcast({"type": "inventory_changed"})
  654. return {"status": "deleted"}
  655. # ── Color Catalog CRUD ─────────────────────────────────────────────────────
  656. @router.get("/colors", response_model=list[ColorEntryResponse])
  657. async def get_color_catalog(
  658. db: AsyncSession = Depends(get_db),
  659. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  660. ):
  661. """Get all color catalog entries."""
  662. result = await db.execute(
  663. select(ColorCatalogEntry).order_by(
  664. ColorCatalogEntry.manufacturer, ColorCatalogEntry.material, ColorCatalogEntry.color_name
  665. )
  666. )
  667. return list(result.scalars().all())
  668. @router.get("/colors/map")
  669. async def get_color_name_map(
  670. db: AsyncSession = Depends(get_db),
  671. _: User | None = Depends(require_auth_if_enabled),
  672. ):
  673. """Compact {hex: name} map for frontend color-name resolution.
  674. Not gated on INVENTORY_READ — every page that renders a spool color needs
  675. this, including read-only views available to users without inventory access.
  676. Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
  677. share the same hex (different materials or manufacturers), Bambu Lab wins,
  678. then default entries, then the first encountered.
  679. ``by_material`` carries the names that collapsing loses. A hex is not one
  680. colour in Bambu's range: #FFFFFF is Jade White in PLA Basic, Ivory White in
  681. PLA Matte and plain White in six more, and #000000 is Black except in PLA
  682. Matte where it is Charcoal. A caller that knows the material — an AMS slot
  683. knows it as ``tray_sub_brands`` — looks up ``"<material>|<hex>"`` there
  684. first and falls back to ``colors`` (#2875).
  685. An entry is included only when it recovers a name the *same manufacturer's*
  686. own range lost. Two conditions, both load-bearing: a name equal to the
  687. collapsed one is pure weight, and a name from a different manufacturer is
  688. not a recovery at all — it would put Prusament's "Pristine White" on every
  689. generic white PLA slot in place of Bambu's "Jade White", trading one
  690. arbitrary answer for another. What survives is the handful of cases this
  691. exists for.
  692. """
  693. result = await db.execute(
  694. select(
  695. ColorCatalogEntry.hex_color,
  696. ColorCatalogEntry.color_name,
  697. ColorCatalogEntry.manufacturer,
  698. ColorCatalogEntry.is_default,
  699. ColorCatalogEntry.material,
  700. )
  701. )
  702. # hex → (name, priority, manufacturer); higher priority wins, first on a tie
  703. mapping: dict[str, tuple[str, int, str]] = {}
  704. by_material: dict[str, tuple[str, int, str]] = {} # "material|hex" → same
  705. for hex_color, color_name, manufacturer, is_default, material in result.all():
  706. if not hex_color or not color_name:
  707. continue
  708. key = hex_color.lstrip("#").lower()[:6]
  709. if len(key) != 6:
  710. continue
  711. brand = (manufacturer or "").strip().lower()
  712. priority = 0
  713. if brand == "bambu lab":
  714. priority += 2
  715. if is_default:
  716. priority += 1
  717. existing = mapping.get(key)
  718. if existing is None or priority > existing[1]:
  719. mapping[key] = (color_name, priority, brand)
  720. material_key = (material or "").strip().lower()
  721. if material_key:
  722. # Split on the LAST separator when reading these back: a material is
  723. # free text and may itself contain a '|'.
  724. qualified = f"{material_key}|{key}"
  725. existing = by_material.get(qualified)
  726. if existing is None or priority > existing[1]:
  727. by_material[qualified] = (color_name, priority, brand)
  728. colors = {k: v[0] for k, v in mapping.items()}
  729. qualified_colors = {}
  730. for qualified, (name, _, brand) in by_material.items():
  731. flat = mapping.get(qualified.rsplit("|", 1)[1])
  732. if flat and flat[0] != name and flat[2] == brand:
  733. qualified_colors[qualified] = name
  734. return {"colors": colors, "by_material": qualified_colors}
  735. @router.post("/colors", response_model=ColorEntryResponse)
  736. async def add_color_entry(
  737. entry: ColorEntryCreate,
  738. db: AsyncSession = Depends(get_db),
  739. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  740. ):
  741. """Add a new color catalog entry."""
  742. row = ColorCatalogEntry(
  743. manufacturer=entry.manufacturer,
  744. color_name=entry.color_name,
  745. hex_color=entry.hex_color,
  746. material=entry.material,
  747. is_default=False,
  748. extra_colors=entry.extra_colors,
  749. effect_type=entry.effect_type,
  750. )
  751. db.add(row)
  752. await db.commit()
  753. await db.refresh(row)
  754. return row
  755. @router.put("/colors/{entry_id}", response_model=ColorEntryResponse)
  756. async def update_color_entry(
  757. entry_id: int,
  758. entry: ColorEntryUpdate,
  759. db: AsyncSession = Depends(get_db),
  760. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  761. ):
  762. """Update a color catalog entry."""
  763. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  764. row = result.scalar_one_or_none()
  765. if not row:
  766. raise HTTPException(404, "Entry not found")
  767. row.manufacturer = entry.manufacturer
  768. row.color_name = entry.color_name
  769. row.hex_color = entry.hex_color
  770. row.material = entry.material
  771. row.extra_colors = entry.extra_colors
  772. row.effect_type = entry.effect_type
  773. await db.commit()
  774. await db.refresh(row)
  775. return row
  776. @router.delete("/colors/{entry_id}")
  777. async def delete_color_entry(
  778. entry_id: int,
  779. db: AsyncSession = Depends(get_db),
  780. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  781. ):
  782. """Delete a color catalog entry."""
  783. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id == entry_id))
  784. row = result.scalar_one_or_none()
  785. if not row:
  786. raise HTTPException(404, "Entry not found")
  787. await db.delete(row)
  788. await db.commit()
  789. return {"status": "deleted"}
  790. @router.post("/colors/bulk-delete")
  791. async def bulk_delete_color_entries(
  792. data: BulkDeleteIdsRequest,
  793. db: AsyncSession = Depends(get_db),
  794. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  795. ):
  796. """Delete multiple color catalog entries by ID."""
  797. if not data.ids:
  798. return {"deleted": 0}
  799. result = await db.execute(select(ColorCatalogEntry).where(ColorCatalogEntry.id.in_(data.ids)))
  800. rows = result.scalars().all()
  801. for row in rows:
  802. await db.delete(row)
  803. await db.commit()
  804. return {"deleted": len(rows)}
  805. @router.post("/colors/reset")
  806. async def reset_color_catalog(
  807. db: AsyncSession = Depends(get_db),
  808. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  809. ):
  810. """Reset color catalog to defaults."""
  811. result = await db.execute(select(ColorCatalogEntry))
  812. for row in result.scalars().all():
  813. await db.delete(row)
  814. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  815. db.add(
  816. ColorCatalogEntry(
  817. manufacturer=manufacturer,
  818. color_name=color_name,
  819. hex_color=hex_color,
  820. material=material,
  821. is_default=True,
  822. )
  823. )
  824. await db.commit()
  825. return {"status": "reset"}
  826. @router.get("/colors/lookup", response_model=ColorLookupResult)
  827. async def lookup_color(
  828. manufacturer: str,
  829. color_name: str,
  830. material: str | None = None,
  831. db: AsyncSession = Depends(get_db),
  832. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  833. ):
  834. """Look up a color by manufacturer and color name."""
  835. query = select(ColorCatalogEntry).where(
  836. ColorCatalogEntry.manufacturer == manufacturer,
  837. ColorCatalogEntry.color_name == color_name,
  838. )
  839. if material:
  840. query = query.where(ColorCatalogEntry.material == material)
  841. query = query.limit(1)
  842. result = await db.execute(query)
  843. row = result.scalar_one_or_none()
  844. if row:
  845. return ColorLookupResult(found=True, hex_color=row.hex_color, material=row.material)
  846. return ColorLookupResult(found=False)
  847. @router.get("/colors/by-material", response_model=ColorByMaterialResult)
  848. async def get_color_by_material(
  849. hex: str,
  850. material: str | None = None,
  851. db: AsyncSession = Depends(get_db),
  852. _: User | None = Depends(require_auth_if_enabled),
  853. ):
  854. """Disambiguated hex→name lookup that respects material context.
  855. ``/colors/map`` collapses every catalog entry sharing a hex to a single
  856. name with "Bambu Lab > is_default > first" priority — that loses, e.g.,
  857. "PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000).
  858. This endpoint preserves the material context so the queue scheduler's
  859. Filament Override label can show the actually-sliced sub-brand colour
  860. instead of the generic bucket. #1718.
  861. Returns ``color_name=None`` when the hex isn't in the catalog at all.
  862. When the hex IS in the catalog but no entry matches the requested
  863. material (or none was supplied), falls back to the same priority order
  864. as ``/colors/map`` so callers without a material hint don't regress.
  865. Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't —
  866. every queue / archive view that renders a sliced filament colour needs
  867. this, including read-only roles.
  868. """
  869. key = hex.lstrip("#").lower()[:6]
  870. if len(key) != 6:
  871. return ColorByMaterialResult(color_name=None)
  872. material_norm = (material or "").strip().lower()
  873. # Catalog rows are stored as ``#RRGGBB`` (verified at write time and
  874. # against production); lookup uses lower-cased hex equality so mixed-case
  875. # writes from older imports still match.
  876. result = await db.execute(
  877. select(
  878. ColorCatalogEntry.color_name,
  879. ColorCatalogEntry.manufacturer,
  880. ColorCatalogEntry.material,
  881. ColorCatalogEntry.is_default,
  882. ).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}")
  883. )
  884. candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name]
  885. if not candidates:
  886. return ColorByMaterialResult(color_name=None)
  887. if material_norm:
  888. for name, _mfg, mat, _is_default in candidates:
  889. if mat and mat.strip().lower() == material_norm:
  890. return ColorByMaterialResult(color_name=name)
  891. # Same priority order as ``/colors/map`` so a caller passing no (or an
  892. # unrecognised) material gets the existing answer, not a degraded one.
  893. best_name: str | None = None
  894. best_priority = -1
  895. for name, mfg, _mat, is_default in candidates:
  896. priority = 0
  897. if mfg and mfg.strip().lower() == "bambu lab":
  898. priority += 2
  899. if is_default:
  900. priority += 1
  901. if priority > best_priority:
  902. best_name = name
  903. best_priority = priority
  904. return ColorByMaterialResult(color_name=best_name)
  905. @router.get("/colors/search", response_model=list[ColorEntryResponse])
  906. async def search_colors(
  907. manufacturer: str | None = None,
  908. material: str | None = None,
  909. db: AsyncSession = Depends(get_db),
  910. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  911. ):
  912. """Search colors by manufacturer and/or material."""
  913. query = select(ColorCatalogEntry)
  914. if manufacturer:
  915. query = query.where(func.lower(ColorCatalogEntry.manufacturer).contains(manufacturer.lower()))
  916. if material:
  917. query = query.where(func.lower(ColorCatalogEntry.material).contains(material.lower()))
  918. query = query.order_by(ColorCatalogEntry.manufacturer, ColorCatalogEntry.color_name).limit(100)
  919. result = await db.execute(query)
  920. return list(result.scalars().all())
  921. @router.post("/colors/sync")
  922. async def sync_from_filamentcolors(
  923. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  924. ):
  925. """Sync colors from FilamentColors.xyz API with progress streaming."""
  926. async def generate():
  927. from backend.app.core.database import async_session
  928. added = 0
  929. skipped = 0
  930. total_fetched = 0
  931. total_available = 0
  932. try:
  933. # Identify honestly as Bambuddy rather than leaking httpx's
  934. # default "python-httpx/x.y" UA — consistent with every other
  935. # outbound client (bambu_cloud, makerworld, firmware_check).
  936. async with httpx.AsyncClient(
  937. timeout=120.0,
  938. headers={"User-Agent": "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"},
  939. ) as client:
  940. page = 1
  941. while True:
  942. response = await client.get(
  943. f"{FILAMENT_COLORS_API}/swatch/",
  944. params={"page": page},
  945. )
  946. response.raise_for_status()
  947. data = response.json()
  948. total_available = data.get("count", total_available)
  949. results = data.get("results", [])
  950. if not results:
  951. break
  952. async with async_session() as db:
  953. for swatch in results:
  954. total_fetched += 1
  955. manufacturer_data = swatch.get("manufacturer")
  956. manufacturer_name = (
  957. manufacturer_data.get("name", "") if isinstance(manufacturer_data, dict) else ""
  958. )
  959. filament_type_data = swatch.get("filament_type")
  960. mat = filament_type_data.get("name", "") if isinstance(filament_type_data, dict) else None
  961. color_name_val = swatch.get("color_name", "")
  962. hex_color_val = swatch.get("hex_color", "")
  963. if not manufacturer_name or not color_name_val or not hex_color_val:
  964. skipped += 1
  965. continue
  966. if not hex_color_val.startswith("#"):
  967. hex_color_val = f"#{hex_color_val}"
  968. # Check if entry already exists
  969. existing = await db.execute(
  970. select(ColorCatalogEntry)
  971. .where(
  972. ColorCatalogEntry.manufacturer == manufacturer_name,
  973. ColorCatalogEntry.color_name == color_name_val,
  974. ColorCatalogEntry.material == mat,
  975. )
  976. .limit(1)
  977. )
  978. if existing.scalar_one_or_none():
  979. skipped += 1
  980. else:
  981. db.add(
  982. ColorCatalogEntry(
  983. manufacturer=manufacturer_name,
  984. color_name=color_name_val,
  985. hex_color=hex_color_val.upper(),
  986. material=mat,
  987. is_default=False,
  988. )
  989. )
  990. added += 1
  991. await db.commit()
  992. progress = {
  993. "type": "progress",
  994. "added": added,
  995. "skipped": skipped,
  996. "total_fetched": total_fetched,
  997. "total_available": total_available,
  998. }
  999. yield f"data: {json.dumps(progress)}\n\n"
  1000. if not data.get("next") or total_fetched >= total_available:
  1001. break
  1002. page += 1
  1003. result = {
  1004. "type": "complete",
  1005. "added": added,
  1006. "skipped": skipped,
  1007. "total_fetched": total_fetched,
  1008. "total_available": total_available,
  1009. }
  1010. yield f"data: {json.dumps(result)}\n\n"
  1011. except httpx.HTTPError as e:
  1012. logger.error("HTTP error syncing from FilamentColors.xyz: %s", e)
  1013. yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
  1014. except Exception as e:
  1015. logger.error("Error syncing from FilamentColors.xyz: %s", e)
  1016. yield f"data: {json.dumps({'type': 'error', 'error': 'Unexpected error during sync'})}\n\n"
  1017. return StreamingResponse(generate(), media_type="text/event-stream")
  1018. # ── Spool CRUD ───────────────────────────────────────────────────────────────
  1019. @router.get("/spools", response_model=list[SpoolResponse])
  1020. async def list_spools(
  1021. include_archived: bool = False,
  1022. db: AsyncSession = Depends(get_db),
  1023. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1024. ):
  1025. """List all spools, excluding archived by default."""
  1026. query = select(Spool).options(selectinload(Spool.k_profiles))
  1027. if not include_archived:
  1028. query = query.where(Spool.archived_at.is_(None))
  1029. query = query.order_by(Spool.material, Spool.brand, Spool.color_name)
  1030. result = await db.execute(query)
  1031. return list(result.scalars().all())
  1032. # ── CSV import / export (#1576) ──────────────────────────────────────────────
  1033. # Declared before the dynamic `/spools/{spool_id}` route below so the literal
  1034. # `export` / `import` segments match here instead of being parsed as an int id.
  1035. @router.get("/spools/export")
  1036. async def export_spools_csv(
  1037. db: AsyncSession = Depends(get_db),
  1038. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1039. ):
  1040. """Export the active inventory as CSV (same schema the importer accepts)."""
  1041. from datetime import datetime, timezone
  1042. query = select(Spool).where(Spool.archived_at.is_(None)).order_by(Spool.material, Spool.brand, Spool.color_name)
  1043. result = await db.execute(query)
  1044. spools = list(result.scalars().all())
  1045. content = serialize(spools)
  1046. # Date-stamp the filename so repeat exports don't overwrite each other in
  1047. # the browser's default download folder.
  1048. filename = f"bambuddy_inventory_{datetime.now(timezone.utc).strftime('%Y%m%d')}.csv"
  1049. return Response(
  1050. content=content,
  1051. media_type="text/csv",
  1052. headers={"Content-Disposition": f'attachment; filename="{filename}"'},
  1053. )
  1054. @router.post("/spools/import", response_model=ImportPreview | ImportResult)
  1055. async def import_spools_csv(
  1056. file: UploadFile = File(...),
  1057. dry_run: bool = Query(False),
  1058. db: AsyncSession = Depends(get_db),
  1059. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1060. ):
  1061. """Import spools from a CSV file.
  1062. With ``dry_run=true`` returns an ImportPreview (per-row valid/error/skipped,
  1063. colours resolved) and writes nothing — the UI shows this before the user
  1064. confirms. With ``dry_run=false`` it validates the same way and then persists
  1065. only the valid rows in a single transaction (invalid rows are skipped, the
  1066. user fixes the CSV and re-uploads), returning an ImportResult summary.
  1067. """
  1068. def _too_large() -> HTTPException:
  1069. return HTTPException(
  1070. status_code=413,
  1071. detail={
  1072. "code": "csv_import_too_large",
  1073. "message": f"CSV file exceeds the {MAX_CSV_IMPORT_BYTES // (1024 * 1024)} MB limit.",
  1074. },
  1075. )
  1076. # Reject by declared size first (fast path when Content-Length is set), then
  1077. # read in bounded chunks and bail the moment the accumulated body crosses the
  1078. # cap — file.size is None for chunked uploads, so the loop is what actually
  1079. # keeps an oversized stream from filling memory.
  1080. if file.size is not None and file.size > MAX_CSV_IMPORT_BYTES:
  1081. raise _too_large()
  1082. raw = bytearray()
  1083. while chunk := await file.read(_CSV_UPLOAD_CHUNK_BYTES):
  1084. raw.extend(chunk)
  1085. if len(raw) > MAX_CSV_IMPORT_BYTES:
  1086. raise _too_large()
  1087. preview = await parse_and_validate(bytes(raw), db)
  1088. if dry_run:
  1089. return preview
  1090. created = 0
  1091. for row in preview.rows:
  1092. if row.status == "valid" and row.spool is not None:
  1093. db.add(Spool(**row.spool))
  1094. created += 1
  1095. if created:
  1096. await db.commit()
  1097. await ws_manager.broadcast({"type": "inventory_changed"})
  1098. return ImportResult(
  1099. created=created,
  1100. skipped=preview.skipped_count,
  1101. errors=preview.error_count,
  1102. error_rows=[r for r in preview.rows if r.status == "error"],
  1103. )
  1104. @router.get("/spools/by-tag", response_model=SpoolResponse)
  1105. async def get_spool_by_tag(
  1106. tray_uuid: str | None = None,
  1107. tag_uid: str | None = None,
  1108. include_archived: bool = False,
  1109. db: AsyncSession = Depends(get_db),
  1110. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
  1111. ):
  1112. """Find a single spool by its NFC ``tray_uuid`` and/or ``tag_uid``.
  1113. Lets NFC inventory integrations dedupe a scan without listing the whole
  1114. inventory. ``tray_uuid`` is the primary identifier (it matches the value the
  1115. AMS reports over MQTT), so it is tried first; ``tag_uid`` is the fallback.
  1116. At least one identifier must be supplied. Returns 404 when nothing matches.
  1117. Accepts ``inventory:read`` OR ``inventory:update`` so a Manage-Inventory API
  1118. key (which has ``inventory:update`` via ``can_manage_inventory``) can read a
  1119. spool back without widening the global ``INVENTORY_READ`` scope mapping (#1663).
  1120. """
  1121. normalized_tray_uuid = normalize_tray_uuid(tray_uuid) or None
  1122. normalized_tag_uid = normalize_tag_uid(tag_uid) or None
  1123. if not normalized_tray_uuid and not normalized_tag_uid:
  1124. raise HTTPException(400, "Provide tray_uuid and/or tag_uid")
  1125. base_query = select(Spool).options(selectinload(Spool.k_profiles))
  1126. if not include_archived:
  1127. base_query = base_query.where(Spool.archived_at.is_(None))
  1128. for column, value in (
  1129. (Spool.tray_uuid, normalized_tray_uuid),
  1130. (Spool.tag_uid, normalized_tag_uid),
  1131. ):
  1132. if not value:
  1133. continue
  1134. result = await db.execute(base_query.where(func.upper(column) == value).order_by(Spool.id))
  1135. spool = result.scalars().first()
  1136. if spool:
  1137. return spool
  1138. raise HTTPException(404, "Spool not found")
  1139. @router.get("/spools/{spool_id}", response_model=SpoolResponse)
  1140. async def get_spool(
  1141. spool_id: int,
  1142. db: AsyncSession = Depends(get_db),
  1143. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1144. ):
  1145. """Get a single spool with k_profiles."""
  1146. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1147. spool = result.scalar_one_or_none()
  1148. if not spool:
  1149. raise HTTPException(404, "Spool not found")
  1150. return spool
  1151. @router.post("/spools", response_model=SpoolResponse)
  1152. async def create_spool(
  1153. spool_data: SpoolCreate,
  1154. db: AsyncSession = Depends(get_db),
  1155. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1156. ):
  1157. """Create a new spool."""
  1158. try:
  1159. payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
  1160. except ValueError as exc:
  1161. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1162. spool = Spool(**payload)
  1163. db.add(spool)
  1164. await db.commit()
  1165. await db.refresh(spool)
  1166. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  1167. await ws_manager.broadcast({"type": "inventory_changed"})
  1168. return result.scalar_one()
  1169. @router.post("/spools/bulk", response_model=list[SpoolResponse])
  1170. async def bulk_create_spools(
  1171. data: SpoolBulkCreate,
  1172. db: AsyncSession = Depends(get_db),
  1173. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1174. ):
  1175. """Create multiple identical spools."""
  1176. spools = []
  1177. fields_set = set(data.spool.model_fields_set)
  1178. try:
  1179. payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
  1180. except ValueError as exc:
  1181. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1182. for _ in range(data.quantity):
  1183. spool = Spool(**payload)
  1184. db.add(spool)
  1185. spools.append(spool)
  1186. await db.commit()
  1187. ids = [s.id for s in spools]
  1188. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
  1189. await ws_manager.broadcast({"type": "inventory_changed"})
  1190. return list(result.scalars().all())
  1191. @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
  1192. async def update_spool(
  1193. spool_id: int,
  1194. spool_data: SpoolUpdate,
  1195. db: AsyncSession = Depends(get_db),
  1196. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1197. ):
  1198. """Update a spool."""
  1199. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1200. spool = result.scalar_one_or_none()
  1201. if not spool:
  1202. raise HTTPException(404, "Spool not found")
  1203. update_data = spool_data.model_dump(exclude_unset=True)
  1204. try:
  1205. update_data = await prepare_internal_spool_payload(db, update_data, set(spool_data.model_fields_set))
  1206. except ValueError as exc:
  1207. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1208. # Auto-lock weight when user explicitly sets weight_used
  1209. if "weight_used" in update_data and "weight_locked" not in update_data:
  1210. update_data["weight_locked"] = True
  1211. for field, value in update_data.items():
  1212. setattr(spool, field, value)
  1213. await db.commit()
  1214. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1215. await ws_manager.broadcast({"type": "inventory_changed"})
  1216. return result.scalar_one()
  1217. @router.delete("/spools/{spool_id}")
  1218. async def delete_spool(
  1219. spool_id: int,
  1220. db: AsyncSession = Depends(get_db),
  1221. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1222. ):
  1223. """Hard delete a spool."""
  1224. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1225. spool = result.scalar_one_or_none()
  1226. if not spool:
  1227. raise HTTPException(404, "Spool not found")
  1228. await db.delete(spool)
  1229. await db.commit()
  1230. await ws_manager.broadcast({"type": "inventory_changed"})
  1231. return {"status": "deleted"}
  1232. @router.post("/spools/{spool_id}/archive", response_model=SpoolResponse)
  1233. async def archive_spool(
  1234. spool_id: int,
  1235. db: AsyncSession = Depends(get_db),
  1236. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1237. ):
  1238. """Soft-delete a spool by setting archived_at."""
  1239. from datetime import datetime, timezone
  1240. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1241. spool = result.scalar_one_or_none()
  1242. if not spool:
  1243. raise HTTPException(404, "Spool not found")
  1244. spool.archived_at = datetime.now(timezone.utc)
  1245. await db.commit()
  1246. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1247. await ws_manager.broadcast({"type": "inventory_changed"})
  1248. return result.scalar_one()
  1249. @router.post("/spools/{spool_id}/restore", response_model=SpoolResponse)
  1250. async def restore_spool(
  1251. spool_id: int,
  1252. db: AsyncSession = Depends(get_db),
  1253. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1254. ):
  1255. """Restore an archived spool."""
  1256. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1257. spool = result.scalar_one_or_none()
  1258. if not spool:
  1259. raise HTTPException(404, "Spool not found")
  1260. spool.archived_at = None
  1261. await db.commit()
  1262. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1263. await ws_manager.broadcast({"type": "inventory_changed"})
  1264. return result.scalar_one()
  1265. @router.post("/spools/{spool_id}/reset-consumed-counter", response_model=SpoolResponse)
  1266. async def reset_spool_consumed_counter(
  1267. spool_id: int,
  1268. db: AsyncSession = Depends(get_db),
  1269. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1270. ):
  1271. """Zero the displayed "Total Consumed" counter without touching remaining.
  1272. Stamps `weight_used_baseline = weight_used` so the Inventory page's
  1273. `weight_used - baseline` display reads 0, while `label_weight -
  1274. weight_used` (remaining) is unchanged. weight_locked is also left
  1275. alone — the spool keeps receiving AMS auto-sync updates. Matches
  1276. Spoolman's split between used_weight and remaining_weight (#1390).
  1277. The earlier name `/reset-usage` was misleading: callers reasonably
  1278. expected `weight_used` itself to drop to 0 and were surprised when
  1279. the response showed it unchanged. The current name describes what
  1280. the endpoint actually does — reset the "Total Consumed" counter
  1281. widget, not the lifetime weight_used field.
  1282. """
  1283. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1284. spool = result.scalar_one_or_none()
  1285. if not spool:
  1286. raise HTTPException(404, "Spool not found")
  1287. spool.weight_used_baseline = spool.weight_used or 0
  1288. await db.commit()
  1289. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1290. await ws_manager.broadcast({"type": "inventory_changed"})
  1291. return result.scalar_one()
  1292. @router.post("/spools/reset-consumed-counter-bulk")
  1293. async def bulk_reset_spool_consumed_counter(
  1294. payload: dict,
  1295. db: AsyncSession = Depends(get_db),
  1296. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1297. ):
  1298. """Bulk-stamp baseline = weight_used across the given spool IDs.
  1299. Caller passes an explicit list of IDs — no "reset all" shortcut, since
  1300. a typo on a wildcard would wipe the entire inventory's tracking.
  1301. Same semantics as the per-spool endpoint: remaining is preserved,
  1302. weight_locked is left alone.
  1303. """
  1304. spool_ids = payload.get("spool_ids")
  1305. if not isinstance(spool_ids, list) or not spool_ids:
  1306. raise HTTPException(400, "spool_ids must be a non-empty list")
  1307. if not all(isinstance(sid, int) for sid in spool_ids):
  1308. raise HTTPException(400, "spool_ids must contain integers")
  1309. result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
  1310. spools = list(result.scalars().all())
  1311. for spool in spools:
  1312. spool.weight_used_baseline = spool.weight_used or 0
  1313. await db.commit()
  1314. await ws_manager.broadcast({"type": "inventory_changed"})
  1315. return {"reset": len(spools)}
  1316. class BulkUpdateRequest(BaseModel):
  1317. ids: list[int] = Field(..., min_length=1, max_length=500)
  1318. update: SpoolUpdate
  1319. class BulkIdsRequest(BaseModel):
  1320. ids: list[int] = Field(..., min_length=1, max_length=500)
  1321. @router.post("/spools/bulk-update")
  1322. async def bulk_update_spools(
  1323. payload: BulkUpdateRequest,
  1324. db: AsyncSession = Depends(get_db),
  1325. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1326. ):
  1327. """Apply the same partial update to every listed spool.
  1328. Per-spool errors are collected and returned alongside the success count so
  1329. a single bad ID doesn't abort the whole batch. Unknown IDs are reported
  1330. in the ``not_found`` list.
  1331. """
  1332. update_data = payload.update.model_dump(exclude_unset=True)
  1333. fields_set = set(payload.update.model_fields_set)
  1334. if not update_data:
  1335. raise HTTPException(status_code=400, detail="update must include at least one field")
  1336. try:
  1337. prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
  1338. except ValueError as exc:
  1339. raise HTTPException(status_code=400, detail=str(exc)) from exc
  1340. # Auto-lock weight when the user explicitly sets weight_used — mirrors the
  1341. # per-spool PATCH behaviour so bulk edits don't desync the lock state.
  1342. if "weight_used" in prepared and "weight_locked" not in prepared:
  1343. prepared["weight_locked"] = True
  1344. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1345. spools = {s.id: s for s in result.scalars().all()}
  1346. not_found = [sid for sid in payload.ids if sid not in spools]
  1347. updated_ids: list[int] = []
  1348. for sid, spool in spools.items():
  1349. for field, value in prepared.items():
  1350. setattr(spool, field, value)
  1351. updated_ids.append(sid)
  1352. await db.commit()
  1353. if updated_ids:
  1354. await ws_manager.broadcast({"type": "inventory_changed"})
  1355. return {"updated": len(updated_ids), "not_found": not_found}
  1356. @router.post("/spools/bulk-delete")
  1357. async def bulk_delete_spools(
  1358. payload: BulkIdsRequest,
  1359. db: AsyncSession = Depends(get_db),
  1360. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1361. ):
  1362. """Hard-delete every listed spool. Unknown IDs are returned in not_found."""
  1363. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1364. spools = list(result.scalars().all())
  1365. found_ids = {s.id for s in spools}
  1366. not_found = [sid for sid in payload.ids if sid not in found_ids]
  1367. for spool in spools:
  1368. await db.delete(spool)
  1369. await db.commit()
  1370. if spools:
  1371. await ws_manager.broadcast({"type": "inventory_changed"})
  1372. return {"deleted": len(spools), "not_found": not_found}
  1373. @router.post("/spools/bulk-archive")
  1374. async def bulk_archive_spools(
  1375. payload: BulkIdsRequest,
  1376. db: AsyncSession = Depends(get_db),
  1377. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1378. ):
  1379. """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
  1380. from datetime import datetime, timezone
  1381. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1382. spools = list(result.scalars().all())
  1383. found_ids = {s.id for s in spools}
  1384. not_found = [sid for sid in payload.ids if sid not in found_ids]
  1385. archived: list[int] = []
  1386. already: list[int] = []
  1387. now = datetime.now(timezone.utc)
  1388. for spool in spools:
  1389. if spool.archived_at is not None:
  1390. already.append(spool.id)
  1391. continue
  1392. spool.archived_at = now
  1393. archived.append(spool.id)
  1394. await db.commit()
  1395. if archived:
  1396. await ws_manager.broadcast({"type": "inventory_changed"})
  1397. return {"archived": len(archived), "already_archived": already, "not_found": not_found}
  1398. @router.post("/spools/bulk-restore")
  1399. async def bulk_restore_spools(
  1400. payload: BulkIdsRequest,
  1401. db: AsyncSession = Depends(get_db),
  1402. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1403. ):
  1404. """Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
  1405. result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
  1406. spools = list(result.scalars().all())
  1407. found_ids = {s.id for s in spools}
  1408. not_found = [sid for sid in payload.ids if sid not in found_ids]
  1409. restored: list[int] = []
  1410. already: list[int] = []
  1411. for spool in spools:
  1412. if spool.archived_at is None:
  1413. already.append(spool.id)
  1414. continue
  1415. spool.archived_at = None
  1416. restored.append(spool.id)
  1417. await db.commit()
  1418. if restored:
  1419. await ws_manager.broadcast({"type": "inventory_changed"})
  1420. return {"restored": len(restored), "already_active": already, "not_found": not_found}
  1421. # ── K-Profiles ───────────────────────────────────────────────────────────────
  1422. @router.get("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  1423. async def list_k_profiles(
  1424. spool_id: int,
  1425. db: AsyncSession = Depends(get_db),
  1426. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1427. ):
  1428. """List K-profiles for a spool."""
  1429. result = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1430. return list(result.scalars().all())
  1431. @router.put("/spools/{spool_id}/k-profiles", response_model=list[SpoolKProfileResponse])
  1432. async def replace_k_profiles(
  1433. spool_id: int,
  1434. profiles: list[SpoolKProfileBase],
  1435. db: AsyncSession = Depends(get_db),
  1436. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1437. ):
  1438. """Replace all K-profiles for a spool (batch save)."""
  1439. # Verify spool exists
  1440. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1441. if not result.scalar_one_or_none():
  1442. raise HTTPException(404, "Spool not found")
  1443. # Delete existing
  1444. existing = await db.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
  1445. for old in existing.scalars().all():
  1446. await db.delete(old)
  1447. # Create new
  1448. new_profiles = []
  1449. for p in profiles:
  1450. kp = SpoolKProfile(spool_id=spool_id, **p.model_dump())
  1451. db.add(kp)
  1452. new_profiles.append(kp)
  1453. await db.commit()
  1454. for kp in new_profiles:
  1455. await db.refresh(kp)
  1456. return new_profiles
  1457. @router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
  1458. async def list_filament_presets(
  1459. spool_id: int,
  1460. db: AsyncSession = Depends(get_db),
  1461. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1462. ):
  1463. """List per-printer-model preset overrides for a spool.
  1464. A dedicated endpoint rather than a field on ``SpoolResponse``: the
  1465. inventory list returns every spool the user owns, and only the spool form
  1466. and the assign path ever need this list, one spool at a time.
  1467. """
  1468. result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
  1469. return list(result.scalars().all())
  1470. @router.put("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
  1471. async def replace_filament_presets(
  1472. spool_id: int,
  1473. presets: list[SpoolFilamentPresetBase],
  1474. db: AsyncSession = Depends(get_db),
  1475. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1476. ):
  1477. """Replace all per-printer-model preset overrides for a spool.
  1478. Replace rather than merge, matching the K-profile endpoint next door: the
  1479. spool form always holds the complete set, and an empty list is how the
  1480. user clears every override back to the spool's own preset.
  1481. """
  1482. result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1483. if not result.scalar_one_or_none():
  1484. raise HTTPException(404, "Spool not found")
  1485. # (model, diameter) is UNIQUE, so a payload that names one twice would
  1486. # fail on flush with an IntegrityError the client cannot act on. Reject it
  1487. # by name instead -- and reject it BEFORE deleting the existing rows, so a
  1488. # bad request cannot wipe overrides it then fails to replace.
  1489. seen: set[tuple[str, str]] = set()
  1490. for p in presets:
  1491. key = (p.printer_model, p.nozzle_diameter)
  1492. if key in seen:
  1493. raise HTTPException(
  1494. 422,
  1495. f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}",
  1496. )
  1497. seen.add(key)
  1498. existing = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
  1499. for old in existing.scalars().all():
  1500. await db.delete(old)
  1501. # Land the deletes before the inserts: within one transaction SQLAlchemy is
  1502. # free to order the INSERTs first, which trips the UNIQUE constraint
  1503. # against rows this call is about to remove.
  1504. await db.flush()
  1505. new_presets = []
  1506. for p in presets:
  1507. row = SpoolFilamentPreset(spool_id=spool_id, **p.model_dump())
  1508. db.add(row)
  1509. new_presets.append(row)
  1510. await db.commit()
  1511. for row in new_presets:
  1512. await db.refresh(row)
  1513. return new_presets
  1514. # ── Spool Assignments ────────────────────────────────────────────────────────
  1515. @router.get("/assignments", response_model=list[SpoolAssignmentResponse])
  1516. async def list_assignments(
  1517. printer_id: int | None = None,
  1518. db: AsyncSession = Depends(get_db),
  1519. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
  1520. ):
  1521. """List spool assignments, optionally filtered by printer."""
  1522. from backend.app.services.printer_manager import printer_manager
  1523. query = select(SpoolAssignment).options(
  1524. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1525. selectinload(SpoolAssignment.printer),
  1526. )
  1527. if printer_id is not None:
  1528. query = query.where(SpoolAssignment.printer_id == printer_id)
  1529. result = await db.execute(query)
  1530. assignments = list(result.scalars().all())
  1531. # Build (printer_id, ams_id) -> ams_serial map from live printer states.
  1532. # Fetch all statuses in one call rather than one get_status() call per printer.
  1533. serial_map: dict[tuple[int, int], str] = {}
  1534. seen_printer_ids: set[int] = {a.printer_id for a in assignments}
  1535. all_statuses = printer_manager.get_all_statuses()
  1536. for pid in seen_printer_ids:
  1537. state = all_statuses.get(pid)
  1538. if state and state.raw_data:
  1539. for ams_unit in state.raw_data.get("ams", []):
  1540. sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
  1541. if sn:
  1542. try:
  1543. serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
  1544. except (ValueError, TypeError):
  1545. continue
  1546. # Fetch all relevant AMS labels keyed by serial number
  1547. all_serials = set(serial_map.values())
  1548. # Also include synthetic fallback keys for assignments without a known serial
  1549. synthetic_keys: dict[str, tuple[int, int]] = {}
  1550. for a in assignments:
  1551. if (a.printer_id, a.ams_id) not in serial_map:
  1552. synthetic = f"p{a.printer_id}a{a.ams_id}"
  1553. synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
  1554. all_serials.add(synthetic)
  1555. label_by_serial: dict[str, str] = {}
  1556. if all_serials:
  1557. lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
  1558. for lbl in lbl_result.scalars().all():
  1559. label_by_serial[lbl.ams_serial_number] = lbl.label
  1560. # Build response objects, attaching ams_label where available
  1561. responses: list[SpoolAssignmentResponse] = []
  1562. for a in assignments:
  1563. resp = SpoolAssignmentResponse.model_validate(a)
  1564. sn = serial_map.get((a.printer_id, a.ams_id))
  1565. if sn and sn in label_by_serial:
  1566. resp.ams_label = label_by_serial[sn]
  1567. elif not sn:
  1568. synthetic = f"p{a.printer_id}a{a.ams_id}"
  1569. resp.ams_label = label_by_serial.get(synthetic)
  1570. responses.append(resp)
  1571. return responses
  1572. @router.post("/assignments", response_model=SpoolAssignmentResponse)
  1573. async def assign_spool(
  1574. data: SpoolAssignmentCreate,
  1575. db: AsyncSession = Depends(get_db),
  1576. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1577. ):
  1578. """Assign a spool to an AMS slot and auto-configure via MQTT."""
  1579. from backend.app.services.printer_manager import printer_manager
  1580. # 1. Validate spool exists and is not archived
  1581. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == data.spool_id))
  1582. spool = result.scalar_one_or_none()
  1583. if not spool:
  1584. raise HTTPException(404, "Spool not found")
  1585. if spool.archived_at:
  1586. raise HTTPException(400, "Cannot assign an archived spool")
  1587. # 2. Get current AMS tray state for fingerprint + existing filament ID.
  1588. # tray_state: Bambu firmware reports 11=loaded, 9=empty, 10=spool present
  1589. # but filament not in feeder. Captured here so the empty-slot heuristic
  1590. # below can prefer it over tray_type — a manual "Reset slot" clears
  1591. # tray_type to "" while leaving state at 11 (filament still physically
  1592. # present), which would otherwise mislead the heuristic into the
  1593. # pending-config branch and skip MQTT forever (#1228 follow-up).
  1594. fingerprint_color = None
  1595. fingerprint_type = None
  1596. current_tray_info_idx = ""
  1597. tray_state: int | None = None
  1598. state = printer_manager.get_status(data.printer_id)
  1599. if state and state.raw_data:
  1600. if data.ams_id == 255:
  1601. # External slot: look up tray from vt_tray by global ID
  1602. vt_tray = state.raw_data.get("vt_tray") or []
  1603. ext_id = data.tray_id + 254 # 0→254, 1→255
  1604. for vt in vt_tray:
  1605. if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
  1606. fingerprint_color = vt.get("tray_color", "")
  1607. fingerprint_type = vt.get("tray_type", "")
  1608. current_tray_info_idx = vt.get("tray_info_idx", "")
  1609. raw_state = vt.get("state")
  1610. if isinstance(raw_state, int):
  1611. tray_state = raw_state
  1612. break
  1613. else:
  1614. ams_data = state.raw_data.get("ams", {})
  1615. ams_list = (
  1616. ams_data.get("ams", [])
  1617. if isinstance(ams_data, dict)
  1618. else ams_data
  1619. if isinstance(ams_data, list)
  1620. else []
  1621. )
  1622. tray = _find_tray_in_ams_data(
  1623. ams_list,
  1624. data.ams_id,
  1625. data.tray_id,
  1626. )
  1627. if tray:
  1628. fingerprint_color = tray.get("tray_color", "")
  1629. fingerprint_type = tray.get("tray_type", "")
  1630. current_tray_info_idx = tray.get("tray_info_idx", "")
  1631. raw_state = tray.get("state")
  1632. if isinstance(raw_state, int):
  1633. tray_state = raw_state
  1634. # 3. Upsert assignment (replace if same printer+ams+tray)
  1635. existing = await db.execute(
  1636. select(SpoolAssignment).where(
  1637. SpoolAssignment.printer_id == data.printer_id,
  1638. SpoolAssignment.ams_id == data.ams_id,
  1639. SpoolAssignment.tray_id == data.tray_id,
  1640. )
  1641. )
  1642. old = existing.scalar_one_or_none()
  1643. if old:
  1644. await db.delete(old)
  1645. await db.flush()
  1646. assignment = SpoolAssignment(
  1647. spool_id=data.spool_id,
  1648. printer_id=data.printer_id,
  1649. ams_id=data.ams_id,
  1650. tray_id=data.tray_id,
  1651. fingerprint_color=fingerprint_color,
  1652. fingerprint_type=fingerprint_type,
  1653. )
  1654. db.add(assignment)
  1655. await db.commit()
  1656. await db.refresh(assignment)
  1657. # 4. Auto-configure AMS slot via MQTT.
  1658. #
  1659. # Only suppress the publish when the firmware's *explicit* empty signal
  1660. # (state ∈ {9, 10}) is set — "no spool" / "spool present but no feed".
  1661. # Every other state, including state=3 (the default idle on A1 Mini BMCU /
  1662. # P1S Standard AMS for both loaded and unconfigured slots) and missing
  1663. # state (older firmwares), is treated as the user's assertion that a
  1664. # spool is in the slot and we attempt the MQTT push.
  1665. #
  1666. # The pre-existing "skip when slot looks empty" guard read state=3 +
  1667. # tray_type="" as "empty" and skipped MQTT. On these firmwares that
  1668. # combination is the post-"Reset Slot" state with the spool still
  1669. # physically inserted — there is NO AMS signal that distinguishes it
  1670. # from a truly-empty slot, so the guard created a deadlock: MQTT never
  1671. # fired, the AMS never reported any change (because nothing changed
  1672. # physically), and on_ams_change replay therefore never re-fired the
  1673. # config either. Reporter (#1322 follow-up by @RosdasHH) verified
  1674. # empirically that removing the guard makes the slot configure
  1675. # correctly because Bambu firmware DOES accept the push for a
  1676. # physically-loaded slot, even when tray_type is "" and state is 3.
  1677. #
  1678. # Trade-off for the truly-empty slot case: firmware drops the push
  1679. # silently (per Bambu's documented behavior), the SpoolAssignment row
  1680. # still has empty fingerprint_type because nothing in the assign path
  1681. # updates that column, and on_ams_change at main.py:1031-1054 still
  1682. # fires the deferred config when a spool eventually appears. So the
  1683. # SpoolBuddy weigh-then-assign-before-insert workflow continues to
  1684. # work — just without the optimization of skipping a no-op MQTT call.
  1685. #
  1686. # state ∈ {9, 10} stays as an explicit short-circuit so we don't churn
  1687. # a doomed MQTT push when the firmware has positively confirmed "no
  1688. # spool" — and to keep the on_ams_change replay path as the single
  1689. # source of truth for those slots.
  1690. slot_is_definitely_empty = tray_state == 9 or tray_state == 10
  1691. configured = False
  1692. if not slot_is_definitely_empty:
  1693. try:
  1694. configured = await apply_spool_to_slot_via_mqtt(
  1695. db=db,
  1696. current_user=current_user,
  1697. spool=spool,
  1698. printer_id=data.printer_id,
  1699. ams_id=data.ams_id,
  1700. tray_id=data.tray_id,
  1701. current_tray_info_idx=current_tray_info_idx,
  1702. current_tray_type=fingerprint_type or "",
  1703. )
  1704. except Exception as e:
  1705. logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
  1706. else:
  1707. # Nudge a fresh pushall so the read-back verification registered in
  1708. # apply_spool_to_slot_via_mqtt (#2582) has current tray telemetry to
  1709. # compare against within its window, instead of waiting for the next
  1710. # idle push. Best-effort — the periodic push is the fallback.
  1711. if configured:
  1712. try:
  1713. client = printer_manager.get_client(data.printer_id)
  1714. if client:
  1715. client.request_status_update()
  1716. except Exception:
  1717. pass
  1718. # pending_config is the "config not landed yet" UI marker. True when the
  1719. # firmware said empty, OR when MQTT couldn't actually publish (printer
  1720. # offline, no client, transient failure). on_ams_change replay re-fires
  1721. # the config in either case once the AMS reports a non-empty fingerprint.
  1722. pending_config = slot_is_definitely_empty or not configured
  1723. # Return assignment with spool data
  1724. result = await db.execute(
  1725. select(SpoolAssignment)
  1726. .options(
  1727. selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
  1728. selectinload(SpoolAssignment.printer),
  1729. )
  1730. .where(SpoolAssignment.id == assignment.id)
  1731. )
  1732. resp = result.scalar_one()
  1733. response = SpoolAssignmentResponse.model_validate(resp)
  1734. response.configured = configured
  1735. response.pending_config = pending_config
  1736. if pending_config:
  1737. logger.info(
  1738. "Pre-configured assignment: spool %d → printer %d AMS%d-T%d (slot empty, will configure on insert)",
  1739. spool.id,
  1740. data.printer_id,
  1741. data.ams_id,
  1742. data.tray_id,
  1743. )
  1744. await ws_manager.broadcast(
  1745. {
  1746. "type": "spool_assignment_changed",
  1747. "printer_id": data.printer_id,
  1748. "ams_id": data.ams_id,
  1749. "tray_id": data.tray_id,
  1750. }
  1751. )
  1752. return response
  1753. @router.delete("/assignments/{printer_id}/{ams_id}/{tray_id}")
  1754. async def unassign_spool(
  1755. printer_id: int,
  1756. ams_id: int,
  1757. tray_id: int,
  1758. db: AsyncSession = Depends(get_db),
  1759. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1760. ):
  1761. """Unassign a spool from an AMS slot."""
  1762. result = await db.execute(
  1763. select(SpoolAssignment).where(
  1764. SpoolAssignment.printer_id == printer_id,
  1765. SpoolAssignment.ams_id == ams_id,
  1766. SpoolAssignment.tray_id == tray_id,
  1767. )
  1768. )
  1769. assignment = result.scalar_one_or_none()
  1770. if not assignment:
  1771. raise HTTPException(404, "Assignment not found")
  1772. await db.delete(assignment)
  1773. await db.commit()
  1774. await ws_manager.broadcast(
  1775. {
  1776. "type": "spool_assignment_changed",
  1777. "printer_id": printer_id,
  1778. "ams_id": ams_id,
  1779. "tray_id": tray_id,
  1780. }
  1781. )
  1782. return {"status": "deleted"}
  1783. # ── Tag Linking ───────────────────────────────────────────────────────────────
  1784. class LinkTagRequest(BaseModel):
  1785. tag_uid: str | None = None
  1786. tray_uuid: str | None = None
  1787. tag_type: str | None = None
  1788. data_origin: str | None = "nfc_link"
  1789. def _validate_tag_input(
  1790. raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
  1791. ) -> None:
  1792. if raw_value is None:
  1793. return
  1794. raw = str(raw_value).strip()
  1795. if not raw:
  1796. return
  1797. if normalized_value is None:
  1798. raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
  1799. if len(normalized_value) % 2 != 0:
  1800. raise HTTPException(422, f"{field_name} must have an even number of hex characters")
  1801. if exact_len is not None and len(normalized_value) != exact_len:
  1802. raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
  1803. @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
  1804. async def link_tag_to_spool(
  1805. spool_id: int,
  1806. data: LinkTagRequest,
  1807. db: AsyncSession = Depends(get_db),
  1808. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1809. ):
  1810. """Link an RFID tag_uid/tray_uuid to an existing spool."""
  1811. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1812. spool = result.scalar_one_or_none()
  1813. if not spool:
  1814. raise HTTPException(404, "Spool not found")
  1815. if spool.archived_at:
  1816. raise HTTPException(400, "Cannot link tag to archived spool")
  1817. normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
  1818. normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
  1819. _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
  1820. _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
  1821. # Check for conflicts: tag already linked to another active spool
  1822. if normalized_tag_uid:
  1823. conflict = await db.execute(
  1824. select(Spool).where(
  1825. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1826. Spool.id != spool_id,
  1827. Spool.archived_at.is_(None),
  1828. )
  1829. )
  1830. if conflict.scalar_one_or_none():
  1831. raise HTTPException(409, "Tag UID already linked to another active spool")
  1832. # Auto-clear from archived spools (tag recycling)
  1833. archived_with_tag = await db.execute(
  1834. select(Spool).where(
  1835. func.upper(Spool.tag_uid) == normalized_tag_uid,
  1836. Spool.id != spool_id,
  1837. Spool.archived_at.is_not(None),
  1838. )
  1839. )
  1840. for old_spool in archived_with_tag.scalars().all():
  1841. old_spool.tag_uid = None
  1842. if normalized_tray_uuid:
  1843. conflict = await db.execute(
  1844. select(Spool).where(
  1845. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1846. Spool.id != spool_id,
  1847. Spool.archived_at.is_(None),
  1848. )
  1849. )
  1850. if conflict.scalar_one_or_none():
  1851. raise HTTPException(409, "Tray UUID already linked to another active spool")
  1852. archived_with_uuid = await db.execute(
  1853. select(Spool).where(
  1854. func.upper(Spool.tray_uuid) == normalized_tray_uuid,
  1855. Spool.id != spool_id,
  1856. Spool.archived_at.is_not(None),
  1857. )
  1858. )
  1859. for old_spool in archived_with_uuid.scalars().all():
  1860. old_spool.tray_uuid = None
  1861. if data.tag_uid is not None:
  1862. spool.tag_uid = normalized_tag_uid
  1863. if data.tray_uuid is not None:
  1864. spool.tray_uuid = normalized_tray_uuid
  1865. if data.tag_type is not None:
  1866. spool.tag_type = data.tag_type
  1867. if data.data_origin is not None:
  1868. spool.data_origin = data.data_origin
  1869. await db.commit()
  1870. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
  1871. return result.scalar_one()
  1872. # ── Usage History ─────────────────────────────────────────────────────────────
  1873. @router.get("/spools/{spool_id}/usage", response_model=list[SpoolUsageHistoryResponse])
  1874. async def get_spool_usage_history(
  1875. spool_id: int,
  1876. limit: int = 50,
  1877. db: AsyncSession = Depends(get_db),
  1878. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1879. ):
  1880. """Get usage history for a specific spool."""
  1881. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1882. # Verify spool exists
  1883. spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
  1884. if not spool_result.scalar_one_or_none():
  1885. raise HTTPException(404, "Spool not found")
  1886. result = await db.execute(
  1887. select(SpoolUsageHistory)
  1888. .where(SpoolUsageHistory.spool_id == spool_id)
  1889. .order_by(SpoolUsageHistory.created_at.desc())
  1890. .limit(limit)
  1891. )
  1892. return list(result.scalars().all())
  1893. @router.get("/usage", response_model=list[SpoolUsageHistoryResponse])
  1894. async def get_all_usage_history(
  1895. limit: int = 100,
  1896. printer_id: int | None = None,
  1897. db: AsyncSession = Depends(get_db),
  1898. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1899. ):
  1900. """Get global usage history, optionally filtered by printer."""
  1901. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1902. query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
  1903. if printer_id is not None:
  1904. query = query.where(SpoolUsageHistory.printer_id == printer_id)
  1905. result = await db.execute(query)
  1906. return list(result.scalars().all())
  1907. @router.delete("/spools/{spool_id}/usage")
  1908. async def clear_spool_usage_history(
  1909. spool_id: int,
  1910. db: AsyncSession = Depends(get_db),
  1911. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1912. ):
  1913. """Clear usage history for a spool."""
  1914. from backend.app.models.spool_usage_history import SpoolUsageHistory
  1915. result = await db.execute(select(SpoolUsageHistory).where(SpoolUsageHistory.spool_id == spool_id))
  1916. for row in result.scalars().all():
  1917. await db.delete(row)
  1918. await db.commit()
  1919. return {"status": "cleared"}
  1920. # ── AMS Weight Sync ──────────────────────────────────────────────────────────
  1921. @router.post("/sync-ams-weights")
  1922. async def sync_weights_from_ams(
  1923. db: AsyncSession = Depends(get_db),
  1924. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1925. ):
  1926. """Force-sync spool weight_used from live AMS remain% data.
  1927. Overwrites the database weight_used for every assigned spool using the
  1928. current AMS remain% from connected printers. This is a manual recovery
  1929. tool — it bypasses the normal "only increase" guard.
  1930. """
  1931. from backend.app.services.printer_manager import printer_manager
  1932. result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  1933. assignments = list(result.scalars().all())
  1934. logger.info("AMS weight sync: found %d assignments", len(assignments))
  1935. synced = 0
  1936. skipped = 0
  1937. for assignment in assignments:
  1938. spool = assignment.spool
  1939. if not spool:
  1940. logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
  1941. skipped += 1
  1942. continue
  1943. if spool.weight_locked:
  1944. logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
  1945. skipped += 1
  1946. continue
  1947. state = printer_manager.get_status(assignment.printer_id)
  1948. if not state or not state.raw_data:
  1949. logger.info(
  1950. "AMS weight sync: printer %d not connected, skipping spool %d",
  1951. assignment.printer_id,
  1952. spool.id,
  1953. )
  1954. skipped += 1
  1955. continue
  1956. ams_raw = state.raw_data.get("ams", [])
  1957. if isinstance(ams_raw, dict):
  1958. ams_raw = ams_raw.get("ams", [])
  1959. tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
  1960. if not tray:
  1961. logger.info(
  1962. "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
  1963. spool.id,
  1964. assignment.printer_id,
  1965. assignment.ams_id,
  1966. assignment.tray_id,
  1967. )
  1968. skipped += 1
  1969. continue
  1970. remain_raw = tray.get("remain")
  1971. if remain_raw is None:
  1972. logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
  1973. skipped += 1
  1974. continue
  1975. try:
  1976. remain_val = int(remain_raw)
  1977. except (TypeError, ValueError):
  1978. skipped += 1
  1979. continue
  1980. if remain_val < 0 or remain_val > 100:
  1981. logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
  1982. skipped += 1
  1983. continue
  1984. lw = spool.label_weight or 1000
  1985. new_used = round(lw * (100 - remain_val) / 100.0, 1)
  1986. old_used = spool.weight_used or 0
  1987. if round(old_used, 1) != new_used:
  1988. logger.info(
  1989. "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
  1990. spool.id,
  1991. old_used,
  1992. new_used,
  1993. remain_val,
  1994. )
  1995. spool.weight_used = new_used
  1996. synced += 1
  1997. else:
  1998. skipped += 1
  1999. await db.commit()
  2000. return {"synced": synced, "skipped": skipped}
  2001. # ── Helpers ──────────────────────────────────────────────────────────────────
  2002. def _find_tray_in_ams_data(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
  2003. """Find a specific tray in the AMS data structure."""
  2004. if not ams_data:
  2005. return None
  2006. for ams_unit in ams_data:
  2007. if int(ams_unit.get("id", -1)) != ams_id:
  2008. continue
  2009. for tray in ams_unit.get("tray", []):
  2010. if int(tray.get("id", -1)) == tray_id:
  2011. return tray
  2012. return None
  2013. # ── Filament SKU Settings (reorder forecasting) ───────────────────────────────
  2014. class FilamentSkuSettingsResponse(BaseModel):
  2015. id: int
  2016. material: str
  2017. subtype: str | None
  2018. brand: str | None
  2019. color_name: str | None
  2020. lead_time_days: int
  2021. safety_margin_value: int
  2022. safety_margin_unit: str
  2023. alerts_snoozed: bool = False
  2024. class Config:
  2025. from_attributes = True
  2026. class FilamentSkuSettingsUpsert(BaseModel):
  2027. material: str
  2028. subtype: str | None = None
  2029. brand: str | None = None
  2030. color_name: str | None = None
  2031. lead_time_days: int = 0
  2032. safety_margin_value: int = 14
  2033. safety_margin_unit: str = "days"
  2034. alerts_snoozed: bool = False
  2035. @router.get("/sku-settings", response_model=list[FilamentSkuSettingsResponse])
  2036. async def list_sku_settings(
  2037. db: AsyncSession = Depends(get_db),
  2038. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  2039. ):
  2040. """List all filament SKU reorder settings."""
  2041. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  2042. result = await db.execute(
  2043. select(FilamentSkuSettings).order_by(FilamentSkuSettings.material, FilamentSkuSettings.brand)
  2044. )
  2045. return list(result.scalars().all())
  2046. @router.post("/sku-settings", response_model=FilamentSkuSettingsResponse)
  2047. async def upsert_sku_settings(
  2048. data: FilamentSkuSettingsUpsert,
  2049. db: AsyncSession = Depends(get_db),
  2050. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2051. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2052. ),
  2053. ):
  2054. """Create or update reorder settings for a filament SKU (material/subtype/brand)."""
  2055. from backend.app.models.filament_sku_settings import FilamentSkuSettings
  2056. result = await db.execute(
  2057. select(FilamentSkuSettings).where(
  2058. FilamentSkuSettings.material == data.material,
  2059. FilamentSkuSettings.subtype == data.subtype,
  2060. FilamentSkuSettings.brand == data.brand,
  2061. FilamentSkuSettings.color_name == data.color_name,
  2062. )
  2063. )
  2064. row = result.scalar_one_or_none()
  2065. if row:
  2066. row.lead_time_days = data.lead_time_days
  2067. row.safety_margin_value = data.safety_margin_value
  2068. row.safety_margin_unit = data.safety_margin_unit
  2069. row.alerts_snoozed = data.alerts_snoozed
  2070. else:
  2071. row = FilamentSkuSettings(
  2072. material=data.material,
  2073. subtype=data.subtype,
  2074. brand=data.brand,
  2075. color_name=data.color_name,
  2076. lead_time_days=data.lead_time_days,
  2077. safety_margin_value=data.safety_margin_value,
  2078. safety_margin_unit=data.safety_margin_unit,
  2079. alerts_snoozed=data.alerts_snoozed,
  2080. )
  2081. db.add(row)
  2082. await db.commit()
  2083. await db.refresh(row)
  2084. return row
  2085. # ── Shopping List ─────────────────────────────────────────────────────────────
  2086. class ShoppingListItemResponse(BaseModel):
  2087. id: int
  2088. material: str
  2089. subtype: str | None
  2090. brand: str | None
  2091. color_name: str | None
  2092. quantity_spools: int
  2093. note: str | None
  2094. status: str
  2095. purchased_at: str | None
  2096. added_at: str
  2097. class Config:
  2098. from_attributes = True
  2099. class ShoppingListItemCreate(BaseModel):
  2100. material: str
  2101. subtype: str | None = None
  2102. brand: str | None = None
  2103. color_name: str | None = None
  2104. quantity_spools: int = 1
  2105. note: str | None = None
  2106. class ShoppingListItemStatusUpdate(BaseModel):
  2107. status: str # pending | purchased | received
  2108. @router.get("/shopping-list", response_model=list[ShoppingListItemResponse])
  2109. async def get_shopping_list(
  2110. db: AsyncSession = Depends(get_db),
  2111. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_FORECAST_READ),
  2112. ):
  2113. """Get the filament shopping list."""
  2114. from backend.app.models.shopping_list import ShoppingListItem
  2115. result = await db.execute(select(ShoppingListItem).order_by(ShoppingListItem.added_at.desc()))
  2116. items = result.scalars().all()
  2117. return [
  2118. ShoppingListItemResponse(
  2119. id=i.id,
  2120. material=i.material,
  2121. subtype=i.subtype,
  2122. brand=i.brand,
  2123. color_name=i.color_name,
  2124. quantity_spools=i.quantity_spools,
  2125. note=i.note,
  2126. status=i.status or "pending",
  2127. purchased_at=i.purchased_at.isoformat() if i.purchased_at else None,
  2128. added_at=i.added_at.isoformat() if i.added_at else "",
  2129. )
  2130. for i in items
  2131. ]
  2132. @router.post("/shopping-list", response_model=ShoppingListItemResponse)
  2133. async def add_to_shopping_list(
  2134. data: ShoppingListItemCreate,
  2135. db: AsyncSession = Depends(get_db),
  2136. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2137. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2138. ),
  2139. ):
  2140. """Add a filament SKU to the shopping list."""
  2141. from backend.app.models.shopping_list import ShoppingListItem
  2142. item = ShoppingListItem(
  2143. material=data.material,
  2144. subtype=data.subtype,
  2145. brand=data.brand,
  2146. color_name=data.color_name,
  2147. quantity_spools=data.quantity_spools,
  2148. note=data.note,
  2149. )
  2150. db.add(item)
  2151. await db.commit()
  2152. await db.refresh(item)
  2153. return ShoppingListItemResponse(
  2154. id=item.id,
  2155. material=item.material,
  2156. subtype=item.subtype,
  2157. brand=item.brand,
  2158. color_name=item.color_name,
  2159. quantity_spools=item.quantity_spools,
  2160. note=item.note,
  2161. status=item.status or "pending",
  2162. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  2163. added_at=item.added_at.isoformat() if item.added_at else "",
  2164. )
  2165. @router.patch("/shopping-list/{item_id}/status", response_model=ShoppingListItemResponse)
  2166. async def update_shopping_list_status(
  2167. item_id: int,
  2168. data: ShoppingListItemStatusUpdate,
  2169. db: AsyncSession = Depends(get_db),
  2170. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2171. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2172. ),
  2173. ):
  2174. """Update the purchase status of a shopping list item."""
  2175. from datetime import datetime, timezone
  2176. from backend.app.models.shopping_list import ShoppingListItem
  2177. if data.status not in ("pending", "purchased", "received"):
  2178. raise HTTPException(400, "Invalid status")
  2179. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  2180. item = result.scalar_one_or_none()
  2181. if not item:
  2182. raise HTTPException(404, "Item not found")
  2183. item.status = data.status
  2184. if data.status in ("purchased", "received") and item.purchased_at is None:
  2185. item.purchased_at = datetime.now(timezone.utc)
  2186. elif data.status == "pending":
  2187. item.purchased_at = None
  2188. await db.commit()
  2189. await db.refresh(item)
  2190. return ShoppingListItemResponse(
  2191. id=item.id,
  2192. material=item.material,
  2193. subtype=item.subtype,
  2194. brand=item.brand,
  2195. color_name=item.color_name,
  2196. quantity_spools=item.quantity_spools,
  2197. note=item.note,
  2198. status=item.status or "pending",
  2199. purchased_at=item.purchased_at.isoformat() if item.purchased_at else None,
  2200. added_at=item.added_at.isoformat() if item.added_at else "",
  2201. )
  2202. @router.delete("/shopping-list/{item_id}")
  2203. async def remove_from_shopping_list(
  2204. item_id: int,
  2205. db: AsyncSession = Depends(get_db),
  2206. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2207. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2208. ),
  2209. ):
  2210. """Remove a single item from the shopping list."""
  2211. from backend.app.models.shopping_list import ShoppingListItem
  2212. result = await db.execute(select(ShoppingListItem).where(ShoppingListItem.id == item_id))
  2213. item = result.scalar_one_or_none()
  2214. if not item:
  2215. raise HTTPException(404, "Item not found")
  2216. await db.delete(item)
  2217. await db.commit()
  2218. return {"status": "deleted"}
  2219. @router.delete("/shopping-list")
  2220. async def clear_shopping_list(
  2221. db: AsyncSession = Depends(get_db),
  2222. _: User | None = RequireAnyPermissionIfAuthEnabled(
  2223. Permission.INVENTORY_FORECAST_WRITE, Permission.INVENTORY_UPDATE
  2224. ),
  2225. ):
  2226. """Clear all items from the shopping list."""
  2227. from backend.app.models.shopping_list import ShoppingListItem
  2228. result = await db.execute(delete(ShoppingListItem).returning(ShoppingListItem.id))
  2229. deleted = len(result.fetchall())
  2230. await db.commit()
  2231. return {"deleted": deleted}
  2232. class CreateSpoolFromSlotRequest(BaseModel):
  2233. printer_id: int
  2234. ams_id: int
  2235. tray_id: int
  2236. @router.post("/spools/from-slot", response_model=SpoolResponse)
  2237. async def create_spool_from_slot(
  2238. req: CreateSpoolFromSlotRequest,
  2239. db: AsyncSession = Depends(get_db),
  2240. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  2241. ):
  2242. """Explicit user action: create an inventory spool from an AMS slot's current tray data.
  2243. Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
  2244. the user looked at the slot and chose to register it. Also assigns the new spool
  2245. to the slot in the same call.
  2246. """
  2247. from backend.app.services.printer_manager import printer_manager
  2248. from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
  2249. state = printer_manager.get_status(req.printer_id)
  2250. if not state or not state.raw_data:
  2251. raise HTTPException(status_code=404, detail="Printer not connected or no state available")
  2252. ams_data = state.raw_data.get("ams")
  2253. ams_units: list[dict] = []
  2254. if isinstance(ams_data, list):
  2255. ams_units = ams_data
  2256. elif isinstance(ams_data, dict):
  2257. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  2258. ams_units = ams_data["ams"]
  2259. elif "tray" in ams_data:
  2260. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  2261. tray: dict | None = None
  2262. for unit in ams_units:
  2263. if not isinstance(unit, dict):
  2264. continue
  2265. if int(unit.get("id", -1)) != req.ams_id:
  2266. continue
  2267. for t in unit.get("tray", []):
  2268. if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
  2269. tray = t
  2270. break
  2271. if tray:
  2272. break
  2273. if not tray or not tray.get("tray_type"):
  2274. raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
  2275. # Guard against ghost-spool creation: a slot without any RFID tag has no
  2276. # stable identity, so creating an inventory row would just duplicate on
  2277. # every confirm and never re-link to the physical spool.
  2278. from backend.app.services.spool_tag_matcher import is_valid_tag
  2279. if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
  2280. raise HTTPException(status_code=400, detail="Slot has no RFID tag")
  2281. spool = await create_spool_from_tray(db, tray)
  2282. await auto_assign_spool(
  2283. req.printer_id,
  2284. req.ams_id,
  2285. req.tray_id,
  2286. spool,
  2287. printer_manager,
  2288. db,
  2289. tray_info_idx=tray.get("tray_info_idx", ""),
  2290. )
  2291. await db.commit()
  2292. await ws_manager.broadcast({"type": "inventory_changed"})
  2293. await ws_manager.broadcast(
  2294. {
  2295. "type": "spool_auto_assigned",
  2296. "printer_id": req.printer_id,
  2297. "ams_id": req.ams_id,
  2298. "tray_id": req.tray_id,
  2299. "spool_id": spool.id,
  2300. }
  2301. )
  2302. result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
  2303. return result.scalar_one()