location_service.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. """Storage location catalog — single write path for spool location fields (#1004)."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. import time
  6. from dataclasses import dataclass
  7. import httpx
  8. from sqlalchemy import func, select, update
  9. from sqlalchemy.exc import IntegrityError
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.models.location import Location
  12. from backend.app.models.spool import Spool
  13. logger = logging.getLogger(__name__)
  14. DUPLICATE_LOCATION_NAME = "A location with this name already exists"
  15. # AMS residency markers, not storage locations. Bambuddy used to write the slot
  16. # a spool was loaded into -- "<printer> - AMS A1", the shape
  17. # `SpoolmanClient.convert_ams_slot_to_location` still produces -- straight into
  18. # Spoolman's `location` field. That writer went away when Storage Location
  19. # became a place the user chooses (#1114), but the strings survive on people's
  20. # Spoolman spools, and importing them offers a printer slot as somewhere to put
  21. # a spool away. A slot is where a spool is loaded, not where it is stored, and
  22. # Bambuddy tracks that separately through slot assignments.
  23. _AMS_SLOT_LOCATION_RE = re.compile(
  24. r"^(?:.+\s-\s)?(?:AMS[- ]HT [A-Z]\d+|AMS [A-Z]\d+|External Spool)$",
  25. re.IGNORECASE,
  26. )
  27. def is_ams_slot_location(name: str) -> bool:
  28. """True when a location string names a printer slot rather than a storage place."""
  29. return bool(_AMS_SLOT_LOCATION_RE.match(name.strip()))
  30. def normalize_location_name(name: str) -> str:
  31. trimmed = name.strip()
  32. if not trimmed:
  33. raise ValueError("name must not be empty")
  34. return trimmed
  35. def location_name_key(name: str) -> str:
  36. """Case-insensitive lookup key stored on Location.name_key."""
  37. return normalize_location_name(name).lower()
  38. def assign_location_name(location: Location, name: str) -> None:
  39. normalized = normalize_location_name(name)
  40. location.name = normalized
  41. location.name_key = location_name_key(normalized)
  42. @dataclass(frozen=True)
  43. class SpoolLocationFields:
  44. """Canonical spool location state: FK + denormalized string for Spoolman/display."""
  45. location_id: int | None
  46. storage_location: str | None
  47. async def get_location_by_id(db: AsyncSession, location_id: int) -> Location | None:
  48. result = await db.execute(select(Location).where(Location.id == location_id))
  49. return result.scalar_one_or_none()
  50. async def get_location_by_name(db: AsyncSession, name: str) -> Location | None:
  51. key = location_name_key(name)
  52. result = await db.execute(select(Location).where(Location.name_key == key))
  53. return result.scalar_one_or_none()
  54. async def get_locations_by_name_keys(db: AsyncSession, keys: set[str]) -> dict[str, Location]:
  55. if not keys:
  56. return {}
  57. result = await db.execute(select(Location).where(Location.name_key.in_(keys)))
  58. return {loc.name_key: loc for loc in result.scalars().all()}
  59. async def _create_location_or_get_existing(db: AsyncSession, normalized: str) -> Location:
  60. """Insert a location row, returning the winner on concurrent name_key collision."""
  61. existing = await get_location_by_name(db, normalized)
  62. if existing:
  63. return existing
  64. location = Location()
  65. assign_location_name(location, normalized)
  66. try:
  67. async with db.begin_nested():
  68. db.add(location)
  69. await db.flush()
  70. return location
  71. except IntegrityError as exc:
  72. winner = await get_location_by_name(db, normalized)
  73. if winner:
  74. return winner
  75. raise ValueError(DUPLICATE_LOCATION_NAME) from exc
  76. async def _insert_location_if_absent(db: AsyncSession, name: str) -> bool:
  77. """Stage a new location row when absent. Returns True when one was added."""
  78. normalized = normalize_location_name(name)
  79. if await get_location_by_name(db, normalized):
  80. return False
  81. location = Location()
  82. assign_location_name(location, normalized)
  83. try:
  84. async with db.begin_nested():
  85. db.add(location)
  86. await db.flush()
  87. return True
  88. except IntegrityError:
  89. # Race: another writer inserted the same name between our check and
  90. # flush. The row already exists by definition — surface as "not added"
  91. # rather than re-raising. Anything else (NULL constraint, FK, check
  92. # constraint) would be a programming bug — re-fetch to verify so we
  93. # don't silently drop unrelated IntegrityErrors.
  94. if await get_location_by_name(db, normalized):
  95. return False
  96. logger.warning("IntegrityError on insert of location %r without surviving row", normalized)
  97. raise
  98. async def resolve_location_by_name(db: AsyncSession, name: str, *, create: bool = True) -> Location | None:
  99. """Find a location by name (case-insensitive), optionally creating it."""
  100. normalized = normalize_location_name(name)
  101. existing = await get_location_by_name(db, normalized)
  102. if existing:
  103. return existing
  104. if not create:
  105. return None
  106. return await _create_location_or_get_existing(db, normalized)
  107. async def resolve_spool_location_fields(
  108. db: AsyncSession,
  109. *,
  110. location_id: int | None = None,
  111. storage_location: str | None = None,
  112. fields_set: set[str],
  113. ) -> SpoolLocationFields | None:
  114. """Resolve location_id + storage_location from API input.
  115. ``location_id`` wins when both fields appear in ``fields_set``.
  116. Returns ``None`` when neither location field was provided.
  117. """
  118. if "location_id" in fields_set:
  119. if location_id is None:
  120. return SpoolLocationFields(location_id=None, storage_location=None)
  121. loc = await get_location_by_id(db, location_id)
  122. if not loc:
  123. raise ValueError(f"Location {location_id} not found")
  124. return SpoolLocationFields(location_id=loc.id, storage_location=loc.name)
  125. if "storage_location" in fields_set:
  126. if not storage_location:
  127. return SpoolLocationFields(location_id=None, storage_location=None)
  128. loc = await resolve_location_by_name(db, storage_location)
  129. if not loc:
  130. return SpoolLocationFields(location_id=None, storage_location=None)
  131. return SpoolLocationFields(location_id=loc.id, storage_location=loc.name)
  132. return None
  133. async def prepare_internal_spool_payload(db: AsyncSession, data: dict, fields_set: set[str]) -> dict:
  134. """Apply resolved location fields before creating or updating an internal spool."""
  135. payload = dict(data)
  136. resolved = await resolve_spool_location_fields(
  137. db,
  138. location_id=payload.get("location_id"),
  139. storage_location=payload.get("storage_location"),
  140. fields_set=fields_set,
  141. )
  142. if resolved is not None:
  143. payload["location_id"] = resolved.location_id
  144. payload["storage_location"] = resolved.storage_location
  145. return payload
  146. async def resolve_spoolman_location_string(
  147. db: AsyncSession,
  148. *,
  149. location_id: int | None = None,
  150. storage_location: str | None = None,
  151. fields_set: set[str],
  152. ) -> tuple[str | None, bool]:
  153. """Return (Spoolman location string, changed) for proxy writes."""
  154. resolved = await resolve_spool_location_fields(
  155. db,
  156. location_id=location_id,
  157. storage_location=storage_location,
  158. fields_set=fields_set,
  159. )
  160. if resolved is None:
  161. return None, False
  162. return resolved.storage_location, True
  163. async def count_internal_spools_at_location(db: AsyncSession, location_id: int) -> int:
  164. result = await db.execute(
  165. select(func.count())
  166. .select_from(Spool)
  167. .where(
  168. Spool.location_id == location_id,
  169. Spool.archived_at.is_(None),
  170. )
  171. )
  172. return int(result.scalar() or 0)
  173. async def count_spools_at_location_by_name(db: AsyncSession, name: str) -> int:
  174. normalized = name.strip()
  175. if not normalized:
  176. return 0
  177. result = await db.execute(
  178. select(func.count())
  179. .select_from(Spool)
  180. .where(
  181. Spool.archived_at.is_(None),
  182. func.lower(func.trim(Spool.storage_location)) == normalized.lower(),
  183. )
  184. )
  185. return int(result.scalar() or 0)
  186. async def enrich_spool_dicts_with_location_id(db: AsyncSession, spools: list[dict]) -> None:
  187. """Attach location_id to mapped Spoolman-style spool dicts in place."""
  188. keys = {location_name_key(s["storage_location"]) for s in spools if (s.get("storage_location") or "").strip()}
  189. if not keys:
  190. for s in spools:
  191. s["location_id"] = None
  192. return
  193. by_key = await get_locations_by_name_keys(db, keys)
  194. for s in spools:
  195. raw = (s.get("storage_location") or "").strip()
  196. if not raw:
  197. s["location_id"] = None
  198. continue
  199. loc = by_key.get(location_name_key(raw))
  200. s["location_id"] = loc.id if loc else None
  201. async def rename_location(db: AsyncSession, location: Location, new_name: str) -> Location:
  202. normalized = normalize_location_name(new_name)
  203. existing = await get_location_by_name(db, normalized)
  204. if existing and existing.id != location.id:
  205. raise ValueError(DUPLICATE_LOCATION_NAME)
  206. old_name = location.name
  207. # Mirror the SQL TRIM on the Python side so a legacy row whose
  208. # `storage_location` has trailing whitespace still matches against the
  209. # `old_name` we just lifted off the Location row. Without `.strip()` the
  210. # equality is asymmetric (SQL strips the column; Python doesn't) and
  211. # legacy rows quietly fall out of the rename cascade.
  212. old_name_key = old_name.strip().lower()
  213. assign_location_name(location, normalized)
  214. await db.execute(update(Spool).where(Spool.location_id == location.id).values(storage_location=normalized))
  215. # Keep legacy rows in sync when only storage_location was set.
  216. await db.execute(
  217. update(Spool)
  218. .where(
  219. Spool.location_id.is_(None),
  220. func.lower(func.trim(Spool.storage_location)) == old_name_key,
  221. )
  222. .values(storage_location=normalized, location_id=location.id)
  223. )
  224. try:
  225. await db.flush()
  226. except IntegrityError as exc:
  227. raise ValueError(DUPLICATE_LOCATION_NAME) from exc
  228. return location
  229. async def sync_locations_from_spoolman(db: AsyncSession, client) -> bool:
  230. """Import distinct Spoolman location strings into the local catalog.
  231. Returns True when new rows were staged (caller must commit). Logs and
  232. returns False on Spoolman fetch failures so the calling read path keeps
  233. serving the local catalog instead of 500ing; bare-Exception swallow used
  234. to be the shape here and hid both transport errors and shape regressions.
  235. """
  236. from backend.app.services.spoolman import SpoolmanClientError, SpoolmanUnavailableError
  237. try:
  238. names = await client.get_distinct_locations()
  239. except (SpoolmanUnavailableError, SpoolmanClientError, httpx.HTTPError) as exc:
  240. logger.warning("location sync from Spoolman failed: %s", exc)
  241. return False
  242. # Collapse case variants before insert — Spoolman may return both
  243. # "Drybox 1" and "DRYBOX 1" in the same payload.
  244. by_key: dict[str, str] = {}
  245. for raw in names:
  246. name = (raw or "").strip()
  247. if not name:
  248. continue
  249. if is_ams_slot_location(name):
  250. logger.debug("Skipping AMS slot marker %r from the Spoolman location import", name)
  251. continue
  252. key = location_name_key(name)
  253. if key not in by_key:
  254. by_key[key] = name
  255. changed = False
  256. for name in by_key.values():
  257. if await _insert_location_if_absent(db, name):
  258. changed = True
  259. return changed
  260. # Per-URL last-sync timestamp guard. Calling list_spools runs the sync, so on
  261. # a polling UI without this guard every refetch round-trips to Spoolman and
  262. # opens a write transaction — measurable latency and SQLite write contention.
  263. # 60s is long enough to absorb dashboard polling, short enough that a manual
  264. # spool rename in Spoolman shows up on the next minute's refresh.
  265. _SPOOLMAN_LOCATION_SYNC_TTL_SECONDS = 60.0
  266. _spoolman_location_sync_last_run: dict[str, float] = {}
  267. def _spoolman_location_sync_cache_clear() -> None:
  268. """Test hook: drop the TTL cache so each test starts from a clean slate."""
  269. _spoolman_location_sync_last_run.clear()
  270. async def maybe_sync_spoolman_locations(db: AsyncSession, *, client=None) -> bool:
  271. """Sync Spoolman location names into the local catalog when integration is enabled.
  272. Pass ``client`` when the caller has already resolved one (the GET /spools
  273. route does); otherwise the function falls back to ``init_spoolman_client``.
  274. Passing the route's client keeps test fixtures honest — without it, the
  275. fall-back path imports from ``backend.app.services.spoolman`` directly and
  276. bypasses any patch that targets the route module's alias, which causes
  277. real TCP connects to whatever ``spoolman_url`` happens to point at.
  278. """
  279. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  280. from backend.app.models.settings import Settings
  281. result = await db.execute(select(Settings))
  282. settings = {s.key: s.value for s in result.scalars().all()}
  283. if settings.get("spoolman_enabled", "false").lower() != "true":
  284. return False
  285. url = settings.get("spoolman_url", "").strip()
  286. if not url:
  287. return False
  288. # Debounce: skip the round-trip when we synced this URL recently.
  289. cache_key = url.rstrip("/")
  290. last_run = _spoolman_location_sync_last_run.get(cache_key, 0.0)
  291. now = time.monotonic()
  292. if now - last_run < _SPOOLMAN_LOCATION_SYNC_TTL_SECONDS:
  293. return False
  294. try:
  295. assert_safe_spoolman_url(url)
  296. except ValueError as exc:
  297. logger.warning("Spoolman URL rejected by SSRF guard during location sync: %s", exc)
  298. return False
  299. if client is None:
  300. from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
  301. client = await get_spoolman_client()
  302. if not client or client.base_url != cache_key:
  303. client = await init_spoolman_client(url)
  304. if not client:
  305. return False
  306. changed = await sync_locations_from_spoolman(db, client)
  307. _spoolman_location_sync_last_run[cache_key] = now
  308. return changed