Phase 1 could only create the owner. Every other account had to be made with
python -m spoolman.cli, which meant shell access on the server was a
prerequisite for having a second user at all. require_admin() has existed since
phase 1 with no callers; these are its first.
Two accounts are shielded from demotion, disabling and deletion, and both
protections exist to stop an instance being administered into a state nobody
can recover from through the interface:
The owner is untouchable by anyone, themselves included. It is the account that
can always fix the others, so it must not be possible to remove the last thing
that can grant rights back.
An administrator cannot demote, disable or delete themselves. Somebody else
with the rights has to do it, which guarantees a second pair of hands still
exists afterwards.
Neither is a security boundary -- both are recoverable with the CLI, which
remains the escape hatch when no working account is left. They are there to
make the obvious mistake impossible, not to withstand an attacker who is
already an administrator.
The protections are consulted only on the changes that actually take something
away. Granting admin is unrestricted, and re-sending an unchanged account from
a form is not refused just because the form included the owner's own level --
_collect_changes compares against the stored row first, so a no-op is a no-op.
Passwords may be supplied or generated. A generated one is returned exactly
once, in the response that created it, which mirrors what the CLI already does
and avoids an administrator inventing "Password123" for somebody. Any reset
ends every session that account holds: whoever knew the old password may be
precisely who the reset is defending against, and their cookie would otherwise
outlive the credential it was obtained with. Disabling an account does the
same, so the state in the browser agrees with the state on disk.
Deleting a user takes its sessions and API keys with it and detaches its audit
entries. The update path goes through a field whitelist in the data layer, so a
mistake in the API layer cannot reach password_hash, is_owner, or the lockout
counters.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
Phase 1 created auth_api_key and left PrincipalKind.APIKEY unproduced. This
fills both in, so a printer or a script has a credential that is not somebody's
password.
A key is presented as spoolman_<prefix>.<secret>. The marker exists so a leaked
key is recognisable in a log or a paste, and the separated prefix is what makes
authentication one indexed lookup instead of a scan that verifies every stored
hash in turn. The separator is a full stop because both halves are url-safe
base64, whose alphabet already contains '-' and '_'. Secrets are hashed with
the same plain SHA-256 as session tokens and for the same reason: 256 bits from
a CSPRNG has no dictionary to attack, so scrypt on the hot path of every
printer poll would cost 50 ms per request to buy nothing.
Three restrictions are the substance of this, and each has a failure mode it
exists to prevent:
A key's level is capped against its owner's *current* level on every request
rather than baked in at creation, so demoting a user weakens their outstanding
keys immediately instead of leaving credentials in the wild that outrank the
account they belong to. Verified against a running server: a key issued at
manage drops to read the moment its owner is demoted, both in enforcement and
in what the listing reports.
A key never carries administrative rights, however senior its owner, so
everything behind require_admin needs a session.
require_user now refuses keys outright, which is what gates the key endpoints
themselves. A credential that can mint another credential can never really be
revoked -- whoever held a leaked key would issue themselves a fresh one ahead
of the withdrawal.
Keys skip CSRF, as cookies.py anticipated in phase 1. The check in
require_level was already conditioned on PrincipalKind.USER, so this needed no
code change; what it needed was the comment saying why a header that a
cross-site page cannot set is not an ambient credential and has nothing to
defend against.
/metrics accepts a key in addition to SPOOLMAN_METRICS_TOKEN rather than
instead of it. The shared token needs no database and no account, which is what
an operator scraping their own instance wants and what keeps working when the
database is down; a key is attributable and revocable through the interface,
which is what you want when the scraper belongs to somebody else. Only keys are
honoured there, never session cookies, so a signed-in browser tab does not
quietly make the endpoint readable.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
Phase 1 created auth_audit_log and deliberately left spoolman/auth/audit.py
unwritten, because an empty stub fails the docstring lint. This writes it.
What is recorded is every sign-in attempt plus anything that alters
permissions. Ordinary data changes are deliberately excluded: creating a spool
is already visible in the spool, and mixing those in would bury the handful of
security-relevant events under thousands of routine ones, which is how audit
logs stop being read.
Three properties are load-bearing:
Writing never breaks the operation it describes. record() swallows and logs
its own failures, and uses its own database session rather than the caller's.
Login attempts have already committed by the time they are audited, several
call sites hold no session at all, and sharing one would let an audit write
roll back the thing it was recording.
The response and the log disagree on purpose. A failed sign-in tells the
caller only "incorrect username or password" -- distinguishing the cases would
let anyone enumerate accounts -- while the audit entry records which case it
was. Its readers are administrators who already know which accounts exist, and
"somebody is trying to sign in to the account you disabled" is exactly what a
log is for.
Entries outlive their subjects. Deleting a user detaches their entries rather
than cascading, since a log that erases what an account did the moment the
account is removed is not evidence of anything. The username survives in the
target column, so the trail stays readable.
Retention is a database setting rather than an environment variable: it is a
policy an administrator revises, not a deployment parameter, and changing it
should not need a restart. The nightly sweep is only scheduled when auth is
enabled, so existing instances do not grow a job that can never do anything.
It runs half an hour after the backup so the two do not contend for SQLite's
single write lock.
Reading the log needs admin. It holds addresses, user agents and the list of
accounts, so serving it at 'read' would hand an anonymous browser a map of the
instance's users.
Claude-Session: https://claude.ai/code/session_01J3YgMAnSapoXqS9iU8Xnmk
Adds `python -m spoolman.cli`, the way back in when nobody can sign in: a
forgotten owner password, a disabled account, an owner who has left. This is
why the design has no per-user recovery codes.
docker exec -it spoolman python -m spoolman.cli auth status
Commands: auth status; user list/create/reset-password/set-level/enable/
disable; session revoke-all. Generated passwords are printed once.
Module form rather than a console script, because the Docker image runs
Spoolman from source and there is no installed distribution to hang an entry
point on. argparse rather than a dependency: eight subcommands do not
justify adding click or typer to the runtime image.
It never runs migrations. If the auth tables are missing it says so and
exits 2, because the fix is to start the server once and let it migrate --
having two things that can migrate the schema is how databases get hurt.
It deliberately works while SPOOLMAN_AUTH_ENABLED is unset, since the
intended first run is to create an owner and only then turn auth on.
Fixes a bug found by running it: "user create <name> --owner" produced an
owner at level read, because the --level default won over the flag. An owner
who cannot change anything is useless and would have sent someone straight
back to the CLI. --admin and --owner now imply manage, and an explicit lower
--level is reported rather than silently ignored.
Adds database.get_engine(), since the schema check needs the engine and the
Database instance is module-private.
Verified against a real database: refuses to run before migrations (exit 2);
reports an unclaimed instance; creates owner, edit and admin accounts with
the right levels; refuses a second owner and a duplicate username; refuses
to disable the owner; reset-password ends existing sessions and sets
must_change_password. Most importantly, end to end: a password generated by
the CLI signs in through the API, arrives with must_change_password set, and
carries the owner's manage level for both reads and writes.
Adds the user and session data layer, the dependency gates that enforce
permission levels, and the coverage check that proves no route was left
ungated. Nothing is wired to a route yet -- that is the next commit.
database/auth_user.py, database/auth_session.py
Follow the vendor.py template, with one deliberate departure: no websocket
notifier. websocket_manager.send fans out to every subscriber, so
broadcasting account or session changes would hand every connected client
the details of every account. Both module docstrings say so, because the
omission otherwise looks like an oversight worth "fixing".
create() and set_password() take an already-derived hash rather than a
plaintext password. Deriving one blocks for ~50 ms, and these are called
from async handlers where that would stall the single process; callers
pick the sync or async hashing entry point according to where they run.
Sessions slide their expiry forward on use, but only once last_seen is
older than a minute. Without that throttle every authenticated request
becomes a database write, which on SQLite serialises the whole application
behind session bookkeeping.
auth/dependencies.py
require_level() short-circuits to UNRESTRICTED before any database access
when auth is off, which is what keeps the disabled path identical to
before. It does not declare a get_db_session dependency: route-level
dependencies are solved ahead of handler parameters, so doing so would
open a session on every request including /health and /info, which need no
database today and would start failing when one is unavailable. It opens
its own short-lived session only when a credential is actually present.
CSRF failure is 403, not 401. The credential is valid, so reporting
"unauthenticated" would sign the user out over what is usually a stale tab.
ws_authenticated() accepts the handshake before closing on rejection,
which looks wrong and is load-bearing. uvicorn turns a close sent before
the accept into an HTTP 403 and discards the code, and Starlette routes
WebSocketException from a dependency through that same path, so neither
can deliver 4401/4403. Accepting first is the only way the browser sees
the real code, which is what lets the client tell "signed out, stop
retrying" from "network blipped, retry". The existing close(4040) in
api/v1/setting.py has never reached a client for this reason.
auth/coverage.py
Gating routes individually is fail-open. This inverts it: walk the route
table at import and refuse to start if anything lacks a gate. It descends
through FastAPI's include_router wrappers, which are not flattened into
the parent route list -- iterating app.routes alone sees 4 of the 49
routes and would call the rest covered.
Also registers the auth_anonymous_read setting, with a comment forbidding
secrets in the settings table: settings are readable via GET /setting/ and
every write is broadcast to all websocket subscribers.
Verified: coverage check catches forgotten HTTP and websocket routes,
recognises gates declared either as dependencies= or as a handler
parameter, and honours the allow-list. Websocket matrix end to end -- auth
off passes through, no credential gives 4401, a forged cookie gives 4401, a
read-level session connects to a read route and gets 4403 on a manage
route, and path parameters still inject through functools.wraps. The
anonymous-read lookup fails closed on any error.
Adds the five auth_ tables and one DDL-only Alembic revision. Nothing reads
or writes them yet; the data layer and endpoints follow in later commits.
All five tables are created now, not just the two phase 1 needs. The reason
is migrations/env.py's `target_metadata = Base.metadata`: if the database
carried auth_api_key while models.py did not, the next autogenerated
revision anyone produced would emit op.drop_table for it. Models and schema
have to move together, so the phase 2/5 tables land as models now and gain
their business logic later.
Design notes, all following existing conventions in this schema:
- Tables are prefixed auth_ because `user` and `session` are reserved words
in PostgreSQL and MySQL, and quoting them at every call site is a footgun.
- No relationship() on any auth table. Auth lookups are single-row queries
on an indexed column, so there is nothing to eager-load; a `sessions`
collection on AuthUser would fetch every session row on every login, and
lazy-loading one under asyncio raises MissingGreenlet.
- Enum-valued columns (level, actor_kind) are String, matching
multi_color_direction. There is not one sa.Enum in this schema, and adding
one would create a native PostgreSQL type and a MySQL ENUM(...) with three
different ALTER dialects.
- Uniqueness is enforced with unique indexes rather than named UNIQUE
constraints. That is the portable form across all four dialects, and it
keeps the multi-tenancy seam cheap: auth_user.username can later become a
composite (tenant_id, username) index without dropping a constraint whose
generated name differs per dialect.
- The revision is pure DDL with no DML, which is what makes it safe as a
single revision on CockroachDB -- the aafcd7fb0e84 -> 304a32906234 split
was needed only because the second half wrote data.
The revision body is alembic's own autogenerate output against a database at
415a8f855e14, so it matches the conventions of the existing revisions rather
than being hand-approximated.
Verified on SQLite: upgrade from scratch creates all five tables with the
four unique indexes; downgrade -1 drops them all; upgrade replays cleanly.
Critically, running autogenerate again after upgrade emits an empty
migration, proving the models and the schema agree and that no future
autogenerate will try to drop the phase 2/5 tables.
The declared floor was ">=3.10", but nothing enforced it: the Docker image
has pinned python:3.14-slim-bookworm for amd64/arm64/armv7 for some time,
and scripts/install.sh runs "uv sync" without UV_PYTHON_DOWNLOADS=0, so uv
fetches a managed interpreter when the system one is too old. Every
dependency declares a floor of <=3.10 with no upper cap.
Raising it unlocks enum.StrEnum, datetime.UTC and typing.Self for the
authentication work that follows.
Consequences handled here:
- uv.lock regenerated. Four pure-backport packages drop out
(async-timeout, backports-asyncio-runner, exceptiongroup, tomli).
- ruff target-version follows to py314, which switches on PEP 649 deferred
annotations and with them a batch of previously-dormant rules. The safe
automatic fixes are applied: datetime.timezone.utc -> datetime.UTC
(UP017) and redundant quotes dropped from annotations (UP037).
- TC001/TC002/TC003 are ignored rather than applied. They want type-only
imports moved into "if TYPE_CHECKING:" blocks, which is wrong here:
FastAPI resolves Annotated[AsyncSession, Depends(...)] and websocket
parameters at runtime, Pydantic resolves field types at runtime, and
SQLAlchemy resolves Mapped[...] at mapper-configuration time. A
TYPE_CHECKING-only import is absent from the runtime namespace, so
applying them breaks dependency injection. Ruff classifies all 50 as
unsafe fixes for that reason.
Verified: ruff check/format clean, uv lock --check clean, alembic head
resolves, SQLAlchemy mappers configure with all relationships intact and
lazy="selectin" preserved on the extra-field collections.
find_by_color loaded every filament as a full ORM object (with its vendor
join) into memory and computed delta-E in Python, then the endpoint re-queried
the matched ids anyway. The full-catalog ORM load dominated the cost: a color
search ran at a flat ~100ms regardless of how few results it returned, ~5-20x
slower than an equivalent regular filtered search, and each concurrent request
cloned the whole catalog into memory.
Scan only id/color_hex/multi_color_hexes and return the matched ids; callers
load full rows for just the matches. The matching algorithm is unchanged.
Color search now scales with the match count like any other filter: ~5-16x
faster here and the concurrency memory spike (+21MB at 8 concurrent) is gone.
Drops the "Slow operation!" note from the color_hex query param since it no
longer applies.
Verified across SQLite/Postgres/MariaDB/CockroachDB (372 integration tests
pass on each).
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
Spool responses carry fields derived from the filament (remaining_length,
plus the initial_weight/price fall-backs). Editing a filament emitted only a
filament WS event, so subscribers to that filament's spools kept stale derived
values until each spool was next touched — visible in client_v2 as a length
inconsistent with the displayed density, but affecting every consumer.
filament.update now re-broadcasts the filament's spools (archived included)
as spool "updated" events after commit. Adds a WS integration test asserting
a density patch reaches /api/v1/spool with the new density and changed length.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
The spool listing already joins spool -> filament -> vendor, so joined
eager loading of all three `extra` collections multiplied their rows
together. With ~12 spool, ~9 filament and ~9 vendor extra fields, a
single GET /spool?limit=30 made the database materialize ~35k wide rows
(one per field combination) and then spent most of its time in
SQLAlchemy de-duplicating them back into 30 objects.
Switch the three `extra` relationships to lazy="selectin" so each
collection is fetched in its own WHERE id IN (...) query and nothing
multiplies. Measured on a 4911-spool SQLite database, GET /spool?limit=30
drops from 431 ms to 12 ms and limit=100 from 1358 ms to 23 ms, with
byte-identical responses across the spool/filament/vendor/group/search
endpoints and all 371 integration tests green on SQLite, PostgreSQL,
MariaDB and CockroachDB.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
The search treated the whole query as one substring match, so "bambu
petg-cf" had to appear verbatim in a single field and returned nothing,
even though "bambu" and "petg-cf" each matched on their own. Vendor-name
matching also ran as a separate query, so a term could never be satisfied
by the vendor while another was satisfied by the filament.
Split the query on whitespace and require every term to match some
searchable field, letting different terms match different fields:
- Filaments search vendor.name in the same pass as their own fields via an
outerjoin, replacing the separate vendor query. This is what makes
"bambu petg-cf" work.
- Extra-field matching moves from a follow-up query to a correlated EXISTS
inside each term's OR-clause, so extras participate in the AND too.
- Ranking generalizes to a per-field-kind base rank (native 0, vendor 2,
extra 4) with prefix hits beating substrings. A row ranks by its worst
term, so rows matching everything strongly sort first; the reported
match_field is the single best hit.
Single-term queries behave as before. Standard SQL only (ilike, correlated
EXISTS), so this holds on all four supported databases.
Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
Add a shared ColorEditor component (single/multi toggle, per-color
pickers + hex fields, add/remove colors, illustrated coaxial vs
longitudinal cards that preview the actual colors) and wire it into
both the add-spool create form and the library filament inspector,
which also gains its first color picker.
Colors and multi_color_direction round-trip through the API via a new
colorFieldsToApi helper that keys single- vs multi-color by count and
nulls out the other fields, so switching between them stays consistent.
Make color type filterable in the library toolbar (Single / Coextruded
/ Longitudinal) backed by a new additive filament.multi_color_direction
query param on the spool and spool/group endpoints; the Single option
sends an empty value to match single-color (NULL) filaments.
Also tidy a few inspector field labels to reuse the canonical
filament.fields.* / vendor.fields.* message keys.
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Extend the cross-entity /search filament results to also include filaments
whose vendor name matches the query, so searching a manufacturer surfaces its
filaments (not just the vendor itself). Vendor-name matches rank below any
native-field match and report match_field "vendor.name". The extra-field
annotation block is factored into a helper to keep _search_filaments within the
complexity budget. The client search bar labels the new match_field as
"Manufacturer".
Claude-Session: https://claude.ai/code/session_01VQQtwPBPiMCoZCpBbbqS64
Implements the search feature for client_v2: a new backend search endpoint
plus a results-panel UI, replacing the old query box that only filtered the
library list by filament name.
Backend (additive, API v1 backward compatibility preserved):
- New GET /api/v1/search returning categorized spool/filament/vendor results,
each annotated with the field that matched, plus an is_color_query flag.
- Case-insensitive text search over native text fields and text/choice extra
fields; exact spool-id match for numeric queries; color-similarity search
(CSS color name or hex) via find_by_color, ordered closest first.
- spoolman/colors.py resolves the CSS4 color names / hex codes to hex.
- Integration tests (run across SQLite/PostgreSQL/MySQL/CockroachDB in CI).
Frontend (client_v2):
- SearchBox overlay with Spools/Filaments/Manufacturers sections, per-result
match badges, keyboard navigation, a color-similarity threshold slider
(persisted to localStorage), and an English-only color-name hint.
- Clicking a result opens the matching inspector on the Library page.
- Removes the old q -> filament.name inline list filtering.
Claude-Session: https://claude.ai/code/session_01Ne7RsfHejKZX97uAq3aVMn
Introduces client_v2, a new SvelteKit-based client, alongside the
backend it depends on: a /spool/group endpoint that returns
per-group aggregates (count, in-use count, remaining weight,
last-used) grouped by filament, vendor, material or location, so the
client can paginate whole groups instead of fetching every spool.
Not yet complete/wired up for release.
* Add filtering and sorting for custom fields
* Fix tests and make code postgres compatible
* Fix bugs and expand test coverage for custom field filter/sort
* trigger CI
* fix: address extra field filter review feedback
* fix: clean up custom field table state handling
* docs: update extra field table view description
* fix: narrow boolean custom field filter syntax
* Expand test coverage for all custom field types/entities and fix range filter/sort
- Add comprehensive tests covering all 9 field types (text, integer, float,
boolean, single-choice, multi-choice, datetime, integer_range, float_range)
for filter and sort on spool, filament, and vendor entities
- Add invalid-filter 400 tests for float, integer_range, and float_range
- Fix integer_range/float_range filter to use LIKE pattern matching against
Python's deterministic json.dumps output instead of fragile .contains()
- Add integer_range/float_range sort support via a @compiles helper
(_JsonArrayFirstElement) that emits CAST(col AS JSON)->>0 on PostgreSQL
and JSON_EXTRACT(col, '$[0]') on SQLite/MariaDB
- Add logger and __all__ to extra_fields.py (aligns with PR #893)
- All tests verified passing on postgres, sqlite, and mariadb
* Add filter UI for all custom field types and fix range filters
- Add text input filter dropdown for text fields (substring search)
- Add range input filter dropdowns (min/max) for integer, float, integer_range, and float_range fields
- Add datetime picker filter dropdown for datetime fields
- Fix boolean "No" filter to use empty value so it correctly matches unset/false fields
- Fix integer_range/float_range filter to use numeric comparisons instead of LIKE-based exact matching (stored_min >= filter_min, stored_max <= filter_max)
- Add range filter support for integer/float fields (min:max format)
- Add _JsonArraySecondElement cross-database helper for extracting second JSON array element
* Range filter for integer/float fields and fix range_field filter semantics
Backend:
- Add _JsonArraySecondElement cross-database helper (mirrors _JsonArrayFirstElement)
- integer_range/float_range filter now uses numeric comparisons:
stored_min >= filter_min and stored_max <= filter_max (was LIKE exact match)
- integer/float filter now supports min:max range format in addition to exact match:
stored_value >= min and/or stored_value <= max
Frontend:
- integer and float fields now use a range filter dropdown (min/max inputs)
instead of a single exact-value input
Tests:
- Fix integer_range/float_range spool and vendor tests broken by new >= semantics
(filter values updated to discriminate between test entries)
- Add range filter tests (min only, max only, both) for integer and float fields
* Replace 12 copy-pasted numeric field tests with 4 parametrized tests
Introduce a _create_entity helper and @pytest.mark.parametrize("entity_type",
["spool", "filament", "vendor"]) to run each numeric field type test against
all three entity types from a single test function.
- Removes 12 individual tests (integer/float/integer_range/float_range × 3 entities)
- Adds 4 parametrized tests covering the same ground plus the previously missing
min-only / max-only range filter cases for filament and vendor
- Uses try/finally for cleanup so entities are always deleted even on assertion failure
* Add datetime range filter and restore integer/float range filter support
- Backend: add datetime range filter using '|' separator (ISO dates contain ':')
- Backend: restore integer/float range filter support (lost during attribution rewrite)
- Frontend: replace single DateTimePicker with From/To range pickers for datetime fields
* Use local timezone in filter picker; add range filter tests
The DateTimeRangeFilterDropdown was initializing pickers with dayjs.utc(),
causing them to display and accept UTC times directly. The entry form shows
local time and converts to UTC, so the filter was inconsistent: entering
the same displayed time would produce a filter value 2h offset from what
was stored (in UTC+2), making exact boundaries fail unexpectedly.
Fix: initialize picker values with dayjs() (local mode) so the filter picker
behaves the same as the entry form — shows local times and converts to UTC.
Tests: replace 3 separate entity-specific datetime tests with one parametrized
test covering spool/filament/vendor, and add range filter cases (start|, |end,
start|end).
* Replace all entity-specific tests with parametrized tests
Replace 16 separate spool/filament/vendor tests for text, boolean,
single-choice, multi-choice, and empty-filter with 5 parametrized tests
that run against all three entity types. Each test is now defined once
and executed 3 times, eliminating copy-paste and ensuring consistent
coverage across all entity types.
Parametrize invalid-filter-value 400 tests across all entity types
The non-numeric-value error uses "Invalid integer/float range filter value"
while the missing-colon error uses "Invalid range filter value". Both contain
"range filter value", so use that as the assertion substring.
* Align custom filter dropdowns with Ant Design choice filter style
All three custom filter dropdowns (text, number range, datetime range) now
match the built-in choice filter footer: a border-top separator, Reset as a
link button on the left (disabled when no filter is active), and OK as a
primary button on the right.
* fix(fields): correct custom-field filter edge cases across databases
- Float exact-match filters now compare numerically (cast to Float) instead of
by JSON string, so float fields stored as integer JSON (e.g. "2") or
non-canonical decimals (e.g. "2.50") match an equivalent filter.
- integer_range/float_range filter and sort no longer return HTTP 500 on
CockroachDB: the PostgreSQL '->>' JSON extraction is now also registered for
the cockroachdb dialect, which previously fell back to the unsupported
JSON_EXTRACT function.
- Text exact-match and single-choice equality use ensure_ascii=False so
non-ASCII values match how the frontend's JSON.stringify stores them.
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
* test(fields): expand custom-field filter/sort coverage
Add tests covering filter composition (multiple extra fields, extra + built-in,
filter one field / sort another), pagination total-count, unknown-field
handling, text case-insensitivity/substring/non-ASCII, numeric multi-value OR,
boolean empty semantics and token rejection, multi-choice substring collisions,
empty filters on numeric fields, null-bounded ranges, and additional
invalid-value 400 paths.
Verified against sqlite, postgres, mariadb and cockroachdb.
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
* style(client): format column.tsx with prettier
The custom-field filter dropdowns' Reset buttons were written as one-line
onClick handlers that prettier wraps across multiple lines. Apply prettier
so the CI style check passes.
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
* fix(fields): decode JSON scalars in-DB for text/choice/datetime custom-field filters
Match text, single-choice and datetime custom-field filters against the
database-decoded JSON scalar (new _JsonScalarText cross-dialect helper:
#>> '{}' on postgres/cockroachdb, JSON_UNQUOTE(JSON_EXTRACT) on mysql,
json_extract on sqlite) instead of reconstructing the exact JSON
serialization the client wrote. This decouples matching from
json.dumps/JSON.stringify encoding quirks (non-ASCII escaping, surrounding
quotes). Also escape LIKE wildcards so '%' and '_' in text and multi-choice
queries match literally rather than acting as wildcards.
Verified against sqlite, postgres, mariadb and cockroachdb.
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
* test(fields): cover LIKE-wildcard escaping in custom-field filters
Add parametrized tests (spool/filament/vendor) asserting that '%' and '_'
are matched literally in text and multi-choice filters, that a literal '/'
(the internal LIKE escape char) matches literally, and that single-choice
equality handles a '%' value exactly.
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
---------
Co-authored-by: Donkie <daniel.cf.hultgren@gmail.com>
Co-authored-by: akira69 <akira69@gmail.com>
Several DB write functions mutated and then sent their websocket notification (or
just returned) without committing, relying on the request session's teardown
commit. On async postgres that commit races the HTTP response, so a fast
follow-up read could still see the pre-write state — e.g. `DELETE /vendor/{id}`
returns 200 but an immediate `GET` still returns 200. Latent since 2023 (a6d527a);
the async-timing dependency bump widened the window enough to intermittently fail
`test (postgres)` in CI.
Commit inline before notifying, matching the functions that already did
(filament.delete, vendor/spool create/update) and the commit-first invariant from
the earlier websocket-ordering fix:
- vendor.delete, spool.delete
- setting.update, setting.delete
- vendor/filament/spool.clear_extra_field
- spool.rename_location
(use/measure already commit via their wrappers; reads are unaffected.)
Verified via `SPOOLMAN_CONTAINER_ENGINE=podman poe itest`: full 4-DB suite green
plus 7/7 repeat postgres runs with zero flakes (was ~66% failing before).
This could result in a major filament usage track failure since the Use http request would fail and Moonraker would keep retrying to save the used filament. However it was actually saved since the db.commit() happens before the websocket message fails.
added env variable SPOOLMAN_METRICS_ENABLED for enabled database collector
set metrics path as /metrics
checked Null values at vendors, init weight and price
removed some logs