mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
fix(spoolman): persist color_name edits and stop form round-tripping the subtype synth fallback (#1319)
Editing a spool's color name on Spoolman-backed inventory appeared to
accept the new value but the inventory list column and the next edit
showed it back to the subtype. Three layers stacked to produce this:
1. find_or_create_filament matches by material/name/color_hex/vendor —
color_name is intentionally not part of the match key, but on a
match it returned the existing filament's id unchanged, silently
dropping the new value.
2. The read helper falls back to subtype when filament.color_name is
empty (kept on purpose: without it Spoolman installs that don't
fill the field render every spool as "Unknown color").
3. The edit form prefilled color_name from spool.color_name — which
on those installs was the synth value. Changing subtype but not
color_name silently round-tripped the OLD subtype back to Spoolman
as if it were a real user-set color_name.
Fixes:
- find_or_create_filament now patches the matched filament's
color_name via the existing patch_filament wrapper when the request
differs. Parameter convention: None = don't touch, "" = explicit
clear, any other string = set/update. A patch failure is logged but
does not block the match.
- The PATCH route uses model_fields_set to distinguish "field omitted"
from "field explicitly set to null" (mirrors the existing
storage_location pattern at the same site).
- The map helper returns color_name_is_synthesized: bool. The edit
form leaves the input blank when true, so the user sees the real
stored state and can't accidentally round-trip the synth value back.
This commit is contained in:
@@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Spoolman weight tracking now uses per-print grams for all spools, matching the internal Filament Inventory** ([#1119](https://github.com/maziggy/bambuddy/issues/1119), reported by @Moskito99) — Spoolman previously had two mutually-exclusive weight paths: AMS remain%×tray_weight auto-sync (default; only worked for Bambu Lab spools with valid RFID tray_weight) and per-print 3MF-grams tracking (only enabled when "Disable AMS Weight Sync" was toggled on). Non-BL spools without RFID fell through both paths — AMS auto-sync had no tray_weight to multiply, and the inventory_remaining fallback was wiped because activating Spoolman deletes the internal `spool_assignment` table — so Spoolman never saw a weight update for them. The internal Filament Inventory has no such gap: it always uses per-print 3MF grams as the primary path with AMS-remain% delta as fallback, and it works for every spool type. Spoolman now does the same: per-print tracking runs whenever Spoolman is enabled and is the only writer of `remaining_weight`. AMS auto-sync continues to maintain spool metadata and slot assignments but no longer touches weight (eliminating the double-count that would otherwise occur for BL spools with both paths active). `store_print_data` ([`spoolman_tracking.py:159`](backend/app/services/spoolman_tracking.py)) had its `disable_weight_sync` early-return removed; the three `sync_ams_tray` callsites (`main.py:1450` auto-sync, `spoolman.py:318` per-printer manual, `spoolman.py:517` sync-all) now hard-code `disable_weight_sync=True`. The `spoolman_disable_weight_sync` setting is now deprecated and a no-op — kept in the DB/UI for backwards compat. Behavioral consequence for existing users on the default flag (False): live AMS-based remaining_weight updates between prints stop happening; weight updates now arrive once per print completion with 3MF gram precision. Regression test in `test_spoolman_tracking.py::test_stores_tracking_when_disable_weight_sync_is_false` proves the early-return is gone.
|
||||
|
||||
### Fixed
|
||||
- **Spoolman inventory: editing a spool's color name no longer "reverts" to the subtype on save** ([#1319](https://github.com/maziggy/bambuddy/issues/1319), reported by @MartinNYHC) — On Spoolman-backed inventory, changing a spool's color name in the edit dialog appeared to accept the new value but the inventory list column and the next edit-dialog open showed it back to the subtype string. **Three layers stacked on top of each other to produce this:** (1) `find_or_create_filament` at [`backend/app/services/spoolman.py:609`](backend/app/services/spoolman.py) matches existing Spoolman filaments by `material / name / color_hex / vendor` — `color_name` is intentionally not part of the match key (Spoolman doesn't standardise the field and most installs leave it null) — but when a match was found it returned the existing filament's id unchanged, silently dropping the new `color_name` value. The write never reached Spoolman. (2) On re-read, the helper at [`_spoolman_helpers.py:279`](backend/app/api/routes/_spoolman_helpers.py) falls back to `subtype` when `filament.color_name` is empty (without the fallback, Spoolman installs that don't fill the field would render every spool as "Unknown color"). The persisted value was still empty, so the read synthesised the column from `subtype`. (3) The edit form prefilled `color_name` from `spool.color_name` — which on Spoolman installs without `color_name` was the synth value (= subtype). If the user changed `subtype` but not `color_name`, the form silently round-tripped the OLD subtype back to Spoolman as if it were a real user-set `color_name`, which then started showing up as the persisted value on the next render — the exact "color reverts to subtype" pattern in the bug report. **Fixes:** (1) `find_or_create_filament` now patches the matched filament's `color_name` via the existing `patch_filament` PATCH wrapper when the request differs from what's stored. Convention on the parameter: `None` = "don't touch", `""` = explicit clear (patches Spoolman to `null`), any other string = set/update. (2) The PATCH route at [`spoolman_inventory.py:567`](backend/app/api/routes/spoolman_inventory.py) now uses Pydantic's `model_fields_set` to distinguish "field omitted" from "field explicitly set to null" — only the latter is a clear (mirrors the existing `storage_location` pattern at the same site). (3) The map helper now also returns `color_name_is_synthesized: bool` on every inventory record, and [`SpoolFormModal.tsx`](frontend/src/components/SpoolFormModal.tsx) checks it on prefill so the input starts blank when the value was synthesised from subtype — the user sees the real stored state and can't accidentally round-trip the synth value back. The read-side fallback is kept on purpose (the list-display "Unknown color" problem hasn't gone away — it's just that the form no longer treats the fallback as a real value). A `patch_filament` failure is caught and logged but doesn't block the match — the spool still links to the correct filament, only the colour-name update is dropped, which is the safer failure mode. **Regression tests:** 5 in [`test_spoolman_inventory_methods.py::TestFindOrCreateFilament`](backend/tests/unit/test_spoolman_inventory_methods.py) — patch-on-change, no-patch-when-unchanged, no-patch-when-`None`, clear-when-`""`-passed, and patch-failure-still-returns-match-id. 2 in [`test_spoolman_inventory_helpers.py::TestMapSpoolmanSpool`](backend/tests/unit/test_spoolman_inventory_helpers.py) — `color_name_is_synthesized` flag is `False` when a real value is stored, `True` when the fallback fires. 2 integration tests in [`test_spoolman_inventory_api.py`](backend/tests/integration/test_spoolman_inventory_api.py) — wire-level `color_name=null` clears (route translates to `""`), and `color_name` omitted from the PATCH body keeps the current value (route passes `None`). All 564 spoolman-tagged tests pass; ruff clean; frontend build clean.
|
||||
|
||||
- **Deleting an SSO user left orphan OIDC/MFA/camera-token rows on SQLite — blocked re-login and leaked auth state** ([#1285](https://github.com/maziggy/bambuddy/issues/1285), [PR #1295](https://github.com/maziggy/bambuddy/pull/1295) by @netscout2001) — On SQLite (default deployment) the `delete_user` route left orphan rows in `user_oidc_links`, `user_totp`, `user_otp_codes`, and `long_lived_tokens` because the project intentionally runs with `PRAGMA foreign_keys=OFF`, so the `ON DELETE CASCADE` declared on those tables never fired. **Reported symptom:** an admin deleted an OIDC-provisioned user, the user tried to re-login via SSO, the OIDC callback found the orphan `UserOIDCLink` pointing at the (now missing) user, failed to resolve it, and redirected to `account_inactive` instead of triggering `auto_create_users`. The same root cause was leaking MFA secrets (`user_totp`), pending email OTP codes (`user_otp_codes`), and per-user camera-stream tokens (`long_lived_tokens` — `verify()` would happily match by `lookup_prefix` even after the owning user was gone). PostgreSQL deployments were unaffected — cascade was firing there. **Fix:** mirrors the existing `APIKey` cleanup pattern in `delete_user` (introduced in PR #1182). `backend/app/api/routes/users.py:delete_user` now explicitly deletes `UserOIDCLink`, `UserTOTP`, `UserOTPCode`, and `LongLivedToken` rows owned by the user; also folds in `PrintBatch.created_by_id` cleanup (same `ondelete=SET NULL` SQLite-FK-off root cause, the `SET NULL` block at `users.py:393-407` was missing it). `backend/app/core/database.py:run_migrations` gains an idempotent startup orphan-cleanup that sweeps the four auth tables (`DELETE FROM <table> WHERE user_id NOT IN (SELECT id FROM users)`), wrapped in `begin_nested()`, logged at INFO only when rows actually drop — so installations carrying orphans from before the fix are healed automatically without manual DB intervention. No-op on Postgres (cascade already fired) and idempotent on SQLite (second run finds nothing). `backend/app/api/routes/mfa.py:list_oidc_links` returns `"<deleted>"` for `provider_name` when `link.provider` is null instead of raising `AttributeError` — covers the symmetric edge case where a `UserOIDCLink` could reference an orphaned provider. **Tests:** 14 new/extended. `test_users_auth_cleanup.py` (new): 5 tests verify `delete_user` removes OIDC/TOTP/OTP/long-lived-token rows individually + combined-cleanup atomically. `test_oidc_relogin.py` (new): full end-to-end test reproducing the #1285 symptom — mocked IdP, first OIDC login, admin delete, second OIDC login proves `auto_create_users` fires again (and pinned the regression boundary by confirming the test fails without the fix). `test_orphan_auth_cleanup_migration.py` (new): 7 tests for per-table cleanup across all four auth tables, idempotency, no-op on fresh install, and survival of rows belonging to real users. `test_mfa_api.py` adds `TestListOidcLinksDefensiveProviderNull` for the null-check. `test_auth_api.py::test_delete_user` extended to assert all five auth-table side effects (`UserOIDCLink`, `UserTOTP`, `UserOTPCode`, `APIKey`, `LongLivedToken`). All 13 PR-added tests + 194 tests in extended files pass; ruff clean. **Reported and patched by @netscout2001.**
|
||||
|
||||
- **Slicer bundle import 400/502/503 errors now land in the log so support bundles tell us why** ([#1312](https://github.com/maziggy/bambuddy/issues/1312), reported by @hasmar04) — Reporter hit `400 Bad Request` from `POST /api/v1/slicer/bundles` when uploading a Bambu Studio Printer Preset Bundle (`.bbscfg`); a second contributor had reported the same shape the day before. Same bundle file uploaded fine on Martin's dev machine, which strongly points at sidecar-side differences (image version, write permissions on `DATA_PATH/bundles`, TrueNAS Docker volume perms, etc.) — but triage was blocked because the sidecar's actual reject reason only made it as far as the FE toast. Bambuddy logged just the uvicorn-access line (`POST /api/v1/slicer/bundles HTTP/1.1 400`), with no detail in the support bundle. The route at `backend/app/api/routes/slicer_presets.py:import_slicer_bundle` now emits a `logger.warning` for each of the three failure shapes: **400 (`SlicerInputError`)** — sidecar's reject string is logged alongside the filename and byte count, so we can see "bundle rejected because `manifest.json` is missing" in the next support bundle without asking the reporter to copy the toast text. **503 (`SlicerApiUnavailableError`)** — logs the configured sidecar URL plus the exception detail (separates "URL wrong" from "sidecar offline"). **502 (`SlicerApiError`)** — logs filename + byte count + error string, useful when the sidecar's `DATA_PATH/bundles` write fails (the typical 5xx cause on this path). The 400 case is `WARNING` rather than `INFO` deliberately — it's an unexpected end-user-visible failure, not a routine event. Existing `test_import_bundle_sidecar_400_passes_through` now also asserts the reject reason AND the filename appear in caplog, so the support-bundle-includes-the-diagnostic contract is pinned. Doesn't fix #1312's actual root cause (sidecar-side, still under investigation with reporter) — but the next reporter we get on this code path will produce a bundle that contains the answer.
|
||||
|
||||
@@ -26,6 +26,7 @@ class MappedSpoolFields(TypedDict):
|
||||
subtype: str | None
|
||||
brand: str | None
|
||||
color_name: str | None
|
||||
color_name_is_synthesized: bool
|
||||
rgba: str | None
|
||||
label_weight: int | None
|
||||
core_weight: int | None
|
||||
@@ -276,7 +277,12 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
|
||||
# prefix (the same string the `subtype` field already carries — typically
|
||||
# "Basic Red" / "PLA+ Black" / etc.) so the user can tell spools apart at
|
||||
# a glance even on Spoolman installs that don't fill color_name.
|
||||
color_name: str | None = filament.get("color_name") or subtype or None
|
||||
# color_name_is_synthesized: surfaced so the edit form can avoid prefilling
|
||||
# the synth value back into the input, which would otherwise round-trip the
|
||||
# subtype string as if it were a user-set color_name (#1319).
|
||||
stored_color_name = filament.get("color_name") or None
|
||||
color_name: str | None = stored_color_name or subtype or None
|
||||
color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
|
||||
|
||||
nozzle_temp_raw = filament.get("settings_extruder_temp")
|
||||
nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
|
||||
@@ -286,6 +292,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
|
||||
"material": material,
|
||||
"subtype": subtype,
|
||||
"color_name": color_name,
|
||||
"color_name_is_synthesized": color_name_is_synthesized,
|
||||
"rgba": rgba,
|
||||
"brand": vendor.get("name") or None,
|
||||
"label_weight": label_weight,
|
||||
|
||||
@@ -564,7 +564,13 @@ async def update_spool(
|
||||
material = data.material if data.material is not None else cur_mat
|
||||
subtype = data.subtype if data.subtype is not None else cur_subtype
|
||||
brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
|
||||
color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None)
|
||||
# color_name uses model_fields_set so explicit null (clear) is distinguishable
|
||||
# from "field omitted" (don't touch). find_or_create_filament's convention:
|
||||
# None = don't touch, "" = explicit clear, "value" = set.
|
||||
if "color_name" in data.model_fields_set:
|
||||
color_name = data.color_name if data.color_name is not None else ""
|
||||
else:
|
||||
color_name = cur_filament.get("color_name") or None
|
||||
cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
|
||||
rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
|
||||
label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
|
||||
|
||||
@@ -637,6 +637,26 @@ class SpoolmanClient:
|
||||
vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
|
||||
|
||||
if material_match and name_match and color_match and vendor_match:
|
||||
# #1319: color_name is not part of the match key, but if the
|
||||
# caller passed a value that differs from what's stored, update
|
||||
# the filament — otherwise the user's edit is silently dropped
|
||||
# and the inventory read falls back to subtype, making it look
|
||||
# like color_name "reverts" to the subtype on every save.
|
||||
# Convention: None = "don't touch"; "" = explicit clear; any
|
||||
# other string = set/update.
|
||||
if color_name is not None:
|
||||
existing = (f.get("color_name") or "").strip()
|
||||
requested = color_name.strip()
|
||||
if requested != existing:
|
||||
payload_value: str | None = requested if requested else None
|
||||
try:
|
||||
await self.patch_filament(f["id"], {"color_name": payload_value})
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to update color_name on filament %s: %s",
|
||||
f["id"],
|
||||
e,
|
||||
)
|
||||
return f["id"]
|
||||
|
||||
filament = await self.create_filament(
|
||||
|
||||
@@ -321,6 +321,42 @@ class TestSpoolmanInventoryCRUD:
|
||||
assert response.status_code == 200
|
||||
mock_spoolman_client.update_spool_full.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_with_explicit_null_color_name_clears(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
spoolman_settings,
|
||||
mock_spoolman_client,
|
||||
):
|
||||
"""#1319 follow-up: explicit color_name=null in the PATCH body means
|
||||
"clear" — route translates it to "" so find_or_create_filament patches
|
||||
the matched filament with color_name=None."""
|
||||
payload = {"color_name": None}
|
||||
response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
|
||||
assert response.status_code == 200
|
||||
mock_spoolman_client.find_or_create_filament.assert_called_once()
|
||||
kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
|
||||
assert kwargs["color_name"] == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_without_color_name_keeps_current(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
spoolman_settings,
|
||||
mock_spoolman_client,
|
||||
):
|
||||
"""#1319 follow-up: when color_name is omitted from the PATCH body the
|
||||
current value is kept — None passed to find_or_create_filament means
|
||||
"don't touch"."""
|
||||
payload = {"note": "only updating note"}
|
||||
response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
|
||||
assert response.status_code == 200
|
||||
kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
|
||||
# SAMPLE_SPOOLMAN_SPOOL has no color_name field, so cur fallback is None.
|
||||
assert kwargs["color_name"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_spool_not_found(
|
||||
|
||||
@@ -179,6 +179,23 @@ class TestMapSpoolmanSpool:
|
||||
}
|
||||
result = _map_spoolman_spool(spool)
|
||||
assert result["color_name"] == "Sunrise Orange"
|
||||
# Real stored value — not synthesised from subtype.
|
||||
assert result["color_name_is_synthesized"] is False
|
||||
|
||||
def test_color_name_flags_synthesized_when_falling_back_to_subtype(self):
|
||||
"""#1319: when the read falls back to subtype, the response must flag it
|
||||
so the edit form doesn't round-trip the synth value back to Spoolman."""
|
||||
spool = {
|
||||
**MINIMAL_SPOOL,
|
||||
"filament": {
|
||||
**MINIMAL_SPOOL["filament"],
|
||||
"name": "PLA Basic Red",
|
||||
# No color_name field.
|
||||
},
|
||||
}
|
||||
result = _map_spoolman_spool(spool)
|
||||
assert result["color_name"] == "Basic Red"
|
||||
assert result["color_name_is_synthesized"] is True
|
||||
|
||||
def test_color_name_falls_back_to_subtype_when_field_missing(self):
|
||||
"""Spoolman doesn't standardise color_name; the LinkSpoolModal would
|
||||
@@ -216,6 +233,8 @@ class TestMapSpoolmanSpool:
|
||||
result = _map_spoolman_spool(spool)
|
||||
assert result["subtype"] is None
|
||||
assert result["color_name"] is None
|
||||
# No synth happened — nothing to fall back to.
|
||||
assert result["color_name_is_synthesized"] is False
|
||||
|
||||
def test_color_hex_with_hash_prefix_stripped(self):
|
||||
spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}}
|
||||
|
||||
@@ -322,6 +322,81 @@ class TestFindOrCreateFilament:
|
||||
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000)
|
||||
assert result == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patches_color_name_on_existing_filament_when_changed(self, client):
|
||||
"""#1319: color_name is not part of the match key, so when a caller
|
||||
updates a spool with a new color_name and the material/name/color/vendor
|
||||
still match an existing filament, the existing filament's color_name
|
||||
must be patched — otherwise the user's edit is silently dropped."""
|
||||
existing = {**SAMPLE_FILAMENT, "color_name": None}
|
||||
with (
|
||||
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
|
||||
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
|
||||
patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
|
||||
):
|
||||
result = await client.find_or_create_filament(
|
||||
"PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
|
||||
)
|
||||
assert result == 7
|
||||
mock_patch.assert_called_once_with(7, {"color_name": "Sunny Yellow"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_patch_when_color_name_unchanged(self, client):
|
||||
existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
|
||||
with (
|
||||
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
|
||||
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
|
||||
patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
|
||||
):
|
||||
result = await client.find_or_create_filament(
|
||||
"PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
|
||||
)
|
||||
assert result == 7
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_patch_when_color_name_empty(self, client):
|
||||
"""An empty/None color_name should not clobber an existing value."""
|
||||
existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
|
||||
with (
|
||||
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
|
||||
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
|
||||
patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
|
||||
):
|
||||
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name=None)
|
||||
assert result == 7
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clears_color_name_when_empty_string_passed(self, client):
|
||||
"""#1319 follow-up: empty string means "explicit clear" — the route
|
||||
layer translates a wire-level null into "" so the user can blank the
|
||||
field on a previously-set spool."""
|
||||
existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
|
||||
with (
|
||||
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
|
||||
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
|
||||
patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
|
||||
):
|
||||
result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="")
|
||||
assert result == 7
|
||||
mock_patch.assert_called_once_with(7, {"color_name": None})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_failure_does_not_block_match(self, client):
|
||||
"""A patch_filament failure must not prevent returning the matched id —
|
||||
save should still link the spool to the correct filament."""
|
||||
existing = {**SAMPLE_FILAMENT, "color_name": None}
|
||||
with (
|
||||
patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
|
||||
patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
|
||||
patch.object(client, "patch_filament", AsyncMock(side_effect=SpoolmanUnavailableError("boom"))),
|
||||
):
|
||||
result = await client.find_or_create_filament(
|
||||
"PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
|
||||
)
|
||||
assert result == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_filament_when_no_match(self, client):
|
||||
new_filament = {"id": 99, "name": "PETG Pro"}
|
||||
|
||||
@@ -2345,6 +2345,11 @@ export interface InventorySpool {
|
||||
material: string;
|
||||
subtype: string | null;
|
||||
color_name: string | null;
|
||||
// True when color_name was synthesised from subtype because Spoolman has no
|
||||
// stored value (Spoolman-backed inventory only). The edit form uses this to
|
||||
// leave the input blank, so the user doesn't round-trip the synth value
|
||||
// back to Spoolman as if it were a real user-set color_name (#1319).
|
||||
color_name_is_synthesized?: boolean;
|
||||
rgba: string | null;
|
||||
// Multi-colour gradient stops (#1154): comma-separated 6/8-char hex.
|
||||
extra_colors: string | null;
|
||||
|
||||
@@ -303,7 +303,11 @@ export function SpoolFormModal({
|
||||
material: spool.material || '',
|
||||
subtype: spool.subtype || '',
|
||||
brand: spool.brand || '',
|
||||
color_name: spool.color_name || '',
|
||||
// #1319: leave color_name blank when the backend reports it was
|
||||
// synthesised from subtype — otherwise the form would round-trip
|
||||
// the synth value to Spoolman on save as if the user had set it,
|
||||
// which is what produced the "color reverts to subtype" symptom.
|
||||
color_name: spool.color_name_is_synthesized ? '' : (spool.color_name || ''),
|
||||
rgba: validRgba,
|
||||
extra_colors: spool.extra_colors || '',
|
||||
effect_type: spool.effect_type || '',
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BfJWmysU.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D5ddq2Sh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BkYu3kLs.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user