Skip to content

Add filtering and sorting for custom fields - #773

Merged
Donkie merged 22 commits into
Donkie:masterfrom
daften:feature/custom-field-filter-sort
Jul 8, 2026
Merged

Add filtering and sorting for custom fields#773
Donkie merged 22 commits into
Donkie:masterfrom
daften:feature/custom-field-filter-sort

Conversation

@daften

@daften daften commented Oct 26, 2025

Copy link
Copy Markdown
Contributor

This PR adds filtering and sorting for custom fields in a generic way. This was tested in a limited capacity and doesn't encompass everything, e.g. filtering on text fields only works to filter empty items.
Nevertheless , I think this is already a useful addition.

My main use case is I have a boolean extra field that I check when I open a roll. This allows me to filter all open spools e.g.

The changes were done for all data types and several tests were written.

Disclaimer: I am not a Python developer and this was largely done using AI. Any feedback si more than welcome. High-level this looks okay to me, but I most likely miss items that are important to get this perfect. I also had issues with circular imports that I now fixed doing the imports not in the header, feedback on this is definitely more than welcome.

@daften

daften commented Dec 7, 2025

Copy link
Copy Markdown
Contributor Author

Is there something I can do to move this forward or make this ready for consideration? :)

@kubrick89

Copy link
Copy Markdown

I've cloned daften repo and i'm using this to filter extra.nfc_id created by filaman. Works great to retieve the spool id to activate the spool in moonraker.

@DanielNagy

Copy link
Copy Markdown

+1 for this to merge into master :)

@daften

daften commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Something I missed before and noticed when starting to rebase, the integration tests for postgres fail. There were also significant issues in the tests written. i've fixed the tests, but haven't had time to fix the issues with postgres, which is because of json_extract. I'll come back to that at a later time

@daften
daften force-pushed the feature/custom-field-filter-sort branch from 1ae07d5 to f545ae5 Compare February 8, 2026 22:46
@daften

daften commented Feb 8, 2026

Copy link
Copy Markdown
Contributor Author

Actually was able to fix it and the tests seem to run well and I was able to rebase everything on master. All feedback welcome :)

@daften
daften force-pushed the feature/custom-field-filter-sort branch from f545ae5 to 33c669e Compare February 8, 2026 22:46
@daften

daften commented Feb 22, 2026

Copy link
Copy Markdown
Contributor Author

I tested some more, fixed some bugs and added test coverage for more scenario's :)

@akira69

akira69 commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

I think I've also addressed this separately in my larger PRs:
#846
#858

@daften

daften commented Feb 23, 2026

Copy link
Copy Markdown
Contributor Author

@akira69 I think you mean #846 instead of #856?

i also don't see in your PR's what is encompassed here, searching on any custom field added by the user. You added a lot of other information in structured data, but this seems more generic (unless I'm looking over that part). Can you point me to where your PR's encompass what's in this PR and why this one would be superfluous?

@akira69

akira69 commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

@akira69 I think you mean #846 instead of #856?

i also don't see in your PR's what is encompassed here, searching on any custom field added by the user. You added a lot of other information in structured data, but this seems more generic (unless I'm looking over that part). Can you point me to where your PR's encompass what's in this PR and why this one would be superfluous?

yes #846: fixed that in the comment.
in general, all fields made visible in columns should be searchable
image
but just testing now when enabling a custom field it didn't allow sorting/search as expected.
I will fix that in the PR.

@daften

daften commented Feb 23, 2026

Copy link
Copy Markdown
Contributor Author

@akira69 while your intentions are noble, I have 2 small issues with your approach:

  • You embed way too much in one PR, making life harder for the reviewer(s). Considering the amount of work most open source maintainers have already, making small(er) more atomic PR's is helpful to allow things that are easier to review.
  • You didn't check what other people did, which is not a requirement, but imo a minimum of politeness. Open source in large part is about collaboration. What you're doing feels - to me, so personal - a bit disrespectful, as if my contribution isn't good enough for you.

So I'm wondering what is your goal with those big PR's? Because you give a comment here that your PR makes mine no longer necessary, but you didn't even test your functionality.

@akira69

akira69 commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

@daften

you're right - I had the worry about the scale of changes as they grew - maybe there's a way to separate the different features/improvements into individual PRs without too much work. probably the best approach. Especially for testing - That I will consider and see what I can do to simplify.

Unfortunately I didn't look for what was already done in the PR list, an omission on my part and not a intention to bull over what's already done. Would have been smarter to build upon. Nevertheless, seems the implementation is similar to your approach. While there's no intention to disrespect, it's easy to see how it looks that way. The scope-creep express was over-eager. Lesson Learned. I've updated the PR for now to reference your code as prior art. At this point, rebase to #773 and build up looks very difficult. Let's see.

@daften

daften commented Feb 23, 2026

Copy link
Copy Markdown
Contributor Author

I appreciate the feedback and understanding. Don't let my (hopefully constructive) feedback to you discourage you from contributing, even with mistakes and misunderstandings in communication, it's valuable to contribute :)

The main thing I'd still advise to do is to split it in more granular items, if that's possible. Mainly because I have contributed to and maintained some projects, and reviewing big chunks can be very cumbersome. I'm closing off for today, have a great night @akira69

@akira69

akira69 commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

I got a pile of draft PRs going to do just that. I was able to rebase #858 to #773 here and build on that (also narrow the scope to just this function).

nothing tested yet, but split up at least.

@akira69

akira69 commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

@daften Hallo mein Freund! Ich spreche ein bisschen Deutsch und glaube, dass du Deutscher bist. Wohnst du in Deutschland? Ich habe drei Jahre in Deutschland gelebt, in der Nähe von Nürnberg. Jetzt bin ich in Chicago.

Aber, Englisch ist besser fur mich:

Check #858
It's now just building on this PR. However, some minor additions to address some potential issues

Therefore the suggestion is to fold commit 1908c55 (currently in #858) into this PR (#773), since it is directly coupled to the custom-field sort/filter work here.

Why this matters:

  • It normalizes sort/filter field keys in client/src/components/column.tsx so array-path columns are compared using a canonical dotted key (a.b) instead of mixed array/keyof forms.
  • It avoids sort/filter UI state mismatch cases (active sort/filter not consistently re-attaching to the expected column key).
  • It aligns typing in client/src/utils/sorting.ts with current master (Field import/signature consistency), keeping the sort helper type-safe with current field handling.

Scope is intentionally small (2 files) and can be cherry-picked cleanly from 1908c55.

If this is folded into #773, #858 can be closed as superseded.

@akira69

akira69 commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

And, by the way, I've split up my 2 or 3 PRs to a whole bunch of separate smaller ones - thanks for the tips

@daften

daften commented Feb 25, 2026

Copy link
Copy Markdown
Contributor Author

@akira69
Unfortunately, I'm not from Germany, but from Flanders, so I speak Dutch. I studied some German, but I'm extremely bad at speaking it. Lucky for me, I can understand it well enough since it's similar to Dutch. :)

I've cherry-picked the commit you indicated and also fixed the linting issues that were present in the frontend because of all the commits. I wasn't able to validate it, it's midnight here now, but I'm trusting what you wrote :)

Thanks for notifying so I can update this PR, I really appreciate it! I won't close any other PR (#858), I'll leave it to you to decide on that and do it.

I'm also seeing the separate PR's, I think that makes things easier. I hope at least.

Good luck in Chicage, I hope you can stay safe, with all the news I see, I worry sometimes.

Comment thread spoolman/database/utils.py Outdated
def get_field_table_for_entity(entity_type: Any) -> Type[models.Base]:
"""Get the field table class for a given entity type."""
# Import here to avoid circular imports
from spoolman.extra_fields import EntityType

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

importing stuff like this is never the correct answer, fix the circular dependency issues properly by rearranging in the files/adding new files if necessary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9711495.

I removed the local imports that were acting as the circular-import workaround and split the responsibilities into dedicated modules instead, so the query paths can import them directly without importing inside function bodies. Helper branch: akira69/Spoolman_Labels:feat/pr773-review-feedback.

Comment thread spoolman/database/utils.py Outdated
value_parts = value.split(",")

# Handle filtering for empty values
if any(p == "<empty>" or len(p) == 0 for p in value_parts):

@Donkie Donkie Mar 16, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty strings are used to filter empty values, this "empty>" is an unnecessary new concept

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed across 9711495 and 3208f8d.

I kept <empty> only as the dropdown label for readability, but restored the backend/API contract to the existing empty-string semantics. So the UI label is still <empty>, but the actual filter value sent to the API is now "".

Comment thread spoolman/database/utils.py Outdated
# Condition A subquery
empty_conditions = [
field_table.value.is_(None),
field_table.value == "null",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see add_where_clause_str on how empty values should be handled to stay consistent with the rest of the API

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9711495.

The extra-field path now follows the same empty-value semantics as the rest of the API: empty string means missing or unset value, including both missing rows and effectively empty/null stored values.

Comment thread spoolman/database/utils.py Outdated
try:
conditions.append(field_table.value == json.dumps(int(value_part)))
except ValueError:
pass

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

silently failing if bad value is not good api design

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9711495.

Invalid custom-field filter values no longer get ignored or silently coerced. The API now returns 400 for invalid integer, float, boolean, and range inputs. I also added coverage for the invalid integer/boolean cases and verified the behavior locally against a running app.

akira69 and others added 8 commits March 29, 2026 17:58
…e 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 Donkie#893)
- All tests verified passing on postgres, sqlite, and mariadb
- 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
…ntics

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
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
@daften
daften force-pushed the feature/custom-field-filter-sort branch from 126b199 to f718d8a Compare March 29, 2026 16:16
daften added 4 commits March 29, 2026 18:37
- 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
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 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.
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.
@daften

daften commented Mar 29, 2026

Copy link
Copy Markdown
Contributor Author

I have incorporated the proposed changed by @akira69 and added on them.

  • I reworked the test battery to work parametrized for all entity types
  • The test coverage was expanded
  • I changed the UI to work better for all custom field types and make the UI more intuitive
  • I made sure the UI is consistent across the different filters. The one thing I couldn't fix fully was using style attributes instead of the ant classes, apologies.

I think all feedback was covered, please let me know if anything is missing!

Thanks @akira69 for the helping hand and @Donkie for the detailed feedback :)

sherrmann added a commit to sherrmann/Spoolman-NG that referenced this pull request Jun 29, 2026
…-delta, calibration (#2)

* Remove forced vertical scrollbar from location spool containers

Drops the always-on overflow-y: scroll from .loc-container .location so the locations view no longer shows a permanent scrollbar.

Ported from upstream Donkie#859 by @VeeBack.

Co-authored-by: VeeBack <VeeBack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Use configured external DB sync interval instead of hardcoded default

schedule_tasks scheduled the cyclic sync with DEFAULT_SYNC_INTERVAL instead of the user-configured sync_interval value, so the configured interval had no effect. Use sync_interval.

Ported from upstream Donkie#943 by @chruoss.

Co-authored-by: chruoss <chruoss@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Allow including archived spools in spool export

Adds an allow_archived query parameter to the spool export endpoint, passed through to spool.find(allow_archived=...), which already supports it. Defaults to False to preserve existing behavior.

Ported from upstream Donkie#919 by @velzi.

Co-authored-by: velzi <velzi@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Publish spoolman_build_info Prometheus metric at startup

Adds a prometheus_client Info metric exposing version, commit and build_date so build provenance is scrapeable.

Ported from upstream Donkie#877 by @sw1nn.

Co-authored-by: sw1nn <sw1nn@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* docs: add spoolman-mcp server to the integrations list

README documentation only.

Ported from upstream Donkie#890 by @Disane87.

Co-authored-by: Disane87 <Disane87@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Add Docker HEALTHCHECK and a sample docker-compose.yml

Adds a container HEALTHCHECK that probes /api/v1/health (which exists) using the bundled Python interpreter, plus a sample docker-compose.yml.

Ported from upstream Donkie#908 by @meek2100.

Co-authored-by: meek2100 <meek2100@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Reload page on 401 to recover from forward-auth proxies

Adds an axios response interceptor that reloads the SPA on 401 for idempotent requests (with a cooldown and service-worker unregister) so forward-auth proxies can re-run their login flow.

Ported from upstream Donkie#924 by @sherrmann.

Co-authored-by: sherrmann <sherrmann@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Handle comma decimal separators in numeric inputs

Wires the existing comma-aware formatNumberOnUserInput / numberParser(AllowEmpty) helpers into the remaining InputNumber fields across filaments, spools, vendors, printing, extra fields and settings.

Ported from upstream Donkie#948 by @nkgotcode.

Co-authored-by: nkgotcode <nkgotcode@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Update client npm dependencies (upstream dependabot bumps)

Regenerates client/package-lock.json to pull the security/patch bumps from
the upstream dependabot PRs. uuid required a major bump (only used via the
stable `v4` export). Resulting versions meet or exceed each PR's target:

  uuid 13 -> 14.0.1                              (Donkie#927)
  axios -> 1.18.1                                (Donkie#928)
  vite -> 7.3.6                                  (Donkie#906)
  i18next-http-backend -> 3.0.6                  (Donkie#920)
  qs -> 6.14.2                                   (Donkie#850)
  minimatch (transitive bump)                    (Donkie#873)
  flatted -> 3.4.2                               (Donkie#889)
  handlebars -> 4.7.9                            (Donkie#894)
  lodash-es -> 4.18.1                            (Donkie#901)
  lodash -> 4.18.1                               (Donkie#909)
  follow-redirects -> 1.16.0                     (Donkie#914)
  fast-uri -> 3.1.2                              (Donkie#930)
  @babel/plugin-transform-modules-systemjs 7.29.7 (Donkie#931)

Verified with `npm run build` (tsc typecheck + refine/vite build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Update Python dependencies cryptography and mako (upstream dependabot bumps)

Bumps two transitive dependencies in uv.lock to the security/patch versions
targeted by the upstream dependabot PRs:

  cryptography 46.0.3 -> 46.0.7                  (Donkie#907)
  mako 1.3.10 -> 1.3.12                          (Donkie#929)

Pinned to the dependabot targets rather than the newest available
(cryptography 49.x) to keep the change to a patch-level security update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Address Copilot review feedback on numeric inputs and 401 handler

- numberFormatter: render a legitimate 0 as "0" instead of a blank field.
  The previous truthiness check formatted 0 as an empty string, so inputs
  where 0 is common (printing margins/spacing) looked blank / like state
  loss when not focused. Now only undefined/empty/non-finite values blank out.
- InputNumberRange.parseInputNumberValue: parse string values (antd typings
  allow them, e.g. stringMode) instead of dropping them to null, while still
  treating empty/null as a deliberate clear.
- authReloadHandler: guard the axios response interceptor against
  double-registration. Vite/React fast refresh can re-evaluate the module and
  stack duplicate interceptors, firing multiple reloads per 401; the flag now
  lives on the shared axios instance so it survives module re-evaluation.

Verified with npm run build (tsc typecheck + vite build) and prettier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Format spoolman/main.py with ruff to fix style CI

The Prometheus BUILD_INFO block (ported from upstream Donkie#877)
wasn't ruff-format compliant, failing the `style` job's `ruff format --check`.
Wrap the BUILD_INFO.info({...}) dict argument per ruff's formatting. No
behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Include weight_delta in spool update websocket events

Adds an optional payload_extras field to SpoolEvent and passes {"weight_delta": weight} from use_weight/use_length so websocket consumers can see the change amount. Follow-up to upstream Donkie#689.

Ported from upstream Donkie#902 by @chof747.

Co-authored-by: chof747 <chof747@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Add filament import from 3dfilamentprofiles.com

Adds a 3D Filament Profiles ID input to the filament create/edit forms and a backend proxy endpoint to fetch and map that profile data. Resolves upstream Donkie#939.

Ported from upstream Donkie#940 by @t0ny-peng.

Co-authored-by: t0ny-peng <t0ny-peng@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Add filtering and sorting for extra/custom fields in table views

Ports upstream Donkie#773: extra fields can now be shown, sorted, and filtered in
the spool/filament/vendor tables. Adds an extra-field query subsystem
(extra_field_query.py, extra_field_registry.py) and the frontend filtering/
sorting utilities and column filter dropdowns. Choice and boolean fields get
filter options; all extra field types support filtering for empty values.

Chosen over the overlapping Donkie#904 (filtering-only) as the more complete
superset implementation.

Ported from upstream Donkie#773 by @daften.

Co-authored-by: daften <daften@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Conform ported feature PRs to fork lint/format/type rules

Fixes issues surfaced by the local CI checks (ruff, eslint, prettier, build)
on the three ported feature PRs so the branch passes the style job:

- spool.py (Donkie#902): fix a real NameError -- spool_changed used Optional[dict]
  without importing Optional. Switched to "dict | None" (codebase style); the
  app failed to import before this.
- externaldb.py (Donkie#940): hoist inline imports to module top, narrow the blind
  except to httpx.HTTPError, use logging.exception + "raise ... from e", add
  the return annotation, wrap long lines, and noqa the complexity warnings
  (consistent with find()).
- filaments/edit.tsx (Donkie#940): replace an "as any" cast (eslint no-explicit-any)
  with a cast to setFieldsValue's parameter type.
- ruff format + prettier formatting on the remaining ported files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Fix CockroachDB extra-field filtering and address review feedback

- extra_field_query.py: the int/float-range extra-field filters compiled to
  json_extract(), which CockroachDB lacks. CockroachDB's SQLAlchemy dialect
  name is "cockroachdb" (not "postgresql"), so the existing PG @compiles
  variant didn't apply and it fell back to the json_extract default --
  test (cockroachdb) failed with "unknown function: json_extract()". Add a
  cockroachdb variant emitting CAST(value AS JSONB)->>N. Verified the
  generated SQL for sqlite/mysql/postgresql/cockroachdb locally.
- filaments/create.tsx, edit.tsx: await importFilament so errors propagate to
  the catch and the success toast only shows after the import completes.
- externaldb.py: reject a non-numeric profile_id before interpolating it into
  the upstream URL (prevents path injection / unintended fetches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Add filament calibration feature

Ports upstream Donkie#855: a calibration module with DB tables, REST API, and a
multi-step calibration wizard UI. Includes two Alembic migrations
(c3a7f2e8b091 calibration_tables, a1b2c3d4e5f6 calibration_filament_fk) that
chain from the current head, and integration tests. Ruff-formatted to the
fork's style; verified imports and a single Alembic head.

Ported from upstream Donkie#855 by @SmoothBrainIT.

Co-authored-by: SmoothBrainIT <SmoothBrainIT@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Fix calibration migration for PostgreSQL/CockroachDB/MariaDB

The a1b2c3d4e5f6 (calibration_filament_fk) migration used
batch_alter_table(recreate="always"), which forces Alembic's SQLite-style
table-copy workflow on every backend. On PostgreSQL/CockroachDB/MariaDB the
copy drops calibration_session's primary key, which calibration_step_result's
foreign key depends on -- "cannot drop constraint calibration_session_pkey
because other objects depend on it" -- so app startup (alembic upgrade) failed
and test (postgres) went red.

Use recreate="auto": only SQLite recreates the table (it must); the other
backends apply direct ALTERs that don't touch the PK. Verified the full
upgrade chain on a fresh SQLite DB locally; CI validates the other three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Fix calibration session cascade-delete on SQLite

test (sqlite) failed test_delete_filament_cascades_to_sessions: deleting a
filament left its calibration sessions orphaned (expected 404, got 200). The
codebase does not enable SQLite's PRAGMA foreign_keys, so DB-level
ON DELETE CASCADE is not enforced there -- cascades are done at the ORM level
instead. The Filament model had no relationship to CalibrationSession, so
nothing deleted the sessions on SQLite (the other three DBs enforce the FK, so
they passed).

Add Filament.calibration_sessions with cascade "save-update, merge, delete,
delete-orphan" (matching the extra-field pattern) plus the
CalibrationSession.filament back-reference. The delete path loads relations via
joinedload("*"), so the ORM cascade fires on every backend. Verified ruff,
imports, the relationship registration, and the full SQLite migration chain
locally; CI validates the cascade test across all four databases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Fix calibration migration column-drop for MySQL/MariaDB

After switching to recreate="auto", MariaDB failed alembic upgrade with
"Cannot drop index 'spool_id': needed in a foreign key constraint" -- MySQL/
MariaDB refuse to drop a column still referenced by a foreign key, whereas
PostgreSQL/CockroachDB drop the FK together with the column.

Drop the spool_id FK explicitly before dropping the column. It was created
unnamed, so reflect its real name; skip on SQLite (no named FK there, and the
batch recreate handles it). Apply the same guard to the downgrade for the
named filament_id FK. Verified upgrade + downgrade + re-upgrade on a fresh
SQLite DB locally; CI validates MariaDB/PostgreSQL/CockroachDB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Collapse calibration migrations to create filament_id directly

The calibration feature shipped two migrations: create calibration_session
with spool_id, then swap spool_id -> filament_id. That swap was an artifact of
the PR's development history and broke differently on every backend
(PostgreSQL pkey drop, MariaDB drop-column-with-FK, CockroachDB DDL+DML in one
transaction). Calibration tables are brand new, so there is no data to migrate.

Create calibration_session with filament_id (FK filament.id ON DELETE CASCADE)
directly in the first migration and delete the second migration. Verified on a
fresh SQLite DB: single head c3a7f2e8b091, calibration_session has filament_id
and no spool_id. The ORM cascade relationship added earlier is unchanged, so
filament deletion still cascades to sessions on SQLite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Fix correctness issues found in review (calibration, filtering, weight events)

Backend:
- spool.delete: commit in-request like filament.delete. Fixes a deterministic
  Postgres StaleDataError 500 when deleting a filament after a websocket+use flow
  (uncommitted spool delete left a phantom row in the next request's unit of work).
- use_weight_safe: return the actually-applied delta (after clamping at 0) and
  broadcast that as weight_delta instead of the requested weight. Consumption
  (weight>=0, the concurrent hot path) stays a single atomic UPDATE with no
  read-before-write, so concurrent /use no longer loses updates on
  MariaDB/CockroachDB; only refills (weight<0) read the prior value to report the
  clamped delta.
- calibration.list_sessions: load steps via selectinload, not joinedload, so
  LIMIT/OFFSET paginate sessions rather than joined step rows.
- calibration delete_session/delete_step_result: commit in-request.
- extra-field float exact-match filter: match both "5" and "5.0" so whole-number
  floats stored verbatim are found.
- export_spools: default allow_archived to True (backups include archived spools).
- Sorting: shared parse_sort() helper rejects malformed sort params with 400
  instead of an uncaught 500 (spool/filament/vendor).

Frontend:
- Calibration VFA step: stop nulling restored/typed min/max_avoidance_speed when
  the artifact-speeds list is empty (was wiping saved values on edit/resume).

Tests: regression tests for calibration pagination, weight_delta clamping,
whole-number float filtering, malformed-sort 400, and export-includes-archived.
Full integration matrix (sqlite/postgres/mariadb/cockroachdb) passes 297 each.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden testing, CI, and Docker (unit tests, dependabot, type/i18n checks)

- Add a fast, non-Docker pytest unit suite under tests/ (26 tests for
  spoolman.math and extra-field validation) plus pytest config
  ([tool.pytest.ini_options]) and pytest-cov.
- lefthook ci: run client `tsc --noEmit` and `npm run check-i18n` so type and
  translation regressions fail CI. Register the previously-unwired `lt` and `tr`
  locales in client/src/i18n.ts so check-i18n passes.
- Add .github/dependabot.yml (weekly pip + npm).
- Expand .dockerignore (.git, .github, tests, env files, etc.).
- Drop the obsolete top-level `version:` key from the compose files.

Note: the matching .github/workflows/ci.yml additions (unit-tests, CodeQL and
Hadolint jobs) are kept as a separate patch (ci-workflow-additions.patch) because
pushing workflow changes needs a token with the `workflow` scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: add unit-tests, CodeQL, and Hadolint jobs

- unit-tests: run the fast non-Docker pytest suite (uv run pytest tests/) with
  coverage, no fail threshold.
- codeql: static analysis for python and javascript-typescript.
- hadolint: lint the Dockerfile.

Split from the hardening commit because pushing workflow files needs a token
with the `workflow` scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cleanup and minor-correctness pass from review

Backend:
- Deduplicate utc_timezone_naive into spoolman/database/utils.py (was copied in
  spool.py and calibration.py).
- Collapse the two _JsonArray*Element classes into one parametrized _JsonArrayElement(col, index).
- Introduce EXTRA_FIELD_PREFIX constant (extra_field_registry) and use it instead of the
  hardcoded "extra."/[6:] in the spool/filament/vendor find endpoints and the query builder.
- Escape LIKE/ILIKE wildcards (% and _) in extra-field text/multi-choice filters so a value like
  "50%" matches literally instead of acting as a wildcard.
- calibration_models.from_db: parse JSON columns with `is not None` instead of truthiness so an
  empty stored value isn't dropped to None.
- get_external_db_sync_interval: tolerate a non-integer EXTERNAL_DB_SYNC_INTERVAL (warn + default)
  instead of crashing startup.
- Dockerfile HEALTHCHECK: add a urlopen timeout.

Frontend:
- Extract the duplicated 3DFP fetchProfile + field-mapping into shared
  utils/queryExternalDB.ts::fetchExternalProfile, using the axios instance (so it carries auth and
  works behind forward-auth proxies instead of a bare fetch).
- Extract a shared FilterDropdownFooter and translate the custom-field filter dropdown labels via
  the i18n instance (antd renders filterDropdown as a callback, so translate statically, not via a
  prop/hook); add the table.filter.* en keys.
- Add CUSTOM_FIELD_PREFIX constant in queryFields.ts; remove dead formatCustomFieldFilterValue /
  getCustomFieldSorters / isCustomFieldSorter.
- CalibrationWizard: re-initialise when the session prop changes while mounted (deps), and guard the
  "skip step" handler against double-submit.

Verified: ruff/eslint/tsc/prettier/check-i18n, 26 unit tests, and the full integration matrix
(sqlite/postgres/mariadb/cockroachdb) all pass 297 each.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: satisfy hadolint on the Dockerfile

The hadolint job (added in b3ed419) runs with the action's default
failure-threshold of "info", which flagged five findings on the
pre-existing build/runtime apt+pip layers.

- Add --no-install-recommends to both apt-get install layers. This is
  the exact remediation hadolint recommends for DL3015 and trims the
  images by skipping recommended-but-unneeded packages.

- Add .hadolint.yaml ignoring DL3008 (pin apt versions) and DL3013
  (pin pip versions). The images build on the rolling
  python:3.x-slim-bookworm base, whose apt package index moves over
  time; pinning exact apt/pip versions would break the build as soon
  as those versions are superseded upstream. Reproducibility of the
  actual Python dependency set is already handled by uv.lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqPtZvXQkraNt9ttEhLsBt

* Deduplicate the entity-type -> extra-field-table mapping

Replace the two parallel if/elif ladders in _get_field_table_for_entity and
_get_entity_id_column with a single _ENTITY_FIELD_TABLES dict mapping each EntityType to its
(field table, owning-entity id column). Pure refactor: verified the dict returns the identical
table/column objects the ladders did for spool/filament/vendor and still raises ValueError on
unknown inputs.

Verified: ruff, 26 unit tests, and sqlite+postgres integration matrix (297 each) all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: VeeBack <VeeBack@users.noreply.github.com>
Co-authored-by: chruoss <chruoss@users.noreply.github.com>
Co-authored-by: velzi <velzi@users.noreply.github.com>
Co-authored-by: sw1nn <sw1nn@users.noreply.github.com>
Co-authored-by: Disane87 <Disane87@users.noreply.github.com>
Co-authored-by: meek2100 <meek2100@users.noreply.github.com>
Co-authored-by: sherrmann <sherrmann@users.noreply.github.com>
Co-authored-by: nkgotcode <nkgotcode@users.noreply.github.com>
Co-authored-by: chof747 <chof747@users.noreply.github.com>
Co-authored-by: t0ny-peng <t0ny-peng@users.noreply.github.com>
Co-authored-by: daften <daften@users.noreply.github.com>
Co-authored-by: SmoothBrainIT <SmoothBrainIT@users.noreply.github.com>
Donkie and others added 6 commits July 7, 2026 19:24
- 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>
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>
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>
…m-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>
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>
@Donkie

Donkie commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Hey, sorry for taking so long. I took the liberty of fixing up some last things I found. Merging now :)

@Donkie
Donkie merged commit 470d8f3 into Donkie:master Jul 8, 2026
15 checks passed
@daften

daften commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

No worries, thanks so much for reviewing and accepting! :)

@daften

daften commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Also thanks @akira69 for the contributions and collaboration 🙏

akira69 added a commit to akira69/Spoolman_Labels that referenced this pull request Jul 9, 2026
* Add conditional logic to drop permissions for unprivileged Docker

This change enables the container to run in unprivileged mode (without
--privileged or additional capabilities) by making permission drops
conditional:

- Changed users group GID from 1000 to 1001 to avoid conflicts with
  the app user's primary group (UID 1000)
- Only modify user/group IDs when PUID/PGID environment variables differ
  from the default value of 1000, avoiding unnecessary privilege
  requirements
- Skip su-exec entirely when running as the default user, since changing
  user context requires additional capabilities that unprivileged
  containers don't have

This allows the container to work in restricted environments like
Kubernetes with security contexts or rootless Docker while maintaining
backward compatibility for users who customize PUID/PGID.

Fixes Donkie#791

* Allow archived spool export

* Tidy up docker file and entrypoint

* Fix spool price disappearing from the list on live updates (Donkie#814)

The REST endpoints serialize with response_model_exclude_none=True (unset fields
are omitted), but websocket events were serialized with a plain .json() that
includes unset fields as explicit null. The spool list fills a missing price
from the filament price only when it is `undefined`, so a live update carrying
`price: null` (e.g. after a quick weight adjust) blanked the price column until
a full page reload.

Fixed on both sides so the two serialization paths can no longer drift:
- Server: websocket payloads now use exclude_none=True (spoolman/ws.py), giving
  live updates and REST responses an identical shape. Note this is a small
  websocket wire-format change external consumers will see: unset fields are now
  omitted instead of sent as null, matching REST.
- Client: collapseSpool and the price column now fall back for both `null` and
  `undefined`, keeping the list resilient regardless of serialization.

* Commit generated requirements.txt for bare-metal/Moonraker installs (Donkie#830)

requirements.txt was moved to .gitignore when the project switched to uv, so
bare-metal installs lost it. Moonraker's update_manager runs
`pip install -r requirements.txt` against the checkout, so upgrading to 0.23
broke with "Invalid path for option `requirements`".

Track requirements.txt again and regenerate it from uv on every release
(spoolman/bump.py exports it alongside the uv.lock bump), keeping uv as the
single source of truth while staying backwards compatible with installs that
expect a requirements.txt.

* Document that extra field values are JSON-encoded strings (Donkie#849)

The `extra` map on vendors, filaments and spools is intentionally typed as
dict[str, str] where every value is a JSON-encoded string (the client round-
trips them through JSON.parse/stringify). This surprised API consumers, who
saw numeric fields come back as strings.

Spell out the encoding on all three response models via a shared helper so the
generated OpenAPI docs make clear that consumers must JSON-decode each value.
No behaviour change.

* Update Python dependencies (uv lock --upgrade)

Refresh all backend dependencies to the latest versions allowed by the
pyproject constraints and regenerate requirements.txt to match. Notable bumps:
starlette 0.50 -> 1.3, fastapi 0.128 -> 0.139, cryptography 46 -> 49,
uvicorn 0.40 -> 0.50, pydantic 2.12 -> 2.13, sqlalchemy 2.0.45 -> 2.0.51,
alembic 1.17 -> 1.18.

Locally verified: uv sync, ruff check, uv lock --check, app import, and a live
startup smoke (SQLite) — migrations run, /health + /info respond, vendor CRUD
works, and a websocket update event is delivered correctly on starlette 1.x.
The Docker-based 4-DB integration suite still needs to run (CI).

* Update client dependencies (npm update)

Refresh transitive client dependencies to the latest versions within the
existing package.json semver ranges (package.json unchanged).

Verified: npm ci, tsc --noEmit, eslint, prettier --check, and npm run build all
pass. (3 high-severity advisories remain that require major/breaking bumps via
`npm audit fix --force`; left for a separate, deliberate change.)

* Make the integration test suite runnable under Podman

Running tests_integration against rootless Podman surfaced three issues; none
affect the Docker/CI path:

- run.py: add a SPOOLMAN_CONTAINER_ENGINE env var (defaults to "docker") so the
  build/compose commands can be driven by podman instead.
- Dockerfile: drop `--chown=app:app` from the builder-stage COPYs. The "app"
  user only exists in the runner stage; Docker/BuildKit tolerated the dangling
  reference but Podman rejects it. Final ownership is unchanged — it is set when
  the files are copied into the runner stage.
- docker-compose-postgres.yml: add a pg_isready healthcheck and gate spoolman on
  `condition: service_healthy`, matching the mariadb/cockroachdb compose files.
  Without it spoolman raced postgres' initdb and died on connection-refused
  before running migrations.

Verified: full 4-DB suite (sqlite/postgres/mariadb/cockroachdb) green via
`SPOOLMAN_CONTAINER_ENGINE=podman uv run poe itest` — 223 tests each.

* 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).

* Bump deps to clear Dependabot alerts (path-to-regexp, pytest)

- path-to-regexp: force >=8.4.0 (8.4.2) under @ant-design/pro-layout via a
  scoped npm override, fixing the ReDoS advisories (GHSA sequential optional
  groups + multiple wildcards). Express keeps its own 0.1.x.
- pytest 8.3 -> 9.1.1 and pytest-asyncio 0.23/0.25 -> 1.4.0 in
  tests_integration/requirements.txt and the dev group; regenerated uv.lock.

Verified: client build + typecheck pass, npm audit clean, and the full
integration suite (223 tests) passes on postgres, sqlite, mariadb, and
cockroachdb under podman.

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* Add Playwright frontend integration tests; fix broken SPA static serving

Add a browser-driven integration suite (tests_frontend/) that runs against
the real production image + PostgreSQL and drives the UI with Playwright:
a smoke test that navigates every page via the sidebar with no console
errors, and a CRUD test that creates a vendor -> filament -> spool entirely
through the UI. Wired up as `poe itest-frontend` and a new `test-frontend`
CI job (reusing the image built for the backend tests, gating releases).

Navigation goes through real UI buttons (language-independent link targets)
and the app language is forced to English so label matchers are stable
regardless of the runner's browser locale.

This immediately caught a real regression: Starlette 1.3.1 (pulled by a
recent dep bump) removed the `method` kwarg from FileResponse, so
SinglePageApplication 500'd on every static asset and the whole UI was dead.
Drop the removed kwarg — Starlette now derives HEAD handling from the scope.

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* Add CI guard blocking manual edits to non-English translations

Non-English locales are managed exclusively through Weblate; any manual
edit to an existing translation file gets silently overwritten on the
next Weblate sync. This workflow fails any PR (except Weblate's own,
detected via PR author login) that modifies or deletes an existing
non-English file under client/public/locales/. Adding a brand-new
language file is still allowed so contributors can bootstrap a new
language alongside the client/src/i18n.ts entry.

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* Update sync interval for external database synchronization (Donkie#943)

The external database sync scheduler was ignoring the configured
EXTERNAL_DB_SYNC_INTERVAL environment variable and always using the DEFAULT_SYNC_INTERVAL constant (3600 seconds).

* Auto-merge Weblate translation PRs on green CI

Weblate opens a PR for every translation sync. These are machine-generated,
always scoped to client/public/locales/, and gated by the full CI suite, so
hand-merging each one is pure friction. This enables GitHub auto-merge on
Weblate-authored PRs so they merge themselves once required checks pass.

A scope-guard step reads the PR's file list from the API and refuses to enable
auto-merge unless every changed file is a .json under client/public/locales/,
so the trust is in the diff, not just the author: a compromised Weblate token
can open a PR but can only be auto-merged if it is confined to translations.

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* Remove hashes from requirements.txt

* Bump uuid from 13.0.2 to 14.0.0 in /client (Donkie#927)

Bumps [uuid](https://github.com/uuidjs/uuid) from 13.0.2 to 14.0.0.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](uuidjs/uuid@v13.0.2...v14.0.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* docs: add MCP server to integrations list (Donkie#890)

Add spoolman-mcp to the list of integrations. It's a Model Context
Protocol server that lets you manage your filament inventory through
AI assistants like Claude.

* feat(scanner): accept Data Matrix and other 2D codes (Donkie#887)

Widen the code scanner's accepted formats beyond qr_code to the common 2D
matrix codes (micro/rm QR, Data Matrix, Aztec, PDF417) so manually-generated
labels using those symbologies can be scanned. Payloads that don't match the
spoolman spool format are ignored, so this is a safe superset.

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* feat(theme): add 'System' option that follows OS/browser theme (Donkie#947)

Replace the light/dark switch with a 3-way System/Light/Dark control. The new
'System' preference follows prefers-color-scheme live via a matchMedia listener.
New installs default to System; existing users keep their stored light/dark
choice (legacy values remain valid preferences).

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* Translations update (Donkie#825)

* Translated using Weblate (Estonian)

Currently translated at 17.3% (50 of 288 strings)

Translated using Weblate (Persian)

Currently translated at 96.5% (278 of 288 strings)

Translated using Weblate (French)

Currently translated at 98.9% (285 of 288 strings)

Translated using Weblate (Greek)

Currently translated at 72.9% (210 of 288 strings)

Translated using Weblate (Dutch)

Currently translated at 99.3% (286 of 288 strings)

Translated using Weblate (Hungarian)

Currently translated at 72.2% (208 of 288 strings)

Translated using Weblate (Danish)

Currently translated at 85.4% (246 of 288 strings)

Translated using Weblate (Thai)

Currently translated at 98.6% (284 of 288 strings)

Translated using Weblate (Japanese)

Currently translated at 99.3% (286 of 288 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/el/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/et/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fa/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/fr/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ja/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/nl/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/th/
Translation: Spoolman/Web Client

* Translated using Weblate (Ukrainian)

Currently translated at 93.0% (268 of 288 strings)

Translated using Weblate (Greek)

Currently translated at 72.9% (210 of 288 strings)

Translated using Weblate (Hungarian)

Currently translated at 72.2% (208 of 288 strings)

Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/el/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/uk/
Translation: Spoolman/Web Client

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 98.6% (284 of 288 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.3% (286 of 288 strings)

Translated using Weblate (Hindi (Latin script))

Currently translated at 3.8% (11 of 288 strings)

Translated using Weblate (Swedish)

Currently translated at 94.7% (273 of 288 strings)

Translated using Weblate (Ukrainian)

Currently translated at 93.0% (268 of 288 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hi_Latn/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sv/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/uk/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/
Translation: Spoolman/Web Client

* Translated using Weblate (Danish)

Currently translated at 87.1% (251 of 288 strings)

Co-authored-by: srbjessen <srbjessen@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/
Translation: Spoolman/Web Client

* Translated using Weblate (Czech)

Currently translated at 100.0% (288 of 288 strings)

Co-authored-by: Miloslav Kos <kos.m@post.cz>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/cs/
Translation: Spoolman/Web Client

* Translated using Weblate (Korean)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Korean)

Currently translated at 78.1% (225 of 288 strings)

Added translation using Weblate (Korean)

Co-authored-by: 김태남 <imedia0@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/ko/
Translation: Spoolman/Web Client

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (288 of 288 strings)

Co-authored-by: Kayz C <kayzed.x@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/zh_Hant/
Translation: Spoolman/Web Client

* Translated using Weblate (Slovenian)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Slovenian)

Currently translated at 38.1% (110 of 288 strings)

Translated using Weblate (Slovenian)

Currently translated at 14.9% (43 of 288 strings)

Added translation using Weblate (Slovenian)

Co-authored-by: Jernej Pangerc <jernejp21@tuta.com>
Co-authored-by: jernejp21 <jernejp21@tuta.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sl/
Translation: Spoolman/Web Client

* Translated using Weblate (Slovak)

Currently translated at 10.4% (30 of 288 strings)

Translated using Weblate (Slovak)

Currently translated at 9.7% (28 of 288 strings)

Added translation using Weblate (Slovak)

Co-authored-by: Pavol Vrba <pvrba2000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/sk/
Translation: Spoolman/Web Client

* Translated using Weblate (Hungarian)

Currently translated at 94.0% (271 of 288 strings)

Co-authored-by: Tamas Veres <tamas.veres@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/hu/
Translation: Spoolman/Web Client

* Translated using Weblate (Danish)

Currently translated at 100.0% (288 of 288 strings)

Translated using Weblate (Danish)

Currently translated at 92.0% (265 of 288 strings)

Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/da/
Translation: Spoolman/Web Client

* Translated using Weblate (Latvian)

Currently translated at 4.5% (13 of 288 strings)

Added translation using Weblate (Latvian)

Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/spoolman/web-client/lv/
Translation: Spoolman/Web Client

---------

Co-authored-by: Daniel Hultgren <daniel.cf.hultgren@gmail.com>
Co-authored-by: srbjessen <srbjessen@gmail.com>
Co-authored-by: Miloslav Kos <kos.m@post.cz>
Co-authored-by: 김태남 <imedia0@gmail.com>
Co-authored-by: Kayz C <kayzed.x@gmail.com>
Co-authored-by: jernejp21 <jernejp21@tuta.com>
Co-authored-by: Pavol Vrba <pvrba2000@gmail.com>
Co-authored-by: Tamas Veres <tamas.veres@gmail.com>
Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com>
Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com>

* feat(i18n): add Lithuanian and Turkish locales

Rework check-i18n to measure real translation coverage against en
(non-empty and differing from the reference) instead of file size,
gating inclusion at 50%. Add lt and tr, both ~96% translated.

Claude-Session: https://claude.ai/code/session_01BQjJiwEbu6bHbeDjPPDm3V

* Bump version to 0.24.0

* Add filtering and sorting for custom fields (Donkie#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 Donkie#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>

* chore(ui): rename Hide Columns button label to Columns

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Joshua Piccari <joshua.piccari@gmail.com>
Co-authored-by: Veli-Matti Leppänen <vellu@velhot.net>
Co-authored-by: Donkie <daniel.cf.hultgren@gmail.com>
Co-authored-by: chruoss <chrigi.ruoss@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marco Thiel <mfranke87@icloud.com>
Co-authored-by: Weblate (bot) <noreply@weblate.org>
Co-authored-by: srbjessen <srbjessen@gmail.com>
Co-authored-by: Miloslav Kos <kos.m@post.cz>
Co-authored-by: 김태남 <imedia0@gmail.com>
Co-authored-by: Kayz C <kayzed.x@gmail.com>
Co-authored-by: jernejp21 <jernejp21@tuta.com>
Co-authored-by: Pavol Vrba <pvrba2000@gmail.com>
Co-authored-by: Tamas Veres <tamas.veres@gmail.com>
Co-authored-by: JonasBentin <jonasbentin19+github@gmail.com>
Co-authored-by: Jurijs Kiricenko <jurijskiricenko@gmail.com>
Co-authored-by: Donkie <2332094+Donkie@users.noreply.github.com>
Co-authored-by: Dieter Blomme <dieterblomme@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants