diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index 88ff2ae85..b68f97845 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -217,6 +217,7 @@ "filament": "Filaments", "fields": { "id": "ID", + "spool_count": "Spool Count", "vendor_name": "Manufacturer", "vendor": "Manufacturer", "name": "Name", diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 059b607f0..5a9981999 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -60,6 +60,7 @@ interface FilteredColumnProps { filters?: ColumnFilterItem[]; filteredValue?: string[]; allowMultipleFilters?: boolean; + includeEmptyFilter?: boolean; onFilterDropdownOpen?: () => void; loadingFilters?: boolean; } @@ -188,6 +189,7 @@ export function RichColumn( interface FilteredQueryColumnProps extends BaseColumnProps { filterValueQuery: UseQueryResult; allowMultipleFilters?: boolean; + includeEmptyOption?: boolean; } export function FilteredQueryColumn(props: FilteredQueryColumnProps) { @@ -205,10 +207,12 @@ export function FilteredQueryColumn(props: FilteredQueryColu return item; }); } - filters.push({ - text: "", - value: "", - }); + if (props.includeEmptyOption ?? true) { + filters.push({ + text: "", + value: "", + }); + } const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, props.dataId ?? (props.id as keyof Obj)); diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 2d42198ce..f90d34e5f 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -1,7 +1,9 @@ import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons"; import { List, useTable } from "@refinedev/antd"; import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core"; +import { useQuery } from "@tanstack/react-query"; import { Button, Dropdown, Table } from "antd"; +import { ColumnFilterItem } from "antd/es/table/interface"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { useMemo, useState } from "react"; @@ -27,6 +29,7 @@ import { removeUndefined } from "../../utils/filtering"; import { EntityType, useGetFields } from "../../utils/queryFields"; import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; import { useCurrencyFormatter } from "../../utils/settings"; +import { getAPIURL } from "../../utils/url"; import { IFilament } from "./model"; dayjs.extend(utc); @@ -50,10 +53,19 @@ function translateColumnI18nKey(columnName: string): string { return `filament.fields.${columnName}`; } +function getColumnLabel(t: (key: string, options?: unknown) => string, columnName: string): string { + if (columnName === "spool_count") { + return t("filament.fields.spool_count", { defaultValue: "Spool Count" }); + } + + return t(translateColumnI18nKey(columnName)); +} + const namespace = "filamentList-v2"; const allColumns: (keyof IFilamentCollapsed & string)[] = [ "id", + "spool_count", "vendor.name", "name", "material", @@ -72,12 +84,35 @@ const defaultColumns = allColumns.filter( (column_id) => ["registered", "density", "diameter", "spool_weight"].indexOf(column_id) === -1, ); +function useSpoolmanSpoolCounts(enabled: boolean = false) { + return useQuery({ + enabled, + queryKey: ["filamentSpoolCounts"], + queryFn: async () => { + const response = await fetch(getAPIURL() + "/filament/spool-count"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data + .sort((a, b) => a - b) + .map((count) => ({ + text: String(count), + value: String(count), + })); + }, + }); +} + export const FilamentList = () => { const t = useTranslate(); const invalidate = useInvalidate(); const navigate = useNavigate(); const extraFields = useGetFields(EntityType.filament); const currencyFormatter = useCurrencyFormatter(); + const querySpoolCounts = useSpoolmanSpoolCounts(true); const allColumnsWithExtraFields = [...allColumns, ...(extraFields.data?.map((field) => "extra." + field.key) ?? [])]; @@ -194,7 +229,7 @@ export const FilamentList = () => { return { key: column_id, - label: t(translateColumnI18nKey(column_id)), + label: getColumnLabel(t, column_id), }; }), selectedKeys: showColumns, @@ -230,6 +265,18 @@ export const FilamentList = () => { i18ncat: "filament", width: 70, }), + FilteredQueryColumn({ + ...commonProps, + id: "spool_count", + dataId: "spool_count", + title: t("filament.fields.spool_count"), + filterValueQuery: querySpoolCounts, + // Spool count is always a computed number, so an bucket would be + // misleading noise rather than a real "missing value" state. + includeEmptyOption: false, + width: 120, + transform: (value) => value ?? 0, + }), FilteredQueryColumn({ ...commonProps, id: "vendor.name", diff --git a/client/src/pages/filaments/model.tsx b/client/src/pages/filaments/model.tsx index b21c709ea..f4f042ec2 100644 --- a/client/src/pages/filaments/model.tsx +++ b/client/src/pages/filaments/model.tsx @@ -11,6 +11,7 @@ export interface IFilament { diameter: number; weight?: number; spool_weight?: number; + spool_count?: number; article_number?: string; comment?: string; settings_extruder_temp?: number; diff --git a/migrations/versions/2026_02_20_1605-b5f9c2e31a1b_add_index_on_spool_filament_id.py b/migrations/versions/2026_02_20_1605-b5f9c2e31a1b_add_index_on_spool_filament_id.py new file mode 100644 index 000000000..978ed0336 --- /dev/null +++ b/migrations/versions/2026_02_20_1605-b5f9c2e31a1b_add_index_on_spool_filament_id.py @@ -0,0 +1,35 @@ +"""add index on spool.filament_id. + +Revision ID: b5f9c2e31a1b +Revises: 415a8f855e14 +Create Date: 2026-02-20 16:05:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b5f9c2e31a1b" +down_revision = "415a8f855e14" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + # Keep the migration idempotent so local PR containers can be rebuilt against the + # same SQLite file without failing on an index that already exists. + index_names = {index["name"] for index in inspector.get_indexes("spool")} + if "ix_spool_filament_id" not in index_names: + op.create_index("ix_spool_filament_id", "spool", ["filament_id"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + conn = op.get_bind() + inspector = sa.inspect(conn) + index_names = {index["name"] for index in inspector.get_indexes("spool")} + if "ix_spool_filament_id" in index_names: + op.drop_index("ix_spool_filament_id", table_name="spool") diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 3e3f859af..9c2867381 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -304,6 +304,15 @@ async def find( examples=["polymaker_pla_polysonicblack_1000_175"], ), ] = None, + spool_count: Annotated[ + str | None, + Query( + title="Spool Count", + description="Match filaments with an exact spool count. Separate multiple counts with a comma.", + pattern=r"^\d+(,\d+)*$", + examples=["0", "1,2,3"], + ), + ] = None, sort: Annotated[ str | None, Query( @@ -331,6 +340,10 @@ async def find( vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")] else: vendor_ids = None + if spool_count is not None: + spool_counts = [int(spool_count_item) for spool_count_item in spool_count.split(",")] + else: + spool_counts = None if color_hex is not None: matched_filaments = await filament.find_by_color( @@ -354,6 +367,7 @@ async def find( sort_by=sort_by, limit=limit, offset=offset, + spool_count=spool_counts, ) # Set x-total-count header for pagination @@ -366,6 +380,19 @@ async def find( ) +@router.get( + "/spool-count", + name="Find filament spool counts", + description="Get distinct spool-count values across filaments.", + response_model_exclude_none=True, +) +async def find_spool_counts( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[int]: + return await filament.find_spool_counts(db=db) + + @router.websocket( "", name="Listen to filament changes", diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index a24d79f7c..476502945 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -134,6 +134,12 @@ class Filament(BaseModel): examples=[1000], ) spool_weight: float | None = Field(None, ge=0, description="The empty spool weight, in grams.", examples=[140]) + spool_count: int | None = Field( + None, + ge=0, + description="Number of spools associated with this filament.", + examples=[3], + ) article_number: str | None = Field( None, max_length=64, @@ -212,6 +218,7 @@ def from_db(item: models.Filament) -> "Filament": diameter=item.diameter, weight=item.weight, spool_weight=item.spool_weight, + spool_count=getattr(item, "spool_count", None), article_number=item.article_number, comment=item.comment, settings_extruder_temp=item.settings_extruder_temp, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index e2d742758..5a828e9bc 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -25,6 +25,27 @@ from spoolman.ws import websocket_manager +async def set_spool_counts( + db: AsyncSession, + filaments: Sequence[models.Filament], +) -> None: + """Populate spool_count on filament models.""" + if not filaments: + return + + filament_ids = [item.id for item in filaments] + spool_count_stmt = ( + select(models.Spool.filament_id, func.count(models.Spool.id)) + .where(models.Spool.filament_id.in_(filament_ids)) + .group_by(models.Spool.filament_id) + ) + spool_count_rows = await db.execute(spool_count_stmt) + spool_count_map = {int(filament_id): int(count) for filament_id, count in spool_count_rows.all()} + + for item in filaments: + item.spool_count = spool_count_map.get(item.id, 0) + + async def create( *, db: AsyncSession, @@ -76,6 +97,7 @@ async def create( ) db.add(filament) await db.commit() + await set_spool_counts(db, [filament]) await filament_changed(filament, EventType.ADDED) return filament @@ -89,10 +111,11 @@ async def get_by_id(db: AsyncSession, filament_id: int) -> models.Filament: ) if filament is None: raise ItemNotFoundError(f"No filament with ID {filament_id} found.") + await set_spool_counts(db, [filament]) return filament -async def find( +async def find( # noqa: C901 *, db: AsyncSession, ids: list[int] | None = None, @@ -105,6 +128,7 @@ async def find( sort_by: dict[str, SortOrder] | None = None, limit: int | None = None, offset: int = 0, + spool_count: int | Sequence[int] | None = None, ) -> tuple[list[models.Filament], int]: """Find a list of filament objects by search criteria. @@ -113,6 +137,13 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ + spool_count_expr = func.coalesce( + select(func.count(models.Spool.id)).where(models.Spool.filament_id == models.Filament.id).scalar_subquery(), + 0, + ) + # Reuse the same scalar count expression for filtering and sorting so the list can + # stay server-driven without materializing every filament first. + stmt = ( select(models.Filament) .options(contains_eager(models.Filament.vendor)) @@ -126,6 +157,10 @@ async def find( stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) stmt = add_where_clause_str_opt(stmt, models.Filament.external_id, external_id) + if spool_count is not None: + if isinstance(spool_count, int): + spool_count = [spool_count] + stmt = stmt.where(spool_count_expr.in_(spool_count)) total_count = None @@ -135,6 +170,16 @@ async def find( stmt = stmt.offset(offset).limit(limit) + spool_count_sort_order = None + if sort_by is not None and "spool_count" in sort_by: + spool_count_sort_order = sort_by["spool_count"] + sort_by = {field: order for field, order in sort_by.items() if field != "spool_count"} + + if spool_count_sort_order == SortOrder.ASC: + stmt = stmt.order_by(spool_count_expr.asc()) + elif spool_count_sort_order == SortOrder.DESC: + stmt = stmt.order_by(spool_count_expr.desc()) + if sort_by is not None: for fieldstr, order in sort_by.items(): field = parse_nested_field(models.Filament, fieldstr) @@ -148,6 +193,8 @@ async def find( execution_options={"populate_existing": True}, ) result = list(rows.unique().scalars().all()) + await set_spool_counts(db, result) + if total_count is None: total_count = len(result) @@ -175,6 +222,7 @@ async def update( else: setattr(filament, k, v) await db.commit() + await set_spool_counts(db, [filament]) await filament_changed(filament, EventType.UPDATED) return filament @@ -221,6 +269,22 @@ async def find_article_numbers( return [row[0] for row in rows.all() if row[0] is not None] +async def find_spool_counts( + *, + db: AsyncSession, +) -> list[int]: + """Find distinct spool counts per filament.""" + spool_counts_stmt = ( + select(func.count(models.Spool.id)) + .select_from(models.Filament) + .join(models.Spool, models.Spool.filament_id == models.Filament.id, isouter=True) + .group_by(models.Filament.id) + ) + spool_count_rows = await db.execute(spool_counts_stmt) + spool_counts = {int(row[0]) for row in spool_count_rows.all()} + return sorted(spool_counts) + + async def find_by_color( *, db: AsyncSession, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..3d5204d28 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -33,6 +33,13 @@ def utc_timezone_naive(dt: datetime) -> datetime: return dt.astimezone(tz=timezone.utc).replace(tzinfo=None) +async def notify_filament_count_changed(db: AsyncSession, filament_ids: set[int]) -> None: + """Send updated filament events for spool-count changes.""" + for filament_id in filament_ids: + filament_item = await filament.get_by_id(db, filament_id) + await filament.filament_changed(filament_item, EventType.UPDATED) + + async def create( *, db: AsyncSession, @@ -96,6 +103,7 @@ async def create( db.add(spool) await db.commit() await spool_changed(spool, EventType.ADDED) + await notify_filament_count_changed(db, {spool.filament_id}) return spool @@ -216,6 +224,7 @@ async def update( ) -> models.Spool: """Update the fields of a spool object.""" spool = await get_by_id(db, spool_id) + previous_filament_id = spool.filament_id for k, v in data.items(): if k == "filament_id": spool.filament = await filament.get_by_id(db, v) @@ -236,14 +245,19 @@ async def update( setattr(spool, k, v) await db.commit() await spool_changed(spool, EventType.UPDATED) + if spool.filament_id != previous_filament_id: + await notify_filament_count_changed(db, {previous_filament_id, spool.filament_id}) return spool async def delete(db: AsyncSession, spool_id: int) -> None: """Delete a spool object.""" spool = await get_by_id(db, spool_id) - await spool_changed(spool, EventType.DELETED) + filament_id = spool.filament_id await db.delete(spool) + await db.flush() + await spool_changed(spool, EventType.DELETED) + await notify_filament_count_changed(db, {filament_id}) async def clear_extra_field(db: AsyncSession, key: str) -> None: diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index 44299aef4..1b29ca788 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -15,6 +15,24 @@ class Fixture: filaments: list[dict[str, Any]] +def add_spool(filament_id: int) -> dict[str, Any]: + """Create a spool for a filament.""" + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": filament_id, + "used_weight": 0, + }, + ) + result.raise_for_status() + return result.json() + + +def delete_spool(spool: dict[str, Any]) -> None: + """Delete a spool created by a test.""" + httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status() + + @pytest.fixture(scope="module") def filaments(random_vendor_mod: dict[str, Any], random_empty_vendor_mod: dict[str, Any]) -> Iterable[Fixture]: """Add some filaments to the database.""" @@ -141,6 +159,71 @@ def test_find_all_filaments_sort_desc(filaments: Fixture): assert filaments_result[-1] == filaments.filaments[0] +def test_find_filaments_by_spool_count(filaments: Fixture): + spools = [ + add_spool(filaments.filaments[0]["id"]), + add_spool(filaments.filaments[0]["id"]), + add_spool(filaments.filaments[1]["id"]), + ] + + try: + result = httpx.get(f"{URL}/api/v1/filament", params={"spool_count": "0", "sort": "id:asc"}) + result.raise_for_status() + + filaments_result = result.json() + assert [item["id"] for item in filaments_result] == [ + filaments.filaments[2]["id"], + filaments.filaments[3]["id"], + filaments.filaments[4]["id"], + ] + assert [item["spool_count"] for item in filaments_result] == [0, 0, 0] + finally: + for spool in spools: + delete_spool(spool) + + +def test_find_filaments_sort_by_spool_count(filaments: Fixture): + spools = [ + add_spool(filaments.filaments[0]["id"]), + add_spool(filaments.filaments[0]["id"]), + add_spool(filaments.filaments[1]["id"]), + ] + + try: + result = httpx.get(f"{URL}/api/v1/filament?sort=spool_count:desc,id:asc") + result.raise_for_status() + + filaments_result = result.json() + assert [item["id"] for item in filaments_result] == [ + filaments.filaments[0]["id"], + filaments.filaments[1]["id"], + filaments.filaments[2]["id"], + filaments.filaments[3]["id"], + filaments.filaments[4]["id"], + ] + assert [item["spool_count"] for item in filaments_result] == [2, 1, 0, 0, 0] + finally: + for spool in spools: + delete_spool(spool) + + +def test_find_distinct_spool_counts(filaments: Fixture): + spools = [ + add_spool(filaments.filaments[0]["id"]), + add_spool(filaments.filaments[0]["id"]), + add_spool(filaments.filaments[1]["id"]), + ] + + try: + result = httpx.get(f"{URL}/api/v1/filament/spool-count") + result.raise_for_status() + + assert result.json() == [0, 1, 2] + finally: + for spool in spools: + delete_spool(spool) + + def test_find_all_filaments_sort_multiple(filaments: Fixture): # Execute result = httpx.get(f"{URL}/api/v1/filament?sort=density:desc,id:asc") diff --git a/tests_integration/tests/filament/test_get.py b/tests_integration/tests/filament/test_get.py index 63d596122..bc00c94a3 100644 --- a/tests_integration/tests/filament/test_get.py +++ b/tests_integration/tests/filament/test_get.py @@ -89,3 +89,25 @@ def test_get_filament_not_found(): assert "filament" in message assert "id" in message assert "123456789" in message + + +def test_get_filament_spool_count(random_filament: dict[str, Any]): + """Test that spool_count is returned for a filament.""" + spool_result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_filament["id"], + "used_weight": 0, + }, + ) + spool_result.raise_for_status() + spool = spool_result.json() + + try: + result = httpx.get(f"{URL}/api/v1/filament/{random_filament['id']}") + result.raise_for_status() + + filament = result.json() + assert filament["spool_count"] == 1 + finally: + httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()