diff --git a/client_v2/e2e/a11y-mobile.spec.ts b/client_v2/e2e/a11y-mobile.spec.ts index 76ced8e7..a8407164 100644 --- a/client_v2/e2e/a11y-mobile.spec.ts +++ b/client_v2/e2e/a11y-mobile.spec.ts @@ -135,7 +135,7 @@ async function audit(page: Page, testInfo: TestInfo, label: string) { const STATIC_ROUTES: { path: string; label: string }[] = [ { path: '/', label: 'library (home)' }, - { path: '/locations', label: 'locations' }, + { path: '/dashboard', label: 'dashboard' }, { path: '/labels', label: 'labels' }, { path: '/settings', label: 'settings' } ]; diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json index db4e6a3e..c627a2b8 100644 --- a/client_v2/locales/en/common.json +++ b/client_v2/locales/en/common.json @@ -53,6 +53,22 @@ "common.increment": "Increment", "common.remove": "Remove", "common.search": "Search", + "dashboard.addFieldHint": "Add a text or choice field to spools in Settings to group by it too", + "dashboard.dashboard": "Dashboard", + "dashboard.deleteGroup": "Delete group", + "dashboard.dragHint": "Drag a spool to another card to change its {{field}}, or within a card to reorder", + "dashboard.dropHere": "drop spools here", + "dashboard.emptyHelp": "This page groups your spools by a field of your choosing, add some spools to get started!", + "dashboard.errorEmptyName": "Name cannot be empty", + "dashboard.errorExists": "A group with that name already exists", + "dashboard.errorRename": "Failed to rename group", + "dashboard.lastUsed": "Last used {{time}} ago", + "dashboard.newGroup": "New group", + "dashboard.newGroupName": "New group {{n}}", + "dashboard.reorderGroup": "Drag to reorder group", + "dashboard.spoolCount_one": "{{count}} spool", + "dashboard.spoolCount_other": "{{count}} spools", + "dashboard.unassigned": "Unassigned", "datetime.done": "Done", "datetime.hour": "Hour", "datetime.minute": "Minute", @@ -61,9 +77,9 @@ "datetime.pick": "Pick date and time", "datetime.prevMonth": "Previous month", "datetime.time": "Time", + "documentTitle.dashboard.list": "Dashboard | Spoolman", "documentTitle.labels.list": "Labels | Spoolman", "documentTitle.library.list": "Library | Spoolman", - "documentTitle.locations.list": "Locations | Spoolman", "documentTitle.settings.list": "Settings | Spoolman", "filament.fields.addColor": "Add color", "filament.fields.articleNumber": "Article Number", @@ -256,21 +272,6 @@ "library.unusedSubMixed": "{{location}}", "library.usedAgo": "used {{time}} ago", "loading": "Loading", - "locations.dragHint": "Drag spools between locations to reassign them, or within a location to reorder them", - "locations.dropHere": "drop spools here", - "locations.errorEmpty": "Location name cannot be empty", - "locations.errorExists": "A location with that name already exists", - "locations.errorRename": "Failed to rename location", - "locations.lastUsed": "Last used {{time}} ago", - "locations.locations": "Locations", - "locations.newLocation": "New Location", - "locations.newShelf": "New location {{n}}", - "locations.noLocation": "No Location", - "locations.deleteLocation": "Delete location", - "locations.noLocationsHelp": "This page lets you organize your spools in locations, add some spools to get started!", - "locations.reorderLocation": "Drag to reorder location", - "locations.spoolCount_one": "{{count}} spool", - "locations.spoolCount_other": "{{count}} spools", "nav.labels": "Labels", "nav.library": "Library", "no": "No", diff --git a/client_v2/src/lib/api/map.ts b/client_v2/src/lib/api/map.ts index efd65edc..90f7200c 100644 --- a/client_v2/src/lib/api/map.ts +++ b/client_v2/src/lib/api/map.ts @@ -106,7 +106,10 @@ export function mapGroup(g: Json): GroupSummary { title = g.key ?? 'No location'; subtitle = `${g.in_use_count} in use`; } else { + // extra.: the key IS the value, and there is no entity to name the group. + // The dashboard, which is what groups on these, titles them itself. title = g.key ?? ''; + subtitle = `${g.spool_count} spool${g.spool_count === 1 ? '' : 's'}`; } return { diff --git a/client_v2/src/lib/api/spoolSource.ts b/client_v2/src/lib/api/spoolSource.ts index f17b4807..e5660220 100644 --- a/client_v2/src/lib/api/spoolSource.ts +++ b/client_v2/src/lib/api/spoolSource.ts @@ -89,6 +89,10 @@ function sortParam(sort: { field: string; dir: string }[]): string | undefined { function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) { if (!scope) return; + // An empty key is the group of spools whose value is unset. Every string-valued + // field spells that as an empty filter, which the backend reads as "null or empty" + // (and, for an extra field, "no row for this key either"). + const exact = (v: string) => (v === '' ? '' : quote(v)); switch (scope.field) { case 'filament': params['filament.id'] = scope.key; @@ -97,10 +101,14 @@ function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) { params['filament.vendor.id'] = scope.key === '' ? '-1' : scope.key; break; case 'material': - params['filament.material'] = scope.key === '' ? '' : quote(scope.key); + params['filament.material'] = exact(scope.key); break; case 'location': - params['location'] = scope.key === '' ? '' : quote(scope.key); + params['location'] = exact(scope.key); + break; + default: + // `extra.`, which is already the query param the backend expects. + params[scope.field] = exact(scope.key); break; } } diff --git a/client_v2/src/lib/api/types.ts b/client_v2/src/lib/api/types.ts index bbebd254..ef443610 100644 --- a/client_v2/src/lib/api/types.ts +++ b/client_v2/src/lib/api/types.ts @@ -15,8 +15,11 @@ export interface SortField { dir: SortDir; } -/** The entity/axis a group is keyed on. */ -export type GroupField = 'filament' | 'vendor' | 'material' | 'location'; +/** + * The entity/axis a group is keyed on. These are the API's own `group_by` values, so + * they travel to the server unchanged — `extra.` groups on a spool custom field. + */ +export type GroupField = 'filament' | 'vendor' | 'material' | 'location' | `extra.${string}`; /** One group returned by listGroups — the header + server aggregates. */ export interface GroupSummary { diff --git a/client_v2/src/lib/components/NavTabs.svelte b/client_v2/src/lib/components/NavTabs.svelte index dbb23855..6c6c6b1c 100644 --- a/client_v2/src/lib/components/NavTabs.svelte +++ b/client_v2/src/lib/components/NavTabs.svelte @@ -6,7 +6,7 @@ const tabs = [ { href: '/', label: m['nav.library'] }, - { href: '/locations', label: m['locations.locations'] }, + { href: '/dashboard', label: m['dashboard.dashboard'] }, { href: '/labels', label: m['nav.labels'] }, { href: '/settings', label: m['settings.header'] } ] satisfies { href: Pathname; label: () => string }[]; diff --git a/client_v2/src/lib/dashboard/fields.ts b/client_v2/src/lib/dashboard/fields.ts new file mode 100644 index 00000000..bfbdc640 --- /dev/null +++ b/client_v2/src/lib/dashboard/fields.ts @@ -0,0 +1,106 @@ +import type { Spool } from '$lib/types'; +import type { GroupField } from '$lib/api/types'; +import { FieldType, type FieldDef } from '$lib/api/fields'; +import { spoolSource } from '$lib/api/spoolSource'; +import { inventory } from '$lib/stores/inventory.svelte'; +import * as m from '$lib/paraglide/messages'; + +// The dashboard groups spools into cards by ONE field, and dragging a spool between +// cards writes that field. This module is the list of fields that can play that role, +// and everything the board needs to read and write one without special-casing it. +// +// A field qualifies on two counts. It has to be OWNED BY THE SPOOL, because a drag moves +// exactly one spool and must mean exactly one change — a filament-owned field like material +// would rewrite every other spool of that filament as a side effect of dragging one. And its +// value has to be a short, repeatable string a spool can be *assigned* to, which rules out +// numbers, dates, booleans and multi-choice; the backend rejects grouping on those too (see +// GROUPABLE_EXTRA_FIELD_TYPES). +// +// That leaves the built-in location plus any single-value text or choice field the user has +// added to spools. Lot number is the near miss: spool-owned and short, but stamped on by the +// manufacturer, so dragging a spool into another lot would record something untrue. Both it +// and material are better served by a library filter than by a board. + +/** The value a spool has for a field; the empty string is the "unassigned" group. */ +export type GroupKey = string; + +export interface DashboardField { + /** Identity: the API's `group_by` value, and the key this field's layout is saved under. */ + key: GroupField; + label: string; + /** This spool's current value, '' when unset. */ + valueOf(spool: Spool): GroupKey; + /** The same spool with `value` applied, for the board's own copies. Pure — no request. */ + withValue(spool: Spool, value: GroupKey): Spool; + /** Write `value` to the spool, updating the cache first. */ + assign(spool: Spool, value: GroupKey): Promise; + /** + * Rename a whole group, moving every spool in it. Only defined where the backend can + * do it in one shot; elsewhere the board only lets empty groups be renamed. + */ + rename?(from: GroupKey, to: GroupKey): Promise; + /** Fixed set of permitted values (single-choice fields); undefined means free text. */ + choices?: string[]; +} + +const LOCATION: DashboardField = { + key: 'location', + get label() { + return m['spool.fields.location'](); + }, + valueOf: (s) => s.location, + withValue: (s, value) => ({ ...s, location: value }), + async assign(spool, value) { + inventory.patchSpool(spool.id, { location: value }); + await spoolSource.saveSpool(spool.id, { location: value }); + }, + rename: (from, to) => spoolSource.renameLocation(from, to) +}; + +/** + * Extra-field values are stored JSON-encoded; the group key is the decoded string. + * Clearing writes JSON null rather than dropping the key: the API patches the extra map + * key by key, so an omitted key would leave the old value in place. + */ +function extraField(def: FieldDef): DashboardField { + const key = `extra.${def.key}` as GroupField; + const encode = (value: GroupKey) => (value === '' ? 'null' : JSON.stringify(value)); + return { + key, + label: def.name, + valueOf(spool) { + const raw = spool.extra[def.key]; + if (raw === undefined) return ''; + try { + const parsed = JSON.parse(raw); + return typeof parsed === 'string' ? parsed : ''; + } catch { + return ''; + } + }, + withValue: (spool, value) => ({ ...spool, extra: { ...spool.extra, [def.key]: encode(value) } }), + async assign(spool, value) { + const extra = { [def.key]: encode(value) }; + inventory.patchSpool(spool.id, { extra: { ...spool.extra, ...extra } }); + await spoolSource.saveSpool(spool.id, { extra }); + }, + choices: def.field_type === FieldType.choice ? (def.choices ?? []) : undefined + }; +} + +/** Spool extra fields that hold one plain string, and so can be grouped and dragged into. */ +function isGroupable(def: FieldDef): boolean { + if (def.field_type === FieldType.text) return true; + return def.field_type === FieldType.choice && !def.multi_choice; +} + +/** + * Every field the dashboard can group by, in menu order: location (the default view) first, + * then the user's own spool fields. A site with no custom spool fields therefore has exactly + * one view — the location board — until it defines one. + */ +export function dashboardFields(spoolFieldDefs: FieldDef[]): DashboardField[] { + return [LOCATION, ...spoolFieldDefs.filter(isGroupable).map(extraField)]; +} + +export const DEFAULT_FIELD_KEY: GroupField = 'location'; diff --git a/client_v2/src/lib/dashboard/layout.ts b/client_v2/src/lib/dashboard/layout.ts new file mode 100644 index 00000000..1d98c42a --- /dev/null +++ b/client_v2/src/lib/dashboard/layout.ts @@ -0,0 +1,48 @@ +import { getSettings, setSetting, parseSetting } from '$lib/api/settings'; + +// The dashboard's saved layout: which cards a field's board shows, in what order, and +// the order of the spools inside each card. Both are stored per grouped-by field, so +// switching the view mode brings back that view's own arrangement. +// +// A group key is the field's value; the empty string is the "unassigned" card. + +export interface DashboardLayout { + /** field key → ordered group keys. */ + groups: Record; + /** field key → group key → ordered spool ids. */ + spoolOrders: Record>; +} + +/** The field key whose layout the pre-dashboard location board's settings describe. */ +const SEEDED_FIELD = 'location'; + +export async function loadLayout(signal?: AbortSignal): Promise { + const s = await getSettings(signal); + const groups = parseSetting>(s.dashboard_groups, {}); + const spoolOrders = parseSetting>>(s.dashboard_spoolorders, {}); + + // Before the dashboard there was a location-only board, whose layout lived in + // `locations` / `locations_spoolorders`. Those keys still belong to the old client, so + // they are read as a one-time seed for the location view and never written back — an + // upgrade keeps the shelves it had, and the old client keeps working unchanged. + if (!groups[SEEDED_FIELD]) { + const seed = parseSetting(s.locations, []).filter((l) => l != null); + if (seed.length) groups[SEEDED_FIELD] = seed; + } + if (!spoolOrders[SEEDED_FIELD]) { + const seed = parseSetting>(s.locations_spoolorders, {}); + if (Object.keys(seed).length) spoolOrders[SEEDED_FIELD] = seed; + } + + return { groups, spoolOrders }; +} + +export function saveGroups(groups: DashboardLayout['groups']): void { + setSetting('dashboard_groups', groups).catch((e) => console.error('Failed to save dashboard groups', e)); +} + +export function saveSpoolOrders(spoolOrders: DashboardLayout['spoolOrders']): void { + setSetting('dashboard_spoolorders', spoolOrders).catch((e) => + console.error('Failed to save dashboard spool order', e) + ); +} diff --git a/client_v2/src/lib/dashboard/params.ts b/client_v2/src/lib/dashboard/params.ts new file mode 100644 index 00000000..aa9d563d --- /dev/null +++ b/client_v2/src/lib/dashboard/params.ts @@ -0,0 +1,56 @@ +import { goto } from '$app/navigation'; +import { browser } from '$app/environment'; + +// The dashboard's view mode — which field its cards group by — lives in the URL, so a +// particular board can be bookmarked and shared. It is also mirrored to localStorage, so +// arriving at a bare /dashboard brings back the view you last looked at. +// +// The URL always wins when it says something: a bookmark has to mean what it says, even +// when this browser was last left on a different view. The parameter is written even for +// the default field rather than being omitted, so "no parameter" unambiguously means +// "nothing asked for" and the remembered view can take over. + +export const FIELD_PARAM = 'by'; + +const LAST_VIEW_KEY = 'spoolman-v2-dashboard-field'; + +/** The field key the URL asks for, or null when it doesn't say. */ +export function fieldKeyFromUrl(params: URLSearchParams): string | null { + return params.get(FIELD_PARAM); +} + +/** The view this browser was last left on, if any. */ +export function rememberedFieldKey(): string | null { + if (!browser) return null; + try { + return localStorage.getItem(LAST_VIEW_KEY); + } catch { + // Private-mode / storage-disabled browsers: the URL and the default still work. + return null; + } +} + +export function rememberFieldKey(key: string): void { + if (!browser) return; + try { + localStorage.setItem(LAST_VIEW_KEY, key); + } catch { + /* nothing to do — remembering the view is a convenience, not a requirement */ + } +} + +/** + * Point the address bar at `key`'s board. + * + * `replace` swaps the current history entry, used when canonicalising a URL that didn't + * name a view; picking a view from the menu pushes, so Back returns to the previous board. + */ +export function gotoField(key: string, replace = false): void { + // A bare `?query` resolves against the current URL, so this is base-path-independent. + // eslint-disable-next-line svelte/no-navigation-without-resolve + goto(`?${FIELD_PARAM}=${encodeURIComponent(key)}`, { + replaceState: replace, + keepFocus: true, + noScroll: true + }); +} diff --git a/client_v2/src/lib/stores/fields.svelte.ts b/client_v2/src/lib/stores/fields.svelte.ts index b9e26958..6f0cc8c5 100644 --- a/client_v2/src/lib/stores/fields.svelte.ts +++ b/client_v2/src/lib/stores/fields.svelte.ts @@ -18,22 +18,29 @@ function byOrder(list: FieldDef[]): FieldDef[] { class Fields { private defs = $state>({ spool: [], filament: [], vendor: [] }); - private loaded = new Set(); + // Reactive so views can wait for the definitions rather than render against an + // empty list and then rearrange themselves once they arrive. + private loaded = $state([]); get(entity: EntityType): FieldDef[] { return this.defs[entity] ?? []; } + /** Whether this entity's definitions are in hand — an empty list is a real answer. */ + isLoaded(entity: EntityType): boolean { + return this.loaded.includes(entity); + } + /** Load once (idempotent). */ ensure(entity: EntityType) { - if (!this.loaded.has(entity)) void this.load(entity); + if (!this.loaded.includes(entity)) void this.load(entity); } async load(entity: EntityType) { try { const list = await getFields(entity); this.defs = { ...this.defs, [entity]: byOrder(list) }; - this.loaded.add(entity); + if (!this.loaded.includes(entity)) this.loaded = [...this.loaded, entity]; } catch (e) { console.error(`Failed to load ${entity} fields`, e); } diff --git a/client_v2/src/routes/dashboard/+page.svelte b/client_v2/src/routes/dashboard/+page.svelte new file mode 100644 index 00000000..072630bc --- /dev/null +++ b/client_v2/src/routes/dashboard/+page.svelte @@ -0,0 +1,1105 @@ + + + { + if (!dragging) cardsDragDisabled = true; + }} + onclick={() => (menuOpen = false)} +/> + + + {m['documentTitle.dashboard.list']()} + + +
+
+ {m['dashboard.dashboard']()} + +
e.stopPropagation()} role="none"> + + {#if menuOpen} + + {/if} +
+ + {#if !isEmpty} + {m['dashboard.dragHint']({ field: field.label })} + {/if} + {#if !field.choices} + + {/if} +
+ +
cardConsider(e as CustomEvent>)} + onfinalize={(e) => cardFinalize(e as CustomEvent>)} + > + {#each cards as card, idx (card.id)} +
ensureLoaded(card.key)} + > +
+ + {#if editingKey === card.key} + (editValue = (e.target as HTMLInputElement).value)} + onblur={commitEdit} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } + }} + /> + {:else} + startEdit(card)} + onkeydown={(e) => e.key === 'Enter' && startEdit(card)} + > + {card.key === UNASSIGNED ? m['dashboard.unassigned']() : card.key} + + {/if} + {m['dashboard.spoolCount']({ count: card.total })} + {#if card.key !== UNASSIGNED && card.total === 0 && !field.choices && editingKey !== card.key} + + {/if} +
+ {#if editingKey === card.key && renameError} +
{renameError}
+ {/if} +
+
onBodyScroll(card.key, e)} + use:dndzone={{ items: card.spools, type: 'spool', flipDurationMs: FLIP, dropTargetStyle: {} }} + onconsider={(e) => spoolConsider(idx, e as CustomEvent>)} + onfinalize={(e) => spoolFinalize(idx, e as CustomEvent>)} + > + {#each card.spools as s (s.id)} + {@const f = inventory.filamentById(s.filamentId)!} + {@const v = inventory.vendorOf(f)} + + + +
+
+ #{s.id} + {#if v.name !== '?'}{v.name} - + {/if}{f.name} +
+
+ {f.material} - + {weightAuto(s.remaining)} + / {weightAuto(f.weight)}{#if s.lastUsedLabel} + - {m['dashboard.lastUsed']({ + time: s.lastUsedLabel + })}{/if} +
+
+
+ {/each} +
+ {#if card.spools.length === 0 && !card.loading} + {m['dashboard.dropHere']()} + {/if} +
+ {#if card.loading} + + {/if} +
+ {/each} +
+ + {#if isEmpty} +

{m['dashboard.emptyHelp']()}

+ {/if} +
+ + diff --git a/client_v2/src/routes/locations/+page.svelte b/client_v2/src/routes/locations/+page.svelte deleted file mode 100644 index fe25a576..00000000 --- a/client_v2/src/routes/locations/+page.svelte +++ /dev/null @@ -1,960 +0,0 @@ - - - { - if (!dragging) shelvesDragDisabled = true; - }} -/> - - - {m['documentTitle.locations.list']()} - - -
-
- {m['locations.locations']()} - {#if !isEmpty}{m['locations.dragHint']()}{/if} - -
- -
shelfConsider(e as CustomEvent>)} - onfinalize={(e) => shelfFinalize(e as CustomEvent>)} - > - {#each shelves as shelf, idx (shelf.id)} -
ensureLoaded(shelf.name)} - > -
- - {#if editingLocation === shelf.name} - (editValue = (e.target as HTMLInputElement).value)} - onblur={commitEdit} - onkeydown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEdit(); - } - }} - /> - {:else} - startEdit(shelf.name)} - onkeydown={(e) => e.key === 'Enter' && startEdit(shelf.name)} - > - {shelf.name === NO_LOCATION ? m['locations.noLocation']() : shelf.name} - - {/if} - {m['locations.spoolCount']({ count: shelf.total })} - {#if shelf.name !== NO_LOCATION && shelf.total === 0 && editingLocation !== shelf.name} - - {/if} -
- {#if editingLocation === shelf.name && renameError} -
{renameError}
- {/if} -
-
onBodyScroll(shelf.name, e)} - use:dndzone={{ items: shelf.spools, type: 'spool', flipDurationMs: FLIP, dropTargetStyle: {} }} - onconsider={(e) => spoolConsider(idx, e as CustomEvent>)} - onfinalize={(e) => spoolFinalize(idx, e as CustomEvent>)} - > - {#each shelf.spools as s (s.id)} - {@const f = inventory.filamentById(s.filamentId)!} - {@const v = inventory.vendorOf(f)} - - - -
-
- #{s.id} - {#if v.name !== '?'}{v.name} - - {/if}{f.name} -
-
- {f.material} - - {weightAuto(s.remaining)} - / {weightAuto(f.weight)}{#if s.lastUsedLabel} - - {m['locations.lastUsed']({ - time: s.lastUsedLabel - })}{/if} -
-
-
- {/each} -
- {#if shelf.spools.length === 0 && !shelf.loading} - {m['locations.dropHere']()} - {/if} -
- {#if shelf.loading} - - {/if} -
- {/each} -
- - {#if isEmpty} -

{m['locations.noLocationsHelp']()}

- {/if} -
- - diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 73a81238..661d369e 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -382,8 +382,8 @@ class SpoolGroup(BaseModel): None, description=( "The group key. For group_by=filament/vendor this is the entity ID as a string; for " - "material/location it is the value. Null when the grouped field is unset (e.g. spools " - "with no location or a filament with no vendor)." + "material/location and extra fields it is the value. Null when the grouped " + "field is unset (e.g. spools with no location or a filament with no vendor)." ), examples=["12"], ) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index a5a45e9e..a938d010 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -3,7 +3,7 @@ import asyncio import logging from datetime import datetime -from typing import Annotated, Literal +from typing import Annotated from fastapi import APIRouter, Depends, Query, Request, WebSocket, WebSocketDisconnect from fastapi.encoders import jsonable_encoder @@ -379,11 +379,12 @@ async def notify_any( "/group", name="Find spool groups", description=( - "Group spools that match the search query by one axis (filament, vendor, material or " - "location) and return per-group aggregates: spool count, in-use count, total remaining " - "weight and most recent usage. Pagination is over groups, so a group is never split and " - "its aggregates are always complete. Uses the same filters as the spool search endpoint. " - "The total number of matching groups is returned in the x-total-count header." + "Group spools that match the search query by one axis (filament, vendor, material, " + "location, or a spool extra field) and return per-group aggregates: spool count, " + "in-use count, total remaining weight and most recent usage. Pagination is over groups, so " + "a group is never split and its aggregates are always complete. Uses the same filters as " + "the spool search endpoint. The total number of matching groups is returned in the " + "x-total-count header." ), response_model_exclude_none=True, responses={ @@ -396,8 +397,15 @@ async def find_groups( request: Request, db: Annotated[AsyncSession, Depends(get_db_session)], group_by: Annotated[ - Literal["filament", "vendor", "material", "location"], - Query(title="Group By", description="The field to group spools by."), + str, + Query( + title="Group By", + description=( + "The field to group spools by: filament, vendor, material, location, or extra. " + "for one of the spool's custom fields (text and single-choice fields only)." + ), + examples=["location", "extra.shelf"], + ), ], filament_name: Annotated[ str | None, diff --git a/spoolman/database/extra_field_query.py b/spoolman/database/extra_field_query.py index 29b92285..4fb540db 100644 --- a/spoolman/database/extra_field_query.py +++ b/spoolman/database/extra_field_query.py @@ -3,10 +3,11 @@ from __future__ import annotations import json +from dataclasses import dataclass from typing import TYPE_CHECKING import sqlalchemy -from sqlalchemy import Select +from sqlalchemy import Alias, ColumnElement, Select from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import aliased from sqlalchemy.sql.expression import FunctionElement @@ -107,6 +108,55 @@ def _compile_json_array_second_default(element: _JsonArraySecondElement, compile return f"JSON_EXTRACT({col_sql}, '$[1]')" +@dataclass +class ExtraFieldJoin: + """One extra field pulled onto a query as a plain column, ready to group or order by. + + Built by :func:`extra_field_join`, applied with :meth:`apply`. The two steps are separate + because the value expression has to exist before the SELECT is constructed, while the join + can only be added afterwards. + """ + + alias: Alias + field_key: str + id_column_name: str + #: The DB-decoded scalar, i.e. the string the user typed rather than its JSON encoding. + value: ColumnElement[str] + + def apply(self, stmt: Select, base_id_column: InstrumentedAttribute[int]) -> Select: + """Outer-join the field's row onto `stmt`. + + OUTER, so entities with no row for this key survive with a NULL value — the same + "unset" bucket a nullable built-in column produces. + """ + return stmt.join( + self.alias, + sqlalchemy.and_( + self.alias.c[self.id_column_name] == base_id_column, + self.alias.c.key == self.field_key, + ), + isouter=True, + ) + + +def extra_field_join(entity_type: EntityType, field_key: str) -> ExtraFieldJoin: + """Prepare an extra field to be selected as a column of an entity query. + + 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 + subqueries would correlate to this join instead of standing on their own. + """ + field_table = _get_field_table_for_entity(entity_type) + id_column_name = _get_entity_id_column(field_table).key + alias = field_table.__table__.alias() + return ExtraFieldJoin( + alias=alias, + field_key=field_key, + id_column_name=id_column_name, + value=_JsonScalarText(alias.c.value), + ) + + def _get_field_table_for_entity(entity_type: EntityType) -> type[models.Base]: """Map an entity type to its extra-field table.""" if entity_type == EntityType.spool: diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index c3d7f356..ab951300 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timezone import sqlalchemy -from sqlalchemy import case, func +from sqlalchemy import ColumnElement, case, func from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload @@ -15,8 +15,10 @@ from sqlalchemy.sql.functions import coalesce from spoolman.api.v1.models import EventType, Spool, SpoolEvent from spoolman.database import filament, models from spoolman.database.extra_field_query import ( + ExtraFieldJoin, apply_extra_field_filters_and_sort, apply_spool_related_extra_filters, + extra_field_join, ) from spoolman.database.utils import ( SortOrder, @@ -27,7 +29,7 @@ from spoolman.database.utils import ( parse_nested_field, ) from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError -from spoolman.extra_field_registry import EntityType +from spoolman.extra_field_registry import EntityType, ExtraField, ExtraFieldType, get_extra_fields from spoolman.math import weight_from_length from spoolman.ws import websocket_manager @@ -234,6 +236,16 @@ GROUP_BY_COLUMNS = { "location": models.Spool.location, } +# Prefix of a group_by that names a spool extra field instead of a built-in column. +EXTRA_GROUP_BY_PREFIX = "extra." + +# Extra fields that can be grouped on. Grouping collects every spool that shares a value, so +# it only makes sense for the field types whose value is a single repeatable string. +# Multi-choice is excluded because its value is a JSON *array*: two spools with +# overlapping-but-unequal selections would land in different groups, and a group key could +# not be turned back into a value to assign. +GROUPABLE_EXTRA_FIELD_TYPES = (ExtraFieldType.text, ExtraFieldType.choice) + def _apply_spool_filters( stmt: sqlalchemy.Select, @@ -269,6 +281,47 @@ def _apply_spool_filters( return stmt +async def _resolve_group_by( + db: AsyncSession, + group_by: str, +) -> tuple[ColumnElement, ColumnElement, ExtraFieldJoin | None]: + """Resolve a group_by into the column to group on, the column to title groups by, and any join. + + Grouping is either on a built-in column or on one of the spool's extra fields. The latter + lives in its own table, so it comes with a join the caller has to apply once the SELECT exists. + """ + if group_by.startswith(EXTRA_GROUP_BY_PREFIX): + join = _extra_field_group_join(await get_extra_fields(db, EntityType.spool), group_by) + # The value is the group's title as well; there is no separate entity to name it. + return join.value, join.value, join + if group_by in GROUP_BY_COLUMNS: + title_col = { + "filament": models.Filament.name, + "vendor": models.Vendor.name, + "material": models.Filament.material, + "location": models.Spool.location, + }[group_by] + return GROUP_BY_COLUMNS[group_by], title_col, None + raise ValueError( + f"Invalid group_by field '{group_by}'. Must be one of {sorted(GROUP_BY_COLUMNS)} " + f"or '{EXTRA_GROUP_BY_PREFIX}'.", + ) + + +def _extra_field_group_join(spool_fields: Sequence[ExtraField], group_by: str) -> ExtraFieldJoin: + """Validate a `group_by=extra.` against the registered spool extra fields.""" + field_key = group_by[len(EXTRA_GROUP_BY_PREFIX) :] + field = next((f for f in spool_fields if f.key == field_key), None) + if field is None: + raise ValueError(f"Unknown spool extra field '{field_key}'.") + is_multi_choice = field.field_type == ExtraFieldType.choice and field.multi_choice + if field.field_type not in GROUPABLE_EXTRA_FIELD_TYPES or is_multi_choice: + raise ValueError( + f"Spool extra field '{field_key}' cannot be grouped by. Only single-value text and choice fields can.", + ) + return extra_field_join(EntityType.spool, field_key) + + @dataclass class SpoolGroupResult: """One aggregated spool group, with the grouped entity hydrated for its header.""" @@ -309,15 +362,7 @@ async def find_groups( Returns a tuple of the requested page of groups and the total number of matching groups. """ - if group_by not in GROUP_BY_COLUMNS: - raise ValueError(f"Invalid group_by field '{group_by}'. Must be one of {sorted(GROUP_BY_COLUMNS)}.") - group_col = GROUP_BY_COLUMNS[group_by] - title_col = { - "filament": models.Filament.name, - "vendor": models.Vendor.name, - "material": models.Filament.material, - "location": models.Spool.location, - }[group_by] + group_col, title_col, extra_join = await _resolve_group_by(db, group_by) # Remaining weight is computed (see Spool.from_db); mirror that formula so the sum is correct. # The fallback literal is 0.0 (float, not int): the weights are floats, and CockroachDB rejects @@ -354,6 +399,8 @@ async def find_groups( lot_nr=lot_nr, allow_archived=allow_archived, ) + if extra_join is not None: + stmt = extra_join.apply(stmt, models.Spool.id) stmt = await apply_extra_field_filters_and_sort( db=db, stmt=stmt, @@ -399,7 +446,8 @@ async def find_groups( rows = (await db.execute(stmt)).all() - # Hydrate the grouped entity for the header (filament/vendor); material/location need nothing. + # Hydrate the grouped entity for the header (filament/vendor). The value-keyed axes — + # material, location, extra fields — are their own header and need nothing. keys = [row.group_key for row in rows if row.group_key is not None] filament_map: dict[int, models.Filament] = {} vendor_map: dict[int, models.Vendor] = {} diff --git a/spoolman/settings.py b/spoolman/settings.py index c2ff1f7a..57aab3ad 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -73,3 +73,10 @@ register_setting("base_url", SettingType.STRING, json.dumps("")) register_setting("locations", SettingType.ARRAY, json.dumps([])) register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({})) + +# The dashboard groups spools by a field the user picks (location, lot_nr, material, or a spool +# extra field), so its layout is stored per field rather than per location like the two settings +# above. dashboard_groups maps a field key to its ordered group keys ("" being the unset group); +# dashboard_spoolorders maps a field key to a group key to its ordered spool ids. +register_setting("dashboard_groups", SettingType.OBJECT, json.dumps({})) +register_setting("dashboard_spoolorders", SettingType.OBJECT, json.dumps({})) diff --git a/tests_integration/tests/spool/test_group.py b/tests_integration/tests/spool/test_group.py index 4bca7e98..86df7528 100644 --- a/tests_integration/tests/spool/test_group.py +++ b/tests_integration/tests/spool/test_group.py @@ -1,5 +1,7 @@ """Integration tests for the Spool group (aggregate) API endpoint.""" +import json +import uuid from collections.abc import Iterable from dataclasses import dataclass from typing import Any @@ -139,6 +141,159 @@ def test_group_total_remaining_clamps_over_used_spool(random_filament: dict[str, def test_group_invalid_group_by(): - """An unsupported group_by is rejected by request validation.""" + """An unsupported group_by is rejected.""" result = httpx.get(f"{URL}/api/v1/spool/group", params={"group_by": "banana"}) - assert result.status_code == 422 + assert result.status_code == 400 + + +@dataclass +class ExtraFixture: + filament_id: int + field_key: str + other_field_key: str + spool_ids: list[int] + + +@pytest.fixture +def extra_field_spools(random_filament: dict[str, Any]) -> Iterable[ExtraFixture]: + """Spools carrying a "shelf" text field: two on A, one on B, one with the field unset. + + A second, unrelated text field is set on every spool so a query can both group by one + extra field and filter by another. + """ + field_key = f"shelf_{uuid.uuid4().hex[:8]}" + other_field_key = f"room_{uuid.uuid4().hex[:8]}" + for key, name in ((field_key, "Shelf"), (other_field_key, "Room")): + httpx.post(f"{URL}/api/v1/field/spool/{key}", json={"name": name, "field_type": "text"}).raise_for_status() + + filament_id = random_filament["id"] + spool_ids: list[int] = [] + for shelf in ("A", "A", "B", None): + extra = {other_field_key: json.dumps("Workshop")} + if shelf is not None: + extra[field_key] = json.dumps(shelf) + result = httpx.post(f"{URL}/api/v1/spool", json={"filament_id": filament_id, "extra": extra}) + result.raise_for_status() + spool_ids.append(result.json()["id"]) + + yield ExtraFixture( + filament_id=filament_id, + field_key=field_key, + other_field_key=other_field_key, + spool_ids=spool_ids, + ) + + for spool_id in spool_ids: + httpx.delete(f"{URL}/api/v1/spool/{spool_id}").raise_for_status() + for key in (field_key, other_field_key): + httpx.delete(f"{URL}/api/v1/field/spool/{key}").raise_for_status() + + +def _counts_by_key(groups: list[dict[str, Any]]) -> dict[str | None, int]: + """Group spool counts keyed by group key. + + Keyed rather than ordered because the databases disagree on where a NULL key sorts + (SQLite/MariaDB put it first, PostgreSQL/CockroachDB last), and none of these tests + are about that. The dashboard orders its cards from its own saved layout anyway. + """ + return {group.get("key"): group["spool_count"] for group in groups} + + +def test_group_by_extra_field(extra_field_spools: ExtraFixture): + """Grouping by a spool extra field keys on the decoded value, with a null group for unset.""" + result = httpx.get( + f"{URL}/api/v1/spool/group", + params={ + "group_by": f"extra.{extra_field_spools.field_key}", + "filament.id": str(extra_field_spools.filament_id), + }, + ) + result.raise_for_status() + assert result.headers["x-total-count"] == "3" + + groups = result.json() + # The value is the key, decoded — not the "A" the JSON encoding would give. + assert _counts_by_key(groups) == {None: 1, "A": 2, "B": 1} + assert groups[0]["group_by"] == f"extra.{extra_field_spools.field_key}" + + +def test_group_by_extra_field_with_extra_filter(extra_field_spools: ExtraFixture): + """Grouping by one extra field while filtering on another keeps both intact. + + The filter is a subquery over the same extra-field table the grouping joins, so this + guards against the two collapsing into each other. + """ + result = httpx.get( + f"{URL}/api/v1/spool/group", + params={ + "group_by": f"extra.{extra_field_spools.field_key}", + f"extra.{extra_field_spools.other_field_key}": '"Workshop"', + "filament.id": str(extra_field_spools.filament_id), + }, + ) + result.raise_for_status() + assert _counts_by_key(result.json()) == {None: 1, "A": 2, "B": 1} + + # A filter that matches nothing leaves no groups at all. + result = httpx.get( + f"{URL}/api/v1/spool/group", + params={ + "group_by": f"extra.{extra_field_spools.field_key}", + f"extra.{extra_field_spools.other_field_key}": '"Garage"', + "filament.id": str(extra_field_spools.filament_id), + }, + ) + result.raise_for_status() + assert result.json() == [] + + +def test_group_by_extra_field_scope_matches_spool_search(extra_field_spools: ExtraFixture): + """Each group's spools can be fetched back with an exact-match filter on the same field.""" + result = httpx.get( + f"{URL}/api/v1/spool", + params={ + f"extra.{extra_field_spools.field_key}": '"A"', + "filament.id": str(extra_field_spools.filament_id), + }, + ) + result.raise_for_status() + assert len(result.json()) == 2 + + # An empty filter is how the client scopes the unset group. + result = httpx.get( + f"{URL}/api/v1/spool", + params={ + f"extra.{extra_field_spools.field_key}": "", + "filament.id": str(extra_field_spools.filament_id), + }, + ) + result.raise_for_status() + assert len(result.json()) == 1 + + +def test_group_by_unknown_extra_field(): + """Grouping by an extra field that isn't registered is a client error.""" + result = httpx.get(f"{URL}/api/v1/spool/group", params={"group_by": "extra.does_not_exist"}) + assert result.status_code == 400 + + +def test_group_by_ungroupable_extra_field(): + """Numeric and multi-choice fields hold no single repeatable value, so they can't be grouped by.""" + numeric_key = f"count_{uuid.uuid4().hex[:8]}" + multi_key = f"tags_{uuid.uuid4().hex[:8]}" + httpx.post( + f"{URL}/api/v1/field/spool/{numeric_key}", + json={"name": "Count", "field_type": "integer"}, + ).raise_for_status() + httpx.post( + f"{URL}/api/v1/field/spool/{multi_key}", + json={"name": "Tags", "field_type": "choice", "choices": ["a", "b"], "multi_choice": True}, + ).raise_for_status() + + try: + for key in (numeric_key, multi_key): + result = httpx.get(f"{URL}/api/v1/spool/group", params={"group_by": f"extra.{key}"}) + assert result.status_code == 400, key + finally: + for key in (numeric_key, multi_key): + httpx.delete(f"{URL}/api/v1/field/spool/{key}").raise_for_status()