From 64ea91d10728ada8805cec8e9456535aaf22edd4 Mon Sep 17 00:00:00 2001 From: Jonas Menzel Date: Sun, 25 Oct 2026 21:52:23 +0100 Subject: [PATCH] Spool page for NFC links, bridge address; vendor-first new spool form - /s/ shows a spool: rest in Spoolman and CC2 Control, where it is, last booking, history, links to Spoolman and the tag generator. - Setting "Adresse der Bridge"; the tag generator can put
/s/ on the tag, so an iPhone held to the roll opens that page. - New spool: vendor and material chosen from Spoolman, the vendor's filaments offered, values taken from similar filaments, duplicates pointed out and never created twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC --- README.md | 19 ++++- app/bridge.py | 24 ++++++ app/newspool.py | 12 ++- app/storage.py | 4 + app/templates/base.html | 1 + app/templates/settings.html | 7 +- app/templates/spool.html | 77 +++++++++++++++++ app/templates/tags.html | 161 +++++++++++++++++++++++++++--------- app/web.py | 30 ++++++- tests/test_web.py | 18 ++++ 10 files changed, 304 insertions(+), 49 deletions(-) create mode 100644 app/templates/spool.html diff --git a/README.md b/README.md index 5f1056d..a7fcd0f 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,25 @@ Felder in Spoolman ziehen dann mit. ### Neue Spule anlegen -Im *Tag-Generator* unter **Neue Spule**: ein vorhandenes Filament wählen (weitere Rolle davon) oder ein neues -anlegen – Hersteller, Name, Material, Farbe, Dichte, Durchmesser, Düsen-/Betttemperatur, Netto- und Leergewicht, -Preis, Anzahl. Material setzt Dichte und Temperaturen vor. Die Bridge legt Hersteller (falls neu), Filament und +Im *Tag-Generator* unter **Neue Spule**: Hersteller aus Spoolman wählen (oder einen neuen), dann ein vorhandenes +Filament dieses Herstellers (weitere Rolle davon) oder ein neues – Material aus den in Spoolman verwendeten (oder den +übrigen Elegoo-Typen), Name, Farbe, Dichte, Durchmesser, Düsen-/Betttemperatur, Netto- und Leergewicht, Preis, +Anzahl. Dichte und Temperaturen kommen von einem vorhandenen Filament mit demselben Material (bevorzugt desselben +Herstellers), Gewichte von den übrigen Filamenten des Herstellers. Gibt es das Filament schon (gleicher Name oder +gleiches Material und gleiche Farbe), weist das Formular darauf hin; ein gleichnamiges wird nie doppelt angelegt. Die Bridge legt Hersteller (falls neu), Filament und Spule(n) in Spoolman an, Ort „Lager“, Label wie gewohnt Hersteller + ID dreistellig (`SUNLU027`), und öffnet gleich den Tag dazu. Die ID vergibt Spoolman (fortlaufend). Im Dry-Run wird nichts angelegt. +### Spule mit dem iPhone auslesen + +Safari kann NFC nicht selbst lesen, das iPhone liest Tags aber im Hintergrund: Steht auf dem Tag ein Link, genügt es, +das entsperrte iPhone an die Rolle zu halten und die Mitteilung anzutippen. Dafür unter *Einstellungen* → *Adresse +der Bridge* die Adresse eintragen, unter der das iPhone die Bridge erreicht (z. B. `https://cc2.menzel.center`), und +im Tag-Generator als Link **Spule in der Bridge** wählen (die Wahl wird gemerkt). Der Tag enthält dann +`/s/`; die Seite zeigt Rest (Spoolman und CC2 Control), wo die Spule steckt, die letzte Buchung und den +Verlauf, mit Links zu Spoolman und zum Tag-Generator. Spoolman-ID und Druckerdaten auf dem Tag bleiben gleich; +ob der Drucker Tags mit einem längeren Link genauso liest, ist noch an einem Test-Tag zu prüfen. + ### Am iPhone als Web-App `http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne diff --git a/app/bridge.py b/app/bridge.py index 0f90cf1..fdadc3d 100644 --- a/app/bridge.py +++ b/app/bridge.py @@ -1224,6 +1224,30 @@ class Bridge: self.spool_cache[smid] = (self.clock(), sm) return sm + def spool_whereabouts(self, smid: int) -> dict: + """Where a Spoolman spool is as the bridge sees it, for the spool page.""" + with self.lock: + slot = None + for pid, n, m in self.mappings(): + if m["spool"] == smid: + w = self.workers.get(pid) + slot = {"printer": w.name if w else pid, "printer_id": pid, "slot": n + 1, + "since": m.get("since"), "assigned": bool(m.get("assigned"))} + copies, last = [], None + for w in self.workers.values(): + st = w.st + for t in st["takeout"]: + if t["spool"] == smid and t["until"] is None and slot is None: + slot = {"printer": w.name, "printer_id": w.id, "slot": t["slot"] + 1, "takeout": True} + for sp in (w.cc2_data or {}).get("spools") or []: + if spoolman_id_of(sp) == smid: + copies.append({"printer": w.name, "remaining": _num(sp.get("remaining"), None), + "archived": bool(sp.get("archived"))}) + for b in st["booked"].values(): + if b.get("spoolman") == smid and b.get("last") and (last is None or b["last"]["time"] > last["time"]): + last = {**b["last"], "printer": w.name} + return {"slot": slot, "copies": copies, "last_booking": last} + def _name(self, smid: int) -> str: cached = self.spool_cache.get(smid) return label(cached[1]) if cached else f"Spoolman-Spule {smid}" diff --git a/app/newspool.py b/app/newspool.py index ad8c238..e1a7ced 100644 --- a/app/newspool.py +++ b/app/newspool.py @@ -121,10 +121,14 @@ def create(client: Spoolman, p: dict, location: str) -> tuple[list[dict], str]: v = client.create_vendor(body) created_parts.append(f"Hersteller {vendor}") vendor = v["name"] - fbody = {k: val for k, val in p["filament"].items() if val is not None} - fbody["vendor_id"] = v["id"] - fil = client.create_filament(fbody) - created_parts.append(f"Filament {fil.get('name')}") + name = p["filament"]["name"].lower() + fil = next((f for f in client.filaments() if (f.get("vendor") or {}).get("id") == v["id"] + and (f.get("name") or "").strip().lower() == name), None) + if fil is None: # the same vendor and name again would only clutter Spoolman + fbody = {k: val for k, val in p["filament"].items() if val is not None} + fbody["vendor_id"] = v["id"] + fil = client.create_filament(fbody) + created_parts.append(f"Filament {fil.get('name')}") spools = [] for _ in range(p["count"]): body: dict = {"filament_id": fil["id"], "location": location} diff --git a/app/storage.py b/app/storage.py index 9789cc6..9a31923 100644 --- a/app/storage.py +++ b/app/storage.py @@ -122,6 +122,7 @@ class Settings: empty_grace: float = 20.0 # seconds with tray status 0 before a slot counts as empty settle_seconds: float = 60.0 # `remaining` must be unchanged this long before booking archive_empty: bool = True # archive a Spoolman spool whose roll ran out + public_url: str = "" # the bridge as phones reach it (links on NFC tags); empty: the address in the browser dry_run: bool = True printers: list[PrinterConfig] = field(default_factory=list) @@ -162,6 +163,9 @@ class Settings: if not self.spoolman_url.startswith(("http://", "https://")): raise ValueError("Spoolman-URL muss mit http:// oder https:// beginnen") self.spoolman_url = self.spoolman_url.rstrip("/") + self.public_url = self.public_url.strip().rstrip("/") + if self.public_url and not re.fullmatch(r"https?://[^\s/?#]+(/[^\s?#]*)?", self.public_url): + raise ValueError("Adresse der Bridge muss mit http:// oder https:// beginnen, ohne ? oder #") if not self.location_storage.strip(): raise ValueError("Ortsname für das Lager fehlt") ids = [p.id for p in self.printers] diff --git a/app/templates/base.html b/app/templates/base.html index 6e04714..10afca9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -42,6 +42,7 @@ } } * { box-sizing: border-box; } +[hidden] { display: none !important; } html { -webkit-text-size-adjust: 100%; } body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 var(--font); -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; } diff --git a/app/templates/settings.html b/app/templates/settings.html index a0edae2..05487d7 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -45,6 +45,11 @@ details.pr[open] > summary .chev { transform: rotate(90deg); }

Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.

+ +
+
+
+
@@ -84,7 +89,7 @@ details.pr[open] > summary .chev { transform: rotate(90deg); } {% endblock %} {% block script %} +{% endblock %} diff --git a/app/templates/tags.html b/app/templates/tags.html index 377b2cf..62e96b4 100644 --- a/app/templates/tags.html +++ b/app/templates/tags.html @@ -55,18 +55,21 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
@@ -115,15 +118,18 @@ details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px
+
+ Link auf dem Tag (fürs Handy; der Drucker liest ihn nicht) + + + +
+
Erweitert
-
- URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie) - - -
@@ -162,7 +168,7 @@ let META = null, SPOOLS = [], current = null, timer = null; const MATDEF = { PLA: [1.24, 210, 60], PETG: [1.27, 240, 75], ABS: [1.04, 250, 100], ASA: [1.07, 255, 100], TPU: [1.21, 225, 50], PA: [1.14, 275, 90], PC: [1.20, 270, 110], PVA: [1.23, 200, 60], HIPS: [1.04, 240, 100], PP: [0.90, 230, 85], CPE: [1.25, 250, 80], PPA: [1.20, 295, 100], PPS: [1.35, 315, 110], BVOH: [1.14, 200, 60], EVA: [0.95, 200, 50] }; -let CAT = null, MODE = 'list'; +let CAT = null, MODE = 'list', SETTINGS = {}, BRIDGE = location.origin; function setMode2(m) { MODE = m; @@ -177,15 +183,24 @@ async function loadCatalog() { $('nf_preview').textContent = 'Lade Spoolman …'; try { CAT = await api('GET', '/api/spoolman/catalog'); } catch (e) { $('nf_preview').innerHTML = `${esc(e.message)}`; return; } - const groups = {}; - for (const f of CAT.filaments) (groups[f.vendor] = groups[f.vendor] || []).push(f); - $('nf_fil').innerHTML = '' + Object.entries(groups).map(([v, fs]) => - `${fs.map((f) => ``).join('')}`).join(''); - $('nf_vendors').innerHTML = CAT.vendors.map((v) => ``).join('') + + ''; + // materials you already use first, then the other Elegoo types + const used = [...new Set(CAT.filaments.map((f) => f.material).filter(Boolean))].sort(); + const more = META.subtypes.map((t) => t.name).filter((n) => !used.some((u) => u.toUpperCase() === n.toUpperCase())); + $('nf_mat_sel').innerHTML = `${used.map((m) => ``).join('')}` + + `${more.map((m) => ``).join('')}` + + ''; + vendorChanged(); } +function vendorSel() { + const v = $('nf_vendor_sel').value; + return v === 'new' ? null : CAT.vendors.find((x) => String(x.id) === v); +} +function vendorName() { const v = vendorSel(); return v ? v.name : $('nf_vendor').value.trim(); } +function materialName() { return $('nf_mat_sel').value === 'new' ? $('nf_material').value.trim() : $('nf_mat_sel').value; } + function baseMaterial(m) { const u = String(m || '').toUpperCase(); return Object.keys(MATDEF).sort((a, b) => b.length - a.length).find((k) => u.replace(/^RAPID\s*/, '').startsWith(k)) || null; @@ -193,62 +208,105 @@ function baseMaterial(m) { function fil() { return CAT && CAT.filaments.find((f) => String(f.id) === $('nf_fil').value); } +function mostCommon(values) { + const n = {}; + for (const v of values) if (v != null) n[v] = (n[v] || 0) + 1; + const best = Object.entries(n).sort((a, b) => b[1] - a[1])[0]; + return best ? Number(best[0]) : null; +} + +function vendorChanged() { + const v = vendorSel(); + $('nf_vendor_new').hidden = !!v; + const own = v ? CAT.filaments.filter((f) => f.vendor === v.name) : []; + $('nf_fil').innerHTML = '' + own.map((f) => + ``).join(''); + // weights as this vendor's other filaments have them + const w = mostCommon(own.map((f) => f.weight)), sw = mostCommon(own.map((f) => f.spool_weight)) ?? (v ? v.empty_spool_weight : null); + if (w) $('nf_weight').value = w; + $('nf_spool').value = sw ?? ''; + newFilamentChanged(); +} + function newFilamentChanged() { const f = fil(); - $('nf_new').hidden = !!f; $('nf_new').style.display = f ? 'none' : ''; if (f) { - $('nf_info').innerHTML = `${f.color ? ` ` : ''}${esc(f.vendor)} · ${esc(f.material)} · ${f.weight ? f.weight + ' g' : 'Gewicht?'}${f.density ? ' · ' + f.density + ' g/cm³' : ''} · ${f.spools} Spule${f.spools === 1 ? '' : 'n'} vorhanden`; + $('nf_info').innerHTML = `Weitere Rolle von: ${f.color ? ` ` : ''}${esc(f.material)} · ${f.weight ? f.weight + ' g' : 'Gewicht?'}${f.density ? ' · ' + f.density + ' g/cm³' : ''}${f.extruder_temp ? ' · ' + f.extruder_temp + ' °C' : ''}`; if (f.weight) $('nf_weight').value = f.weight; $('nf_spool').value = f.spool_weight ?? ''; $('nf_price').value = f.price ?? ''; } else { $('nf_info').textContent = ''; + materialChanged(); } preview(); } function materialChanged() { - const d = MATDEF[baseMaterial($('nf_material').value)]; - if (!d) return; - let density = d[0]; - if (/-(CF|GF)\b/i.test($('nf_material').value)) density = +(density + 0.05).toFixed(2); - $('nf_density').value = density; $('nf_extruder').value = d[1]; $('nf_bed').value = d[2]; - preview(); -} - -function vendorChanged() { - const v = CAT && CAT.vendors.find((x) => x.name.toLowerCase() === $('nf_vendor').value.trim().toLowerCase()); - if (v && v.empty_spool_weight != null && !$('nf_spool').value) $('nf_spool').value = v.empty_spool_weight; + $('nf_mat_new').hidden = $('nf_mat_sel').value !== 'new'; + const m = materialName(); + if (!m) { preview(); return; } + // like the filaments you already have with this material (this vendor's first), else typical values + const same = CAT.filaments.filter((f) => f.material.toUpperCase() === m.toUpperCase()); + const ref = same.find((f) => f.vendor === vendorName()) || same[0]; + if (ref) { + $('nf_density').value = ref.density ?? ''; $('nf_diameter').value = ref.diameter ?? 1.75; + $('nf_extruder').value = ref.extruder_temp ?? ''; $('nf_bed').value = ref.bed_temp ?? ''; + $('nf_from').textContent = `Werte übernommen von ${ref.vendor} ${ref.name}.`; + } else { + const d = MATDEF[baseMaterial(m)]; + if (d) { + let density = d[0]; + if (/-(CF|GF)\b/i.test(m)) density = +(density + 0.05).toFixed(2); + $('nf_density').value = density; $('nf_extruder').value = d[1]; $('nf_bed').value = d[2]; + $('nf_from').textContent = 'Typische Werte für ' + m + ' – bitte mit dem Etikett der Rolle vergleichen.'; + } else $('nf_from').textContent = ''; + } + const name = $('nf_name').value.trim(); + if (!name || (nameAuto && name === nameAuto)) { nameAuto = m + ' '; $('nf_name').value = nameAuto; } preview(); } +let nameAuto = ''; function setNewColor(c) { c = c.replace('#', '').toUpperCase(); $('nf_color').value = c; if (/^[0-9A-F]{6}$/.test(c)) $('nf_picker').value = '#' + c; document.querySelectorAll('#nf_palette button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c.toUpperCase() === c))); + preview(); } function labelFor(vendor, id) { return (String(vendor).toUpperCase().replace(/[^A-Z0-9]/g, '') || 'SPOOL') + String(id).padStart(3, '0'); } +function duplicate() { + if (fil() || !vendorSel()) return null; + const name = $('nf_name').value.trim().toUpperCase(), color = $('nf_color').value.replace('#', '').toUpperCase(); + const m = materialName().toUpperCase(), own = CAT.filaments.filter((f) => f.vendor === vendorSel().name); + return own.find((f) => name && f.name.toUpperCase() === name) + || own.find((f) => color && f.color === color && f.material.toUpperCase() === m) || null; +} + +function useFilament(id) { $('nf_fil').value = String(id); newFilamentChanged(); } + function preview() { if (!CAT) return; - const f = fil(), vendor = f ? f.vendor : $('nf_vendor').value.trim(); - const v = CAT.vendors.find((x) => x.name.toLowerCase() === vendor.toLowerCase()); - const shown = v ? v.name : vendor; + const dupe = duplicate(); + $('nf_dupe').innerHTML = dupe ? `` : ''; + const vendor = vendorName() || '?'; const n = Math.max(1, Math.min(20, Number($('nf_count').value) || 1)); - const first = labelFor(shown || '?', CAT.next_id), last = labelFor(shown || '?', CAT.next_id + n - 1); + const first = labelFor(vendor, CAT.next_id), last = labelFor(vendor, CAT.next_id + n - 1); $('nf_preview').innerHTML = `Wird angelegt als ${esc(first)}${n > 1 ? ` bis ${esc(last)}` : ''}, Ort „Lager“. Die ID vergibt Spoolman (voraussichtlich ${CAT.next_id}${n > 1 ? '–' + (CAT.next_id + n - 1) : ''}); das Label folgt ihr. - ${!f && vendor && !v ? `
Neuer Hersteller „${esc(vendor)}“ wird ebenfalls angelegt.` : ''}`; + ${!vendorSel() && vendor !== '?' ? `
Neuer Hersteller „${esc(vendor)}“ wird ebenfalls angelegt.` : ''}`; } async function createSpools() { const f = fil(); const body = { count: $('nf_count').value, weight: $('nf_weight').value, spool_weight: $('nf_spool').value, price: $('nf_price').value }; if (f) body.filament_id = f.id; - else Object.assign(body, { vendor: $('nf_vendor').value, name: $('nf_name').value, material: $('nf_material').value, + else Object.assign(body, { vendor: vendorName(), name: $('nf_name').value, material: materialName(), color: $('nf_color').value, density: $('nf_density').value, diameter: $('nf_diameter').value, extruder_temp: $('nf_extruder').value, bed_temp: $('nf_bed').value }); const n = Number(body.count) || 1; @@ -275,7 +333,25 @@ async function init() { $('material').innerHTML = Object.keys(META.materials).map((m) => ``).join(''); $('palette').innerHTML = META.palette.map((c) => ``).join(''); + try { SETTINGS = await api('GET', '/api/settings'); } catch (e) { SETTINGS = {}; } + BRIDGE = SETTINGS.public_url || location.origin; + $('urlhint').innerHTML = SETTINGS.public_url ? '' + : `Keine Adresse der Bridge eingestellt; verwendet wird ${esc(location.origin)}. Unter Einstellungen festlegen.`; + try { + const saved = localStorage.getItem('tagurl'); + const el = saved && document.querySelector(`input[name=url][value=${saved}]`); + if (el) el.checked = true; + } catch (e) {} await loadSpools(); + const want = Number(new URLSearchParams(location.search).get('spool')); + if (want && SPOOLS.some((x) => x.id === want)) pick(want); +} + +function tagUrl() { + const v = document.querySelector('input[name=url]:checked').value; + if (v === 'bridge') return `${BRIDGE}/s/${current.id}`; + if (v === 'spoolman') return current.spoolman_url; + return null; } async function loadSpools() { @@ -329,6 +405,7 @@ function pick(id) { ${esc(current.label)} #${current.id} ${esc(spoolLine(current))} `; + $('bridgeurl').textContent = `${BRIDGE}/s/${current.id}`; $('material').value = sg.material; fillSubtypes(sg.material, sg.subtype); $('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max; @@ -363,12 +440,11 @@ function toggleQR() { async function generate() { if (!current) return; - const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman'; const body = { spool_id: current.id, material: $('material').value, subtype: Number($('subtype').value), color: $('color').value, temp_min: Number($('temp_min').value), temp_max: Number($('temp_max').value), finish: parseInt($('finish').value || 'FF', 16), diameter: current.diameter || 1.75, - url: useSpoolman ? current.spoolman_url : null, + url: tagUrl(), }; try { const r = await api('POST', '/api/tag', body); @@ -404,11 +480,16 @@ $('material').addEventListener('change', () => { for (const id of ['subtype', 'temp_min', 'temp_max', 'finish']) $(id).addEventListener('input', schedule); $('color').addEventListener('input', () => { const c = $('color').value.replace('#', ''); if (/^[0-9a-f]{6}$/i.test(c)) $('picker').value = '#' + c; markPalette(); schedule(); }); $('picker').addEventListener('input', () => setColor($('picker').value)); -document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule)); +document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', () => { + try { localStorage.setItem('tagurl', el.value); } catch (e) {} + schedule(); +})); $('filter').addEventListener('input', renderSpools); $('nf_fil').addEventListener('change', newFilamentChanged); +$('nf_vendor_sel').addEventListener('change', vendorChanged); +$('nf_mat_sel').addEventListener('change', materialChanged); $('nf_material').addEventListener('change', materialChanged); -$('nf_vendor').addEventListener('input', vendorChanged); +$('nf_vendor').addEventListener('input', preview); $('nf_color').addEventListener('input', () => setNewColor($('nf_color').value)); $('nf_picker').addEventListener('input', () => setNewColor($('nf_picker').value)); for (const id of ['nf_count', 'nf_name']) $(id).addEventListener('input', preview); diff --git a/app/web.py b/app/web.py index 803b237..a27657b 100644 --- a/app/web.py +++ b/app/web.py @@ -20,7 +20,7 @@ from fastapi.templating import Jinja2Templates from . import materials, newspool, tagcodec from .bridge import Bridge from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint -from .spoolman import SpoolmanError, summary +from .spoolman import SpoolmanError, SpoolmanNotFound, summary from .storage import SettingsStore log = logging.getLogger("web") @@ -89,6 +89,10 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st def index(request: Request): return page(request, "status.html") + @app.get("/s/{smid}", response_class=HTMLResponse) + def spool_page(request: Request, smid: int): + return page(request, "spool.html", spool_id=smid) + @app.get("/log", response_class=HTMLResponse) def log_page(request: Request): return page(request, "log.html") @@ -276,6 +280,30 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st out.sort(key=lambda x: x["id"]) return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset} + @app.get("/api/spool/{smid}") + def spool_info(smid: int): + s = settings.get() + client = bridge.spoolman_factory(s) + try: + sm = client.spool(smid) + except SpoolmanNotFound: + raise HTTPException(404, f"Spoolman-Spule {smid} gibt es nicht") from None + except SpoolmanError as e: + raise HTTPException(502, str(e)) from None + finally: + client.close() + info = summary(sm) + name = info["label"] + return { + "spool": info, + "filament_name": (sm.get("filament") or {}).get("name") or "", + "spoolman_url": f"{s.spoolman_url}/spool/show/{smid}", + **bridge.spool_whereabouts(smid), + "storage": s.location_storage, + "log": [e for e in bridge.log.recent(300) if name in e["msg"]][:8], + "dry_run": s.dry_run, + } + @app.get("/api/spoolman/catalog") def spoolman_catalog(): client = bridge.spoolman_factory(settings.get()) diff --git a/tests/test_web.py b/tests/test_web.py index ee6c4e8..9998979 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -161,6 +161,24 @@ def test_create_spool_with_new_filament_and_vendor(tmp_path): fil = [x[1] for x in sm.calls if x[0] == "create_filament"][0] assert fil["color_hex"] == "1A1A1A" and fil["settings_extruder_temp"] == 210 and fil["vendor_id"] == 2 assert ("create_vendor", {"name": "Elegoo", "empty_spool_weight": 154.0}) in sm.calls + again = c.post("/api/spoolman/create", json=dict(body, vendor="ELEGOO", name="pla matte black"), headers=H) + assert again.status_code == 200 and len([x for x in sm.calls if x[0] == "create_filament"]) == 1 # reused + assert len([x for x in sm.calls if x[0] == "create_vendor"]) == 1 bad = dict(body, color="red") assert c.post("/api/spoolman/create", json=bad, headers=H).status_code == 400 assert c.post("/api/spoolman/create", json=dict(body, count=50), headers=H).status_code == 400 + + +def test_public_url_and_spool_page(tmp_path): + c, bridge = make_client(tmp_path) + assert c.post("/api/settings", json={"public_url": "cc2.menzel.center"}, headers=H).status_code == 400 + r = c.post("/api/settings", json={"public_url": "https://cc2.menzel.center/"}, headers=H) + assert r.json()["public_url"] == "https://cc2.menzel.center" + assert c.get("/s/5").status_code == 200 + d = c.get("/api/spool/5").json() + assert d["spool"]["id"] == 5 and d["slot"] is None and d["spoolman_url"].endswith("/spool/show/5") + assert c.get("/api/spool/99").status_code == 404 + url = "https://cc2.menzel.center/s/5" + t = c.post("/api/tag", json={"spool_id": 5, "material": "PLA", "subtype": 1, "color": "000000", "temp_min": 190, + "temp_max": 230, "url": url}, headers=H).json() + assert t["decoded"]["url"] == url and t["decoded"]["spool_id"] == 5