87 Commits
Author SHA1 Message Date
Donkie 7fbe604cc2 Keep an explicit 0 tare when measuring a spool
measure() treated a spool_weight of 0 the same as a missing one and fell
back to the filament's tare, and since #1164 the vendor's. A refill wound
on no spool has a real tare of 0, so its scale reading was short by the
filament's spool weight. Only a missing tare now falls back.
2026-10-10 11:07:37 +02:00
Donkie 30928407a7 Catalog lists: every sort and filter, plain #ids
The filament and manufacturer lists now sort by every field of the
filament or manufacturer, their custom fields included, plus spool and
filament counts, and offer the same filters as the spool view. A filter
on the spools themselves leaves out the filaments with none, as it does
there.

/spool/group takes the matching sort keys: filament.<column>,
filament.vendor.<column>, filament.extra.<key>,
filament.vendor.extra.<key> and group.filament_count. Keys it can't
resolve are still ignored.

Rows show the id as #id on the left like spool rows, and the Library
tab menu loses its descriptive sublines.
2026-10-10 10:02:26 +02:00
Donkie 02f5c07c13 Add filament and manufacturer lists to the Library
The Library gets two power-user sub-views, /?view=filaments and
/?view=manufacturers, reached from a menu on the Library tab, the search
panel and the manufacturer inspector. Rows are lists rather than tables so
translated labels and long names never widen them. The spool view is
unchanged.

The group endpoint gains what the lists need, all additive: a
group.vendor_name sort, include_empty for group_by=vendor, filament_count
on vendor groups, an opt-in preview of a group's first spools or
filaments, and X-Total-Spools / X-Total-Remaining-Weight headers.
2026-10-10 10:02:26 +02:00
Niko11111 9ae9827f4c Resolve the empty spool weight up to the vendor when measuring
measure() walked the tare from the spool to its filament and then fell back to
zero, so a spool whose tare only exists on its vendor was recorded as if it had
no core at all. A filament copies its vendor's empty spool weight in create(),
which makes that inheritance a snapshot: a tare given to a vendor later never
reaches the filaments that already exist, and the value is then known one level
up while measure() treats it as absent.

With a vendor tare of 250 g, a 1000 g filament and a 704 g reading, the spool
came back with 704 g remaining and 296 g used instead of 454 g and 546 g.

The vendor is now selected alongside the filament in the query that already
runs, and its tare is the level below the filament's, in the same order
create() resolves the value in. The join is an outer join on purpose:
filament.vendor_id is nullable, and a filament without a vendor still has to
return its row, or measuring it fails with "Filament not found for spool".

Fixes #1117
2026-09-16 01:54:14 +02:00
Donkie 6520ce6d1b Let NFC tags identify filaments as well as spools
A tag can now be linked to a filament type, not just to one spool of it. The
tag table already carried filament_id and target_type for exactly this, so
there is no migration.

API, all additive:

  POST   /filament/{id}/tag        link; 409 if anything else holds the UID
  DELETE /filament/{id}/tag/{uid}  unlink
  GET    /filament?tag=<uid>       exact lookup on the normalized UID
  Filament.tags                    also inside every spool's embedded filament
  TagScan.matched_filament_id      always present, like matched_spool_id
  TagConflictMessage.filament_id   names a filament holder on a 409

One UID still identifies one thing across both kinds: a spool's tag cannot be
linked to a filament or the reverse, and neither ?tag= lookup answers with the
other kind. The scan debounce key now includes the kind, since spool 7 and
filament 7 are different answers.

SpoolTag and SpoolTagParameters are renamed Tag and TagLinkParameters while
they are unreleased, so the schema does not describe filament tags as spool
tags forever.

client_v2: the filament inspector gets the Tags section, the link dialog names
and moves a tag held by either kind, and scanning a filament's tag opens that
filament when auto-navigate is on.

Tag having two delete-orphan parents was checked on SQLAlchemy 2.0.51: new tags
flush, unlinking deletes the row, and deleting either parent cascades.

Proven on sqlite, postgres, mariadb and cockroachdb: 558 integration tests
each. Unit tests, svelte-check and the Playwright tag specs pass.
2026-09-13 10:10:13 +02:00
Donkie 931e3c9b3b Merge remote-tracking branch 'origin/master' into worktree-nfc-design-docs
Master split filter application in two: the filters a filament can answer on
its own (_apply_filament_filters) now serve both the spool query and the new
include_empty query, which selects FROM the filaments so that a filament with
no spools still produces a group.

The tag filter stays where it was, in _apply_spool_filters, and the comment
there now says why it cannot move: a tag is linked to a spool, so a filament
on its own can no more answer "which tag is on you" than it can answer "which
shelf are you on". Nothing routes a tag filter into the filament-driven query
-- it never calls this builder, and the group endpoint takes no `tag` -- so
there is no silently-dropped filter to guard against.
2026-08-23 10:06:30 +02:00
Donkie 6dce8717a0 Fold the empty-group query back into find_groups
The first cut of include_empty grew a second 177-line function next to
find_groups that repeated its filters, ordering, pagination and hydration, so
every future change to grouping had to be made twice or silently diverge.

Only the FROM ever differed. Writing the aggregates so they hold for a group
with no spools in it -- COUNT over a column instead of COUNT(*), one explicit
zero for the one SUM -- removes the need for a second set, and with it the
COALESCE that CockroachDB rejected and the CAST that worked around it. What is
left is one query with a branch over how its rows are selected, and one shared
tail.

Spool-level filters are now refused alongside include_empty rather than quietly
producing a contradiction (every filament as empty, or no empty groups at all).
That also leaves the archived rule as the only spool-level condition the
filament-driven query has to carry, in its JOIN.

Group ordering now breaks ties on the grouped column. Every ordering can tie,
and empty groups tie on all four aggregates, so paging could otherwise repeat or
drop a group between pages.
2026-08-18 18:04:34 +02:00
Donkie 33ad8d7173 Show filaments you have no spools of
Grouping by filament aggregates over spools, so a filament you own no spools of
could never produce a group and simply vanished from the library. A filtered
view then looked fully stocked when it was the opposite, and the thing you
needed to re-order was the one thing not on screen (#1092).

GET /api/v1/spool/group takes include_empty, which inverts the query: spools are
aggregated per filament in a subquery that the filament list is LEFT JOINed
against, so filaments with no matching spools survive with their aggregates
folded to zero. Which side of that join a filter goes is the whole subtlety, and
the docstring spells it out - spool-scoped conditions have to stay in the
subquery or they delete the very rows the flag exists to keep.

The client asks for it while grouping by filament and every active filter is one
a filament can answer on its own. "Which of these are on Shelf A" has no answer
for a filament that is nowhere, and including them there would bury the shelf
under the whole catalogue. Such a group renders as an out-of-stock row: no
disclosure, no fetch, and a marker in place of the zeroes it would otherwise
report.

The group header also picks up the truncation tooltip added in the previous
commit. Its title sits under a link stretched across the whole row, so the
pointer never lands on the text itself and the tooltip has to hang off the row.
2026-08-18 17:33:29 +02:00
Donkie d43552bd26 Generalise the tag table beyond spools before it ships
A tag maps a UID to "the thing to bring up", and only some of those things are
spools -- or rows at all. Locations are the case that settles the shape: a
location in Spoolman is a String(64) on Spool, not a table, so a tag meaning
"show me Shelf A" can never be a foreign key.

Getting this wrong is asymmetrically expensive. Renaming the table, dropping
spool_id's NOT NULL and adding a discriminator all mean altering a populated
table on four databases; adding an endpoint or a target kind later is additive
and free. The migration has shipped to nobody -- it is not on master and
v0.26.1 predates it -- so the schema is settled now and the unshipped revision
is rewritten in place rather than chased with a second one.

spool_tag becomes tag:

  uid           unchanged, still globally unique -- one physical tag, one thing
  target_type   which kind of target is in force, rather than leaving readers
                to infer it from whichever column is non-null
  spool_id      nullable now; targets that ARE rows keep real foreign keys, so
  filament_id   the database still cascades deletes and refuses orphans
  target_value  targets that are not rows; a new kind of this sort costs no
                migration at all, only a new target_type string

Exactly-one-target is enforced in database/tag.py rather than by a CHECK: no
migration in this tree uses one, and MySQL below 8.0.16 parses and silently
ignores them, so a constraint here would be real on three databases out of
four. Single write path beats a constraint that is only sometimes a constraint
-- the same argument, in the same module, as normalize_uid.

filament_id gets a column and a foreign key but no ORM relationship: nothing
writes filament tags, and a selectin collection on Filament would add a query
to every filament listing for rows that cannot exist. ON DELETE CASCADE holds
integrity until the feature that needs the relationship arrives.

No API surface changes. POST /spool/{id}/tag, ?tag=, the spool response's tags
and matched_spool_id are all exactly as they were; the reads are just explicit
now about wanting a spool rather than assuming the tag they found is one.
TagConflictError's spool_id becomes optional for the same reason, which the
client already handled -- it only offers "move it here" when there is an id.

Proven on sqlite, postgres, mariadb and cockroachdb: 526 integration tests each.
2026-08-15 09:35:13 +02:00
Donkie d67a2afbe4 Merge remote-tracking branch 'origin/master' into worktree-nfc-design-docs 2026-08-14 23:14:49 +02:00
Donkie de533bc74d NFC phase 1 backend: tag identity, lookup, and the scan relay
Implements the backend half of design/nfc-phase-1-tag-identity.md. Format
agnostic: it keys only on a tag's hardware UID, so it works with blank NTAG
stickers, Prusa NFC-V tags, and the Bambu/Creality/Qidi MIFARE tags whose
contents Spoolman can never decrypt.

Answers the question the whole external ecosystem has been working around:
which spool is this physical tag? Every project so far invented its own way to
smuggle a Spoolman id into the tag contents, which fails the moment the tag is
a vendor tag you cannot write to.

Data model: new spool_tag table, additive only, with a unique index on uid.
Many tags per spool, since copying a vendor tag onto a blank sticker leaves one
spool with two UIDs. UIDs are normalized to uppercase hex with separators
stripped, in the database layer rather than a Pydantic validator, so the unique
constraint holds for callers that never arrive over HTTP.

API, all additive:
  GET    /spool?tag=<uid>          exact match on the normalized UID
  POST   /spool/{id}/tag           201, 409 with the conflicting spool id
  DELETE /spool/{id}/tag/{uid}     204
  POST   /tag/scan                 broadcast and return the match
  GET    /tag/reader               readers seen recently
  WS     /tag/scan[/{reader_id}]   the relay

Link and unlink emit the existing spool event, so clients need no new wiring.
Spool responses gain a tags array.

The relay decouples where the reader is from where the user is looking: an
agent POSTs a scan, a paired browser reacts. Scans are ephemeral, never
persisted, and go through a SECOND WebsocketManager instance rather than the
shared one. The shared tree propagates to subscribers at every level, and the
root /api/v1/ pool means "any change", so putting scans there would inject a
novel resource into every existing consumer's stream. An integration test pins
that they never reach the entity websockets.

Repeated identical (uid, reader_id) scans are debounced before the broadcast
only, since readers re-read a tag that is sitting still. The response always
carries the match, so a de-duplicated scan never looks to a device like a
failed lookup.

Migration creates the table and its indexes in one revision, following the
extra-fields tables rather than the doc's suggested split. The DDL/DML split
cockroachdb needs does not apply when there is no data migration, and the 4-DB
suite confirms it.

Verified: 506 integration tests on sqlite, postgres, mariadb and cockroachdb;
263 unit tests; ruff clean.

Frontend, docs, and the #783 action links are not included.
2026-08-13 18:56:35 +02:00
Donkie 2c84acfd24 Filter dates with the grammar the extra fields already use
The two commits before this one invented `<field>_after`, `<field>_before`
and `<field>_unset` for the built-in timestamps. That was a third way to
ask a question this API already answers: extra-field filters have taken
`<start>|<end>` for datetimes and `<min>:<max>` for numbers since v0.26.0,
with an empty value meaning "unset", the same as it means for `location`
or `lot_nr`.

Two grammars for one question is bad on its own, but the split was worse
than cosmetic. Someone who learned `extra.opened_on=2025-01-01T00:00:00Z|`
and tried `last_used=2025-01-01T00:00:00Z|` got every spool back, because
an unknown query param is silently ignored.

So the eight parameters become three, named after their columns and
taking the shipped grammar:

    /spool?last_used=2026-08-01T00:00:00Z|      since
    /spool?last_used=|2026-01-01T00:00:00Z      until
    /spool?last_used=<start>|<end>              between
    /spool?last_used=                           never used

Commas OR several of those, and quotes are accepted and ignored, so every
filter on the endpoint is spelled the same way. `_unset` disappears: the
empty value already means unset everywhere else here. No new convention
enters the API, and the next filterable column costs one parameter rather
than three.

Two consequences worth naming. Malformed input is now a 400 with a message
instead of FastAPI's automatic 422, matching the other unparseable filters
on this endpoint. And "has a timestamp" (the old `_unset=false`) has no
spelling any more, because the shipped grammar has none; nothing offered
it in the UI.

The client is unchanged apart from how it builds the request. The chip
value in the app URL keeps its own grammar, since it also carries relative
offsets and "never", neither of which the API takes.
2026-08-09 15:18:46 +02:00
Donkie b6d220a3ef Filter the library by spools that have never been used
A date range cannot express "no date at all": a NULL fails every
comparison, so no bound, however far back it reaches, selects the spools
that have never been used. That state needed a filter of its own.

Backend: /spool and /spool/group take first_used_unset / last_used_unset.
True matches only the spools missing that timestamp, false only those
that have one. Registered gets no such param — the server sets it at
creation and a spool never lacks one.

Client: the chip value `never` sits alongside the range grammar, and
Last Used / First Used offer it under their presets. It is labelled
"Never", deliberately not "unused": the list already calls a spool unused
when no weight has come off it, which is a different claim — a spool can
be unused and still carry a last_used date, or have used weight and no
date at all.
2026-08-09 11:02:26 +02:00
Donkie 179ea3b4d0 Filter the library by date range
The library could filter by every discrete property a spool has but by
none of its timestamps, so "what have I used this week" and "what has
been sitting untouched for a year" were questions the UI could not ask.

Backend: /spool and /spool/group take first_used / last_used /
registered `_after` and `_before` bounds, each optional, inclusive, and
resolved against the UTC-naive form the columns store (an offset-aware
value is converted; a naive one is read as UTC). A spool with no
timestamp matches neither end.

Client: the Filter menu gains the three timestamps, each opening presets
(last 24 hours ... older than 1 year) plus a custom range whose empty
end means open. A range lives in the URL as a chip value, `<from>..<to>`,
where an end is a calendar day or an offset back from now. Relative ends
stay relative all the way into the URL and are resolved at request time,
so a bookmarked "last 24 hours" keeps meaning the last 24 hours instead
of drifting into "since the day I made it".
2026-08-09 09:53:42 +02:00
Donkie dd39a11c7e Show every unassigned spool when grouping by an extra field
Fixes #1019. A dashboard card grouped by a custom spool field counted more
spools than it listed: the group aggregate summed them, the card's own query
returned a subset.

Grouping and filtering disagreed about what "no value" means. group_by keys on
the decoded value, so a spool holding the JSON empty string a cleared text box
writes became its own group, separate from the null one; the client renders
both as "unassigned" and adds the counts. The card then fetches its spools with
`extra.<key>=`, whose empty branch matched only NULL and JSON null - never the
empty string. Built-in string columns have matched both NULL and '' since
forever (add_where_clause_str_opt), which is why grouping by location was fine.

- The empty extra-field filter matches a stored empty string too.
- Value-keyed group axes (extra fields, location, material) fold a blank into
  NULL, so "unassigned" is one group rather than two the client can only draw
  as one.
- Emptying a text extra field clears it instead of storing "", so no new spool
  lands in the state above.
- The dashboard clears an extra field with a real JSON null. It sent the string
  'null', which the API rejects as not-a-string, so dragging a spool onto the
  unassigned card 400'd on every extra-field board.
2026-08-05 19:42:59 +02:00
Donkie b885108a3d client_v2: let a spool override the values it inherits from its filament
A spool can hold its own full weight, empty-spool (tare) weight and price,
falling back to its filament's where it has none. The API has had all three
since #339, and the v1 client exposed them, but client_v2 never mapped
spool_weight at all and had no field for it or for the full weight.

The result was silent wrong arithmetic (#1013): the API gives every spool it
creates a copy of the filament's tare weight, so correcting the *filament*
later looked like it had worked — the spool panel showed the new figure — while
"adjust by measured weight" went on subtracting the old one. A reporter's empty
261 g spool came back with 129 g still on it, exactly the difference.

Both ends of a disagreement are now marked. The spool's field says what it
shadows, the filament's row says what applies instead and is struck through,
and the same treatment covers the manufacturer's empty-spool weight, which
seeds a filament and is then left behind. Only where the values actually
differ: merely having one of its own is already visible in the field, and
marking every spool would teach everyone to stop reading the marks.

Also fixes a 500 from PUT /spool/{id}/measure when nothing in the chain has a
tare weight — reachable in one click now that the field can be cleared.
2026-08-03 18:06:05 +02:00
Donkie c8be881e03 fix(sort): keep never-used spools and filaments at the bottom
Sorting the library by "last used" led with everything that had never been
used, on PostgreSQL and CockroachDB. Those two default to NULLS FIRST on
DESC, while SQLite and MySQL treat NULL as the lowest value and put it last;
`last_used` is NULL until a spool is first used, so the default library
ordering came out inverted for half the supported databases (#984, #985).
Grouped views showed it worst: a group's date is the max over its spools, so
a filament holding nothing but unused spools aggregates to NULL and floated
to the top, above filaments used minutes ago.

order_by_clauses() now orders on an explicit "is it null" flag ahead of the
field itself, so an absent value sorts last in both directions everywhere.
It is written as `expr IS NULL` rather than SQLAlchemy's nullslast(), which
renders a literal NULLS LAST that MySQL and MariaDB reject. Applied to every
sort site -- spools, groups, filaments, vendors, extra fields -- so ordering
no longer depends on which database is underneath.

Verified against all four: 438 integration tests pass on SQLite, PostgreSQL,
MariaDB and CockroachDB, and both new regression tests fail on PostgreSQL
without the fix.

Claude-Session: https://claude.ai/code/session_01EaLK21WpQYDE1DkKwZHK3P
2026-07-31 14:38:50 +02:00
Donkie ae3208a9c3 Let an extra field's value be cleared again
Picking the empty option of a single-choice extra field did nothing --
in either client, on spools, filaments and vendors alike. Emptying an
integer, range, datetime or multi-choice field was just as stuck. There
was no way to get a value back to unset once it had one.

Nothing in the API could express it. A key left out of the map means
"leave it alone", and every value that could stand in for empty is
rejected by the field's type check: "" is not one of a choice field's
choices. So `extra` values now accept null, meaning the entity has no
value for that field -- no row is stored, which clears whatever was
there. Null was a 422 before it reached any logic, so nothing that used
to work behaves differently.

A filament's and a vendor's extra were also REPLACED wholesale by a
patch rather than merged per key, unlike a spool's since 28f6f8e
(v0.22.0). Anyone patching one field of a filament -- as client_v2 did
-- silently destroyed the rest of its custom fields; anyone relying on
the replacement to delete a key can now say so with null. All three
entities merge per key now, and the field descriptions say so instead
of saying nothing.

client_v2's saver was where the frontend half died: an emptied input
produced no patch at all. It sends the cleared key as null now, and
keeps sending only the keys that changed, which is what stops two people
editing different fields of one entity from overwriting each other. It
also captures the entity id when the edit is made -- the inspectors are
reused across selections rather than remounted, so a debounced patch
could otherwise land on whatever you clicked next.

The legacy client cleared a filament's or vendor's field by omitting it,
which the merge would now ignore, so its form sends null for a field
left empty. That also buys it clearing on spools, which merged (and so
ignored the omission) all along.

Claude-Session: https://claude.ai/code/session_01UB5KocsTBm9GL5x3Uuup5F
2026-07-30 18:11:02 +02:00
Donkie aa447dd05b Let any spool field value be renamed everywhere at once
Renaming a dashboard card only worked for Location, which has a
server-side rename that moves every spool in one statement. A custom
field had no equivalent, so the card name was read-only unless the group
was empty -- renaming it from the client would have meant patching each
spool one at a time, and only the ones the board had actually paged in.

Generalise the location rename to the spool's other string fields:

    PATCH /spool/field/{field}  {"value": "Shelf A", "new_value": "Shelf B"}
      -> {"spools_updated": 6}

where field is `location` or `extra.<key>`. Archived spools are included
-- the value is the value, and skipping them would leave half the spools
behind. Renaming onto a value already in use merges the two, though the
dashboard still refuses that so it can't happen by accident.

The old value travels in the body rather than the path, unlike
/location/{name}. Field keys are ^[a-z0-9_]+$ and so are path-safe, but
values are not: a location like "Rack A/B" needs an encoded slash, which
proxies routinely mangle. No reason to carry that flaw into the general
form.

Only fields the spool owns can be renamed. material/vendor/filament are
refused because they belong to another entity, and rewriting them "for
these spools" would change filaments that other spools share -- the same
rule that decides what the dashboard can group by, so grouping and
renaming now share one validator.

Like rename_location, no spool event is broadcast per row: the change is
one statement over what may be hundreds of spools, and that fan-out is
what the websocket layer avoids elsewhere.

Verified on all four databases, and in the browser on a 40-spool group
of which the board had loaded 30 -- all 40 moved, and the card renamed in
place without losing its position.
2026-07-30 09:08:58 +02:00
Donkie 21651216af Turn the locations page into a group-by-anything dashboard
The locations board could only ever answer one question: where is this
spool. The same card-and-drag interaction works for any field a spool
carries, so make the field a view mode and keep the rest.

Location is still the default view and behaves exactly as before. The
other views come from the user's own spool fields: any single-value text
or choice field now appears in a Group-by menu. A choice field renders a
card per choice, including the empty ones, which turns a "Status" field
into a kanban board.

Which field a card belongs to is decided by one small interface
(src/lib/dashboard/fields.ts), so the page never special-cases a field.
A field only qualifies if the SPOOL OWNS IT, because a drag moves one
spool and must mean exactly one change. That rules out material and
vendor, where writing one spool's card would silently rewrite every other
spool of the same filament, and lot number, which is stamped on by the
manufacturer and would be recording something untrue. Those stay library
filters. It also rules out numbers, dates, booleans and multi-choice,
whose values don't name a card you can drag into.

Backing changes:

- /spool/group grows group_by=extra.<key> for a spool's custom fields.
  The field table is aliased so it stays a distinct FROM element -- the
  extra-field filters reference the un-aliased table inside correlated
  subqueries, and without the alias those would correlate to this join
  instead of standing alone. Grouping is rejected for field types whose
  value isn't a single repeatable string. group_by is no longer a
  Literal, so an unsupported value is a 400 rather than a 422.

- The saved layout is per field: dashboard_groups and
  dashboard_spoolorders replace the location-only pair. The old
  `locations` / `locations_spoolorders` are read once as a seed for the
  location view and never written back, so an upgrade keeps the shelves
  it had and the old client keeps working.

- The view mode lives in the URL (?by=) so a board can be bookmarked and
  shared, and is mirrored to localStorage so a bare /dashboard resumes
  the last one looked at. The URL wins when it says something; a stale
  ?by= naming a deleted field falls back to location.

Verified against SQLite, PostgreSQL, MariaDB and CockroachDB -- Postgres
caught a real difference here, it sorts NULLs last where SQLite sorts
them first, so the new group assertions are keyed rather than ordered.
2026-07-30 08:41:36 +02:00
Donkie b369cf049e Clamp grouped remaining weight at 0 for over-used spools
Spool.from_db reports remaining_weight as max(initial - used, 0), but
find_groups' remaining_expr omitted the clamp. A spool used past empty
therefore contributed a negative number to total_remaining_weight, so a
group's total no longer matched the sum of the spools it contains — two
500 g spools, one with 200 g used and one with 800 g, reported 0 g instead
of 300 g.

Mirror the clamp with CASE rather than func.greatest, which is not portable
across all four supported databases. Covered by a new integration test.
2026-07-28 20:27:59 +02:00
Donkie 29f0f362d4 client_v2: add multi-color filament support
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
2026-07-18 23:42:53 +02:00
Donkie d7486d5d51 Improved filtering/sorting. Allows filtering/sorting by extra fields. 2026-07-18 15:54:58 +02:00
Donkie c82e3911e1 WIP: add v2 client (Svelte) with spool grouping backend
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.
2026-07-12 19:21:34 +02:00
470d8f3bcc Add filtering and sorting for custom fields (#773)
* 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>
2026-07-08 10:14:27 +02:00
Donkie 9b6af119c6 Fix commit-before-notify races in write paths (flaky postgres deletes)
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).
2026-07-06 15:53:40 +02:00
Donkie b942d1f8a1 Replaced pdm with uv, updated backend deps, python 3.10 minimum, simplified install script 2026-01-07 22:01:02 +01:00
Thorsten Lanfer 55acc4993d reformat 2025-03-05 19:46:54 +01:00
Thorsten Lanfer 4b8fd3aed3 Update spool.py 2025-02-24 22:21:10 +01:00
Thorsten Lanfer 28f6f8e916 Allow posting partial updates of extra attributes 2025-02-24 22:21:10 +01:00
Donkie 37b6d1fcee Added ability to rename location 2024-11-20 21:00:39 +01:00
Donkie 86f69d470f Added backend supported csv/json export functionality 2024-10-27 12:36:39 +01:00
Donkie 32c737fa98 Revert so commit now happens before notice, but add a catch-all to websocket notifications 2024-08-16 15:13:09 +02:00
Donkie e5f79493cc Fixed error on trying to send ws message to disconnected client
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.
2024-08-16 10:28:22 +02:00
Donkie 535fa40ad2 Updated most python dependencies
Primarily FastAPI and Pydantic to v2. Also ruff to latest.

Updated some code to support Pydantic v2
2024-05-23 19:42:27 +02:00
Donkie ca545b72b9 Fixed spool price sorting not working
Resolves #349
2024-05-20 22:42:44 +02:00
Donkie c4693d3f34 Implemented used_length spool sort 2024-05-15 20:18:24 +02:00
Donkie 51c3b46f1f Fixed wrong remaining weight and length spool sort 2024-05-15 20:17:00 +02:00
Matt Gerega fdec032d19 Updated handling of higher measurements 2024-04-15 16:41:14 -04:00
Matt Gerega c6f9abeced Changed initial_weight to be net weight, not gross weight 2024-04-10 11:00:46 -04:00
Matt Gerega 23cfb42ba8 Updated based on PR requests 2024-04-08 17:38:18 -04:00
Matt Gerega 37804c14a8 modified default settings 2024-04-08 09:27:15 -04:00
Matt Gerega a6ae808389 Added additional integration tests for measure and add 2024-03-28 09:29:39 -04:00
Matt Gerega 19c414da4b Add integration tests for new measure endpoint 2024-03-27 15:44:54 -04:00
Matt Gerega 3ea4937f24 Updated spool UIs (create/edit) 2024-03-27 14:47:05 -04:00
Matt Gerega e68f88da2a Added empty spool weight as an option field for the vendor 2024-03-26 20:35:50 -04:00
Matt Gerega 83fd3073ec Fixed formatting 2024-03-26 14:02:17 -04:00
Matt Gerega 1d6830d769 Added initial_weight and empty_weight to spool 2024-03-26 13:55:44 -04:00
Donkie c2335366bb Fixed sorting by remaining weight/length 2024-02-02 22:10:11 +01:00
Donkie 290eb5cb81 Added backend ability to sort by combined_name 2024-01-30 18:20:53 +01:00