From 4fdea75e3fe6b7738723fda69eac3e7b8231377f Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 25 Aug 2023 07:21:57 +0200 Subject: [PATCH 01/31] Serverside spool sorting --- spoolman/api/v1/spool.py | 16 +++++++++++++++ spoolman/database/spool.py | 40 ++++++++++++++++++++++++++++++++++++-- spoolman/database/utils.py | 8 ++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 spoolman/database/utils.py diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index db7b91c..fb66c03 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Message, Spool from spoolman.database import spool from spoolman.database.database import get_db_session +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemCreateError logger = logging.getLogger(__name__) @@ -110,7 +111,21 @@ async def find( title="Allow Archived", description="Whether to include archived spools in the search results.", ), + sort: Optional[str] = Query( + default=None, + title="Sort", + description=( + 'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.' + ), + example="filament.name:asc,location:desc", + ), ) -> list[Spool]: + sort_by: dict[str, SortOrder] = {} + if sort is not None: + for sort_item in sort.split(","): + field, direction = sort_item.split(":") + sort_by[field] = SortOrder[direction.upper()] + db_items = await spool.find( db=db, filament_name=filament_name, @@ -121,6 +136,7 @@ async def find( location=location, lot_nr=lot_nr, allow_archived=allow_archived, + sort_by=sort_by, ) return [Spool.from_db(db_item) for db_item in db_items] diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 660c71c..96aa349 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import filament, models +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemCreateError, ItemNotFoundError from spoolman.math import weight_from_length @@ -75,7 +76,29 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool: return spool -async def find( +def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column: + """Parse a nested field string into a sqlalchemy field object.""" + fields = field.split(".") + if not hasattr(base_obj, fields[0]): + raise ValueError(f"Invalid field name '{field}'") + + if fields[0] == "filament" and len(fields) == 1: + raise ValueError("No field specified for filament") + if fields[0] == "filament": + return parse_nested_field(models.Filament, ".".join(fields[1:])) + + if fields[0] == "vendor" and len(fields) == 1: + raise ValueError("No field specified for vendor") + if fields[0] == "vendor": + return parse_nested_field(models.Vendor, ".".join(fields[1:])) + + if len(fields) > 1: + raise ValueError(f"Field '{fields[0]}' does not have any nested fields") + + return getattr(base_obj, fields[0]) + + +async def find( # noqa: C901 *, db: AsyncSession, filament_name: Optional[str] = None, @@ -86,8 +109,13 @@ async def find( location: Optional[str] = None, lot_nr: Optional[str] = None, allow_archived: bool = False, + sort_by: Optional[dict[str, SortOrder]] = None, ) -> list[models.Spool]: - """Find a list of spool objects by search criteria.""" + """Find a list of spool objects by search criteria. + + Sort by a field by passing a dict with the field name as key and the sort order as value. + The field name can contain nested fields, e.g. filament.name. + """ stmt = ( sqlalchemy.select(models.Spool) .join(models.Spool.filament, isouter=True) @@ -117,6 +145,14 @@ async def find( ), ) + if sort_by is not None: + for fieldstr, order in sort_by.items(): + field = parse_nested_field(models.Spool, fieldstr) + if order == SortOrder.ASC: + stmt = stmt.order_by(field.asc()) + elif order == SortOrder.DESC: + stmt = stmt.order_by(field.desc()) + rows = await db.execute(stmt) return list(rows.scalars().all()) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py new file mode 100644 index 0000000..eb3e15b --- /dev/null +++ b/spoolman/database/utils.py @@ -0,0 +1,8 @@ +"""Utility functions for the database module.""" + +from enum import Enum + + +class SortOrder(Enum): + ASC = 1 + DESC = 2 From b9f1b71c1fa30db6e7d3a678aba2dde0872ae966 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 25 Aug 2023 10:04:21 +0200 Subject: [PATCH 02/31] Serverside spool find limit and offset --- spoolman/api/v1/spool.py | 12 ++++++++++++ spoolman/database/spool.py | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index fb66c03..703c955 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -119,6 +119,16 @@ async def find( ), example="filament.name:asc,location:desc", ), + limit: Optional[int] = Query( + default=None, + title="Limit", + description="Maximum number of items in the response.", + ), + offset: int = Query( + default=0, + title="Offset", + description="Offset in the full result set if a limit is set.", + ), ) -> list[Spool]: sort_by: dict[str, SortOrder] = {} if sort is not None: @@ -137,6 +147,8 @@ async def find( lot_nr=lot_nr, allow_archived=allow_archived, sort_by=sort_by, + limit=limit, + offset=offset, ) return [Spool.from_db(db_item) for db_item in db_items] diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 96aa349..c36066b 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -98,7 +98,7 @@ def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Co return getattr(base_obj, fields[0]) -async def find( # noqa: C901 +async def find( # noqa: C901, PLR0912 *, db: AsyncSession, filament_name: Optional[str] = None, @@ -110,6 +110,8 @@ async def find( # noqa: C901 lot_nr: Optional[str] = None, allow_archived: bool = False, sort_by: Optional[dict[str, SortOrder]] = None, + limit: Optional[int] = None, + offset: int = 0, ) -> list[models.Spool]: """Find a list of spool objects by search criteria. @@ -153,6 +155,9 @@ async def find( # noqa: C901 elif order == SortOrder.DESC: stmt = stmt.order_by(field.desc()) + if limit is not None: + stmt = stmt.offset(offset).limit(limit) + rows = await db.execute(stmt) return list(rows.scalars().all()) From 9d51ee49eddc9c16b236115aae9a003b413f93e0 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 25 Aug 2023 10:23:12 +0200 Subject: [PATCH 03/31] Serverside sort and limit for vendor and filaments --- spoolman/api/v1/filament.py | 28 ++++++++++++++++++++++++++++ spoolman/api/v1/vendor.py | 28 ++++++++++++++++++++++++++++ spoolman/database/filament.py | 23 +++++++++++++++++++++-- spoolman/database/spool.py | 24 +----------------------- spoolman/database/utils.py | 26 ++++++++++++++++++++++++++ spoolman/database/vendor.py | 15 +++++++++++++++ 6 files changed, 119 insertions(+), 25 deletions(-) diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 6d94d74..46b4310 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Filament, Message from spoolman.database import filament from spoolman.database.database import get_db_session +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemDeleteError logger = logging.getLogger(__name__) @@ -137,7 +138,31 @@ async def find( title="Filament Article Number", description="Partial case-insensitive search term for the filament article number.", ), + sort: Optional[str] = Query( + default=None, + title="Sort", + description=( + 'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.' + ), + example="vendor.name:asc,spool_weight:desc", + ), + limit: Optional[int] = Query( + default=None, + title="Limit", + description="Maximum number of items in the response.", + ), + offset: int = Query( + default=0, + title="Offset", + description="Offset in the full result set if a limit is set.", + ), ) -> list[Filament]: + sort_by: dict[str, SortOrder] = {} + if sort is not None: + for sort_item in sort.split(","): + field, direction = sort_item.split(":") + sort_by[field] = SortOrder[direction.upper()] + db_items = await filament.find( db=db, vendor_name=vendor_name, @@ -145,6 +170,9 @@ async def find( name=name, material=material, article_number=article_number, + sort_by=sort_by, + limit=limit, + offset=offset, ) return [Filament.from_db(db_item) for db_item in db_items] diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index c09675f..8f9f348 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Message, Vendor from spoolman.database import vendor from spoolman.database.database import get_db_session +from spoolman.database.utils import SortOrder router = APIRouter( prefix="/vendor", @@ -51,10 +52,37 @@ async def find( title="Vendor Name", description="Partial case-insensitive search term for the vendor name.", ), + sort: Optional[str] = Query( + default=None, + title="Sort", + description=( + 'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.' + ), + example="name:asc,id:desc", + ), + limit: Optional[int] = Query( + default=None, + title="Limit", + description="Maximum number of items in the response.", + ), + offset: int = Query( + default=0, + title="Offset", + description="Offset in the full result set if a limit is set.", + ), ) -> list[Vendor]: + sort_by: dict[str, SortOrder] = {} + if sort is not None: + for sort_item in sort.split(","): + field, direction = sort_item.split(":") + sort_by[field] = SortOrder[direction.upper()] + db_items = await vendor.find( db=db, name=name, + sort_by=sort_by, + limit=limit, + offset=offset, ) return [Vendor.from_db(db_item) for db_item in db_items] diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 5e36827..0376b28 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import models, vendor +from spoolman.database.utils import SortOrder, parse_nested_field from spoolman.exceptions import ItemDeleteError, ItemNotFoundError @@ -65,7 +66,7 @@ async def get_by_id(db: AsyncSession, filament_id: int) -> models.Filament: return filament -async def find( +async def find( # noqa: C901 *, db: AsyncSession, vendor_name: Optional[str] = None, @@ -73,8 +74,15 @@ async def find( name: Optional[str] = None, material: Optional[str] = None, article_number: Optional[str] = None, + sort_by: Optional[dict[str, SortOrder]] = None, + limit: Optional[int] = None, + offset: int = 0, ) -> list[models.Filament]: - """Find a list of filament objects by search criteria.""" + """Find a list of filament objects by search criteria. + + Sort by a field by passing a dict with the field name as key and the sort order as value. + The field name can contain nested fields, e.g. vendor.name. + """ stmt = ( select(models.Filament) .options(contains_eager(models.Filament.vendor)) @@ -91,6 +99,17 @@ async def find( if article_number is not None: stmt = stmt.where(models.Filament.article_number.ilike(f"%{article_number}%")) + if sort_by is not None: + for fieldstr, order in sort_by.items(): + field = parse_nested_field(models.Filament, fieldstr) + if order == SortOrder.ASC: + stmt = stmt.order_by(field.asc()) + elif order == SortOrder.DESC: + stmt = stmt.order_by(field.desc()) + + if limit is not None: + stmt = stmt.offset(offset).limit(limit) + rows = await db.execute(stmt) return list(rows.scalars().all()) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index c36066b..ab14a04 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import filament, models -from spoolman.database.utils import SortOrder +from spoolman.database.utils import SortOrder, parse_nested_field from spoolman.exceptions import ItemCreateError, ItemNotFoundError from spoolman.math import weight_from_length @@ -76,28 +76,6 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool: return spool -def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column: - """Parse a nested field string into a sqlalchemy field object.""" - fields = field.split(".") - if not hasattr(base_obj, fields[0]): - raise ValueError(f"Invalid field name '{field}'") - - if fields[0] == "filament" and len(fields) == 1: - raise ValueError("No field specified for filament") - if fields[0] == "filament": - return parse_nested_field(models.Filament, ".".join(fields[1:])) - - if fields[0] == "vendor" and len(fields) == 1: - raise ValueError("No field specified for vendor") - if fields[0] == "vendor": - return parse_nested_field(models.Vendor, ".".join(fields[1:])) - - if len(fields) > 1: - raise ValueError(f"Field '{fields[0]}' does not have any nested fields") - - return getattr(base_obj, fields[0]) - - async def find( # noqa: C901, PLR0912 *, db: AsyncSession, diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index eb3e15b..44dae1f 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -2,7 +2,33 @@ from enum import Enum +import sqlalchemy + +from spoolman.database import models + class SortOrder(Enum): ASC = 1 DESC = 2 + + +def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column: + """Parse a nested field string into a sqlalchemy field object.""" + fields = field.split(".") + if not hasattr(base_obj, fields[0]): + raise ValueError(f"Invalid field name '{field}'") + + if fields[0] == "filament" and len(fields) == 1: + raise ValueError("No field specified for filament") + if fields[0] == "filament": + return parse_nested_field(models.Filament, ".".join(fields[1:])) + + if fields[0] == "vendor" and len(fields) == 1: + raise ValueError("No field specified for vendor") + if fields[0] == "vendor": + return parse_nested_field(models.Vendor, ".".join(fields[1:])) + + if len(fields) > 1: + raise ValueError(f"Field '{fields[0]}' does not have any nested fields") + + return getattr(base_obj, fields[0]) diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index 7f9459c..8ecb2d3 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from spoolman.database import models +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemNotFoundError @@ -37,12 +38,26 @@ async def find( *, db: AsyncSession, name: Optional[str] = None, + sort_by: Optional[dict[str, SortOrder]] = None, + limit: Optional[int] = None, + offset: int = 0, ) -> list[models.Vendor]: """Find a list of vendor objects by search criteria.""" stmt = select(models.Vendor) if name is not None: stmt = stmt.where(models.Vendor.name.ilike(f"%{name}%")) + if sort_by is not None: + for fieldstr, order in sort_by.items(): + field = getattr(models.Vendor, fieldstr) + if order == SortOrder.ASC: + stmt = stmt.order_by(field.asc()) + elif order == SortOrder.DESC: + stmt = stmt.order_by(field.desc()) + + if limit is not None: + stmt = stmt.offset(offset).limit(limit) + rows = await db.execute(stmt) return list(rows.scalars().all()) From 557ea82171dfb43bb5ed12cfc657cca76023cd8e Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 26 Aug 2023 11:08:57 +0200 Subject: [PATCH 04/31] Added endpoints for getting list of materials, etc --- spoolman/api/v1/other.py | 125 ++++++++++++++++++++++++++++++++++ spoolman/api/v1/router.py | 4 +- spoolman/database/filament.py | 24 +++++++ spoolman/database/spool.py | 20 ++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 spoolman/api/v1/other.py diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py new file mode 100644 index 0000000..b0bdb84 --- /dev/null +++ b/spoolman/api/v1/other.py @@ -0,0 +1,125 @@ +"""Filament related endpoints.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database import filament, spool +from spoolman.database.database import get_db_session + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="", + tags=["other"], +) + +# ruff: noqa: D103, B008 + + +@router.get( + "/material", + name="Find materials", + description="Get a list of all filament materials.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all filament materials.", + "content": { + "application/json": { + "example": [ + "PLA", + "ABS", + "PETG", + ], + }, + }, + }, + }, +) +async def find_materials( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_materials(db=db) + + +@router.get( + "/article-number", + name="Find article numbers", + description="Get a list of all article numbers.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all article numbers.", + "content": { + "application/json": { + "example": [ + "123456", + "987654", + ], + }, + }, + }, + }, +) +async def find_article_numbers( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await filament.find_article_numbers(db=db) + + +@router.get( + "/lot-number", + name="Find lot numbers", + description="Get a list of all lot numbers.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all lot numbers.", + "content": { + "application/json": { + "example": [ + "123456", + "987654", + ], + }, + }, + }, + }, +) +async def find_lot_numbers( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await spool.find_lot_numbers(db=db) + + +@router.get( + "/locations", + name="Find locations", + description="Get a list of all spool locations.", + response_model_exclude_none=True, + responses={ + 200: { + "description": "A list of all spool locations.", + "content": { + "application/json": { + "example": [ + "Printer 1", + "Printer 2", + "Storage Shelf A", + ], + }, + }, + }, + }, +) +async def find_locations( + *, + db: Annotated[AsyncSession, Depends(get_db_session)], +) -> list[str]: + return await spool.find_locations(db=db) diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index cbeeb8c..00e8f05 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -13,14 +13,13 @@ from spoolman import env from spoolman.database.database import backup_global_db from spoolman.exceptions import ItemNotFoundError -from . import filament, models, spool, vendor +from . import filament, models, other, spool, vendor logger = logging.getLogger(__name__) app = FastAPI( title="Spoolman REST API v1", version="1.0.0", - root_path_in_servers=False, ) @@ -76,3 +75,4 @@ async def backup(): # noqa: ANN201 app.include_router(filament.router) app.include_router(spool.router) app.include_router(vendor.router) +app.include_router(other.router) diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 0376b28..0f6b1e6 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -1,5 +1,6 @@ """Helper functions for interacting with filament database objects.""" +import logging from typing import Optional from sqlalchemy import select @@ -143,3 +144,26 @@ async def delete(db: AsyncSession, filament_id: int) -> None: except IntegrityError as exc: await db.rollback() raise ItemDeleteError("Failed to delete filament.") from exc + + +logger = logging.getLogger(__name__) + + +async def find_materials( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of filament materials by searching for distinct values in the filament table.""" + stmt = select(models.Filament.material).distinct() + rows = await db.execute(stmt) + return [row[0] for row in rows.all() if row[0] is not None] + + +async def find_article_numbers( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of filament article numbers by searching for distinct values in the filament table.""" + stmt = select(models.Filament.article_number).distinct() + rows = await db.execute(stmt) + return [row[0] for row in rows.all() if row[0] is not None] diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index ab14a04..2d5fbd2 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -257,3 +257,23 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S await db.commit() return spool + + +async def find_locations( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of spool locations by searching for distinct values in the spool table.""" + stmt = sqlalchemy.select(models.Spool.location).distinct() + rows = await db.execute(stmt) + return [row[0] for row in rows.all() if row[0] is not None] + + +async def find_lot_numbers( + *, + db: AsyncSession, +) -> list[str]: + """Find a list of spool lot numbers by searching for distinct values in the spool table.""" + stmt = sqlalchemy.select(models.Spool.lot_nr).distinct() + rows = await db.execute(stmt) + return [row[0] for row in rows.all() if row[0] is not None] From e2d937aaf125de3063299758ce02b5a2361e78bb Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 26 Aug 2023 11:58:06 +0200 Subject: [PATCH 05/31] Added x-total-count response header --- spoolman/api/v1/filament.py | 16 +++++++++++++--- spoolman/api/v1/spool.py | 16 +++++++++++++--- spoolman/api/v1/vendor.py | 16 +++++++++++++--- spoolman/database/filament.py | 23 +++++++++++++++++------ spoolman/database/spool.py | 23 +++++++++++++++++------ spoolman/database/utils.py | 3 ++- spoolman/database/vendor.py | 26 +++++++++++++++++++------- 7 files changed, 94 insertions(+), 29 deletions(-) diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 46b4310..50a70c9 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -4,6 +4,7 @@ import logging from typing import Annotated, Optional from fastapi import APIRouter, Depends, Query +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, validator @@ -109,6 +110,7 @@ class FilamentUpdateParameters(FilamentParameters): name="Find filaments", description="Get a list of filaments that matches the search query.", response_model_exclude_none=True, + response_model=list[Filament], ) async def find( *, @@ -156,14 +158,14 @@ async def find( title="Offset", description="Offset in the full result set if a limit is set.", ), -) -> list[Filament]: +) -> JSONResponse: sort_by: dict[str, SortOrder] = {} if sort is not None: for sort_item in sort.split(","): field, direction = sort_item.split(":") sort_by[field] = SortOrder[direction.upper()] - db_items = await filament.find( + db_items, total_count = await filament.find( db=db, vendor_name=vendor_name, vendor_id=vendor_id, @@ -174,7 +176,15 @@ async def find( limit=limit, offset=offset, ) - return [Filament.from_db(db_item) for db_item in db_items] + + # Set x-total-count header for pagination + return JSONResponse( + content=jsonable_encoder( + (Filament.from_db(db_item) for db_item in db_items), + exclude_none=True, + ), + headers={"x-total-count": str(total_count)}, + ) @router.get( diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 703c955..3eefc25 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import Annotated, Optional from fastapi import APIRouter, Depends, Query +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import BaseModel, Field @@ -67,6 +68,7 @@ class SpoolUseParameters(BaseModel): name="Find spool", description="Get a list of spools that matches the search query.", response_model_exclude_none=True, + response_model=list[Spool], ) async def find( *, @@ -129,14 +131,14 @@ async def find( title="Offset", description="Offset in the full result set if a limit is set.", ), -) -> list[Spool]: +) -> JSONResponse: sort_by: dict[str, SortOrder] = {} if sort is not None: for sort_item in sort.split(","): field, direction = sort_item.split(":") sort_by[field] = SortOrder[direction.upper()] - db_items = await spool.find( + db_items, total_count = await spool.find( db=db, filament_name=filament_name, filament_id=filament_id, @@ -150,7 +152,15 @@ async def find( limit=limit, offset=offset, ) - return [Spool.from_db(db_item) for db_item in db_items] + + # Set x-total-count header for pagination + return JSONResponse( + content=jsonable_encoder( + (Spool.from_db(db_item) for db_item in db_items), + exclude_none=True, + ), + headers={"x-total-count": str(total_count)}, + ) @router.get( diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index 8f9f348..6722c00 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -3,7 +3,9 @@ from typing import Annotated, Optional from fastapi import APIRouter, Depends, Query +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from pydantic.error_wrappers import ErrorWrapper from sqlalchemy.ext.asyncio import AsyncSession @@ -44,6 +46,7 @@ class VendorUpdateParameters(VendorParameters): name="Find vendor", description="Get a list of vendors that matches the search query.", response_model_exclude_none=True, + response_model=list[Vendor], ) async def find( db: Annotated[AsyncSession, Depends(get_db_session)], @@ -70,21 +73,28 @@ async def find( title="Offset", description="Offset in the full result set if a limit is set.", ), -) -> list[Vendor]: +) -> JSONResponse: sort_by: dict[str, SortOrder] = {} if sort is not None: for sort_item in sort.split(","): field, direction = sort_item.split(":") sort_by[field] = SortOrder[direction.upper()] - db_items = await vendor.find( + db_items, total_count = await vendor.find( db=db, name=name, sort_by=sort_by, limit=limit, offset=offset, ) - return [Vendor.from_db(db_item) for db_item in db_items] + # Set x-total-count header for pagination + return JSONResponse( + content=jsonable_encoder( + (Vendor.from_db(db_item) for db_item in db_items), + exclude_none=True, + ), + headers={"x-total-count": str(total_count)}, + ) @router.get( diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 0f6b1e6..04c7cbb 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -3,7 +3,7 @@ import logging from typing import Optional -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload @@ -78,11 +78,13 @@ async def find( # noqa: C901 sort_by: Optional[dict[str, SortOrder]] = None, limit: Optional[int] = None, offset: int = 0, -) -> list[models.Filament]: +) -> tuple[list[models.Filament], int]: """Find a list of filament objects by search criteria. Sort by a field by passing a dict with the field name as key and the sort order as value. The field name can contain nested fields, e.g. vendor.name. + + Returns a tuple containing the list of items and the total count of matching items. """ stmt = ( select(models.Filament) @@ -100,6 +102,14 @@ async def find( # noqa: C901 if article_number is not None: stmt = stmt.where(models.Filament.article_number.ilike(f"%{article_number}%")) + total_count = None + + if limit is not None: + total_count_stmt = stmt.with_only_columns(func.count()) + total_count = (await db.execute(total_count_stmt)).scalar() + + stmt = stmt.offset(offset).limit(limit) + if sort_by is not None: for fieldstr, order in sort_by.items(): field = parse_nested_field(models.Filament, fieldstr) @@ -108,11 +118,12 @@ async def find( # noqa: C901 elif order == SortOrder.DESC: stmt = stmt.order_by(field.desc()) - if limit is not None: - stmt = stmt.offset(offset).limit(limit) - rows = await db.execute(stmt) - return list(rows.scalars().all()) + result = list(rows.scalars().all()) + if total_count is None: + total_count = len(result) + + return result, total_count async def update( diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 2d5fbd2..467b8fe 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import Optional import sqlalchemy -from sqlalchemy import case +from sqlalchemy import case, func from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload @@ -90,11 +90,13 @@ async def find( # noqa: C901, PLR0912 sort_by: Optional[dict[str, SortOrder]] = None, limit: Optional[int] = None, offset: int = 0, -) -> list[models.Spool]: +) -> tuple[list[models.Spool], int]: """Find a list of spool objects by search criteria. Sort by a field by passing a dict with the field name as key and the sort order as value. The field name can contain nested fields, e.g. filament.name. + + Returns a tuple containing the list of items and the total count of matching items. """ stmt = ( sqlalchemy.select(models.Spool) @@ -125,6 +127,14 @@ async def find( # noqa: C901, PLR0912 ), ) + total_count = None + + if limit is not None: + total_count_stmt = stmt.with_only_columns(func.count()) + total_count = (await db.execute(total_count_stmt)).scalar() + + stmt = stmt.offset(offset).limit(limit) + if sort_by is not None: for fieldstr, order in sort_by.items(): field = parse_nested_field(models.Spool, fieldstr) @@ -133,11 +143,12 @@ async def find( # noqa: C901, PLR0912 elif order == SortOrder.DESC: stmt = stmt.order_by(field.desc()) - if limit is not None: - stmt = stmt.offset(offset).limit(limit) - rows = await db.execute(stmt) - return list(rows.scalars().all()) + result = list(rows.scalars().all()) + if total_count is None: + total_count = len(result) + + return result, total_count async def update( diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 44dae1f..5f95ad2 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -1,6 +1,7 @@ """Utility functions for the database module.""" from enum import Enum +from typing import Any import sqlalchemy @@ -12,7 +13,7 @@ class SortOrder(Enum): DESC = 2 -def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column: +def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column[Any]: """Parse a nested field string into a sqlalchemy field object.""" fields = field.split(".") if not hasattr(base_obj, fields[0]): diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index 8ecb2d3..a9e66a9 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -2,7 +2,7 @@ from typing import Optional -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from spoolman.database import models @@ -41,12 +41,23 @@ async def find( sort_by: Optional[dict[str, SortOrder]] = None, limit: Optional[int] = None, offset: int = 0, -) -> list[models.Vendor]: - """Find a list of vendor objects by search criteria.""" +) -> tuple[list[models.Vendor], int]: + """Find a list of vendor objects by search criteria. + + Returns a tuple containing the list of items and the total count of matching items. + """ stmt = select(models.Vendor) if name is not None: stmt = stmt.where(models.Vendor.name.ilike(f"%{name}%")) + total_count = None + + if limit is not None: + total_count_stmt = stmt.with_only_columns(func.count()) + total_count = (await db.execute(total_count_stmt)).scalar() + + stmt = stmt.offset(offset).limit(limit) + if sort_by is not None: for fieldstr, order in sort_by.items(): field = getattr(models.Vendor, fieldstr) @@ -55,11 +66,12 @@ async def find( elif order == SortOrder.DESC: stmt = stmt.order_by(field.desc()) - if limit is not None: - stmt = stmt.offset(offset).limit(limit) - rows = await db.execute(stmt) - return list(rows.scalars().all()) + result = list(rows.scalars().all()) + if total_count is None: + total_count = len(result) + + return result, total_count async def update( From 1a031903de9ed7c603e7d639fc411152187e55af Mon Sep 17 00:00:00 2001 From: Donkie Date: Tue, 29 Aug 2023 21:45:26 +0200 Subject: [PATCH 06/31] Start of serverside client tables --- client/src/components/dataProvider.ts | 29 +++++++++++++++++++++-- client/src/pages/spools/list.tsx | 33 +++++++-------------------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/client/src/components/dataProvider.ts b/client/src/components/dataProvider.ts index c88ed91..4e93b26 100644 --- a/client/src/components/dataProvider.ts +++ b/client/src/components/dataProvider.ts @@ -13,12 +13,36 @@ const dataProvider = ( Required, "createMany" | "updateMany" | "deleteMany" > => ({ - getList: async ({ resource, meta }) => { + getList: async ({ resource, meta, pagination, sorters, filters }) => { const url = `${apiUrl}/${resource}`; const { headers: headersFromMeta, method, queryParams } = meta ?? {}; const requestMethod = (method as MethodTypes) ?? "get"; + if (pagination && pagination.mode == "server") { + const pageSize = pagination.pageSize ?? 10; + const offset = ((pagination.current ?? 1) - 1) * pageSize; + queryParams["limit"] = pageSize; + queryParams["offset"] = offset; + } + + if (sorters && sorters.length > 0) { + queryParams["sort"] = sorters.map((sort) => { + const field = sort.field.replace("_", ".") + return `${field}:${sort.order}`; + }).join(",") + } + + if (filters && filters.length > 0) { + filters.forEach(filter => { + if (!("field" in filter)) { + throw Error("Filter must be a LogicalFilter.") + } + const field = filter.field.replace(".", "_") + queryParams[field] = filter.value + }); + } + const { data } = await httpClient[requestMethod]( `${url}`, { @@ -29,7 +53,8 @@ const dataProvider = ( return { data, - total: data.length, + total: 100, + //TODO: total: data.length, }; }, diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index e7bae19..5adb287 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -26,7 +26,7 @@ const { confirm } = Modal; interface ISpoolCollapsed extends ISpool { filament_name: string; - material?: string; + filament_material?: string; } export const SpoolList: React.FC = () => { @@ -49,16 +49,16 @@ export const SpoolList: React.FC = () => { }, syncWithLocation: false, pagination: { - mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work. + mode: "server", current: initialState.pagination.current, pageSize: initialState.pagination.pageSize, }, sorters: { - mode: "off", // Disable server-side sorting + mode: "server", initial: initialState.sorters, }, filters: { - mode: "off", // Disable server-side filtering + mode: "server", initial: initialState.filters, }, }); @@ -67,7 +67,7 @@ export const SpoolList: React.FC = () => { const allColumns: (keyof ISpoolCollapsed & string)[] = [ "id", "filament_name", - "material", + "filament_material", "used_weight", "remaining_weight", "used_length", @@ -84,10 +84,6 @@ export const SpoolList: React.FC = () => { ); const [showColumns, setShowColumns] = React.useState(initialState.showColumns ?? defaultColumns); - // Type the sorters and filters - const typedSorters = typeSorters(sorters); - const typedFilters = typeFilters(filters); - // Store state in local storage const tableState: TableState = { sorters, @@ -110,18 +106,14 @@ export const SpoolList: React.FC = () => { return { ...element, filament_name, - material: element.filament.material, + filament_material: element.filament.material, }; }), [tableProps.dataSource] ); // Filter and sort the dataSource - const filteredDataSource = React.useMemo(() => { - const filtered = dataSource.filter(genericFilterer(typedFilters)); - filtered.sort(genericSorter(typedSorters)); - return filtered; - }, [dataSource, typedFilters, typedSorters]); + const filteredDataSource = dataSource; // Function for opening an ant design modal that asks for confirmation for archiving a spool const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => { @@ -205,15 +197,6 @@ export const SpoolList: React.FC = () => { { - setCurrent(page); - setPageSize(pageSize); - }, - }} rowKey="id" // Make archived rows greyed out onRow={(record) => { @@ -243,7 +226,7 @@ export const SpoolList: React.FC = () => { tableState, })} {FilteredColumn({ - id: "material", + id: "filament_material", i18ncat: "spool", dataSource, tableState, From 4b33ba0b1c3eaf0bcc0f6af72c35b763cf3ea538 Mon Sep 17 00:00:00 2001 From: Donkie Date: Tue, 29 Aug 2023 19:08:53 +0200 Subject: [PATCH 07/31] Renamed locations endpoint to location --- spoolman/api/v1/other.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spoolman/api/v1/other.py b/spoolman/api/v1/other.py index b0bdb84..345ea60 100644 --- a/spoolman/api/v1/other.py +++ b/spoolman/api/v1/other.py @@ -99,7 +99,7 @@ async def find_lot_numbers( @router.get( - "/locations", + "/location", name="Find locations", description="Get a list of all spool locations.", response_model_exclude_none=True, From 5bae67725ad8f692c53daa23b546c40ebfa9ec5c Mon Sep 17 00:00:00 2001 From: Donkie Date: Wed, 30 Aug 2023 19:43:55 +0200 Subject: [PATCH 08/31] Added tanstack query devtools --- client/package-lock.json | 76 ++++++++++++++++++++++++++++++++++++++++ client/package.json | 1 + client/src/App.tsx | 2 ++ 3 files changed, 79 insertions(+) diff --git a/client/package-lock.json b/client/package-lock.json index 6f76470..2fb556b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,6 +16,7 @@ "@refinedev/react-router-v6": "^4.5.0", "@refinedev/simple-rest": "^4.5.0", "@tanstack/react-query": "^4.32.6", + "@tanstack/react-query-devtools": "^4.33.0", "@types/loadable__component": "^5.13.4", "@yudiel/react-qr-scanner": "^1.1.10", "antd": "^5.8.3", @@ -2408,6 +2409,21 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.8.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz", + "integrity": "sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==", + "dependencies": { + "remove-accents": "0.4.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kentcdodds" + } + }, "node_modules/@tanstack/query-core": { "version": "4.32.6", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.32.6.tgz", @@ -2443,6 +2459,25 @@ } } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-4.33.0.tgz", + "integrity": "sha512-6gegkuDmOoiY5e6ZKj1id48vlCXchjfE/6tIpYO8dFlVMQ7t1bYna/Ce6qQJ69+kfEHbYiTTn2lj+FDjIBH7Hg==", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.7.0", + "superjson": "^1.10.0", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^4.33.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@types/estree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", @@ -3920,6 +3955,20 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -6107,6 +6156,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-what": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", + "integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -8855,6 +8915,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" + }, "node_modules/repeat-element": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", @@ -9829,6 +9894,17 @@ "react-dom": ">=16.9.0" } }, + "node_modules/superjson": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.1.tgz", + "integrity": "sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/client/package.json b/client/package.json index 2f11f6b..b13dc18 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "@refinedev/react-router-v6": "^4.5.0", "@refinedev/simple-rest": "^4.5.0", "@tanstack/react-query": "^4.32.6", + "@tanstack/react-query-devtools": "^4.33.0", "@types/loadable__component": "^5.13.4", "@yudiel/react-qr-scanner": "^1.1.10", "antd": "^5.8.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index dccb6c5..a0fdea8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { Refine } from "@refinedev/core"; import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ErrorComponent, ThemedLayoutV2, ThemedSiderV2, ThemedTitleV2 } from "@refinedev/antd"; import "@refinedev/antd/dist/reset.css"; @@ -185,6 +186,7 @@ function App() { + From 3bba0b1dcec993edd431aedd08c9495baef8de74 Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 4 Sep 2023 20:48:24 +0200 Subject: [PATCH 09/31] Added pdm cmd to run spoolman --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index dc5ab2f..ef63865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,9 @@ call = "spoolman.docs:generate_docs" [tool.pdm.scripts.bump] call = "spoolman.bump:bump" +[tool.pdm.scripts.app] +cmd = "uvicorn spoolman.main:app" + [tool.ruff] select = ["ALL"] ignore = [ From 826fde6967ce65f263bea6eac2592cbffe7fcf78 Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 4 Sep 2023 21:11:10 +0200 Subject: [PATCH 10/31] API find nested filters now specified using period Previously you used underscore, like vendor_name. This made fields like filament_article_number have ambiguous underscores. --- spoolman/api/v1/filament.py | 20 ++- spoolman/api/v1/spool.py | 52 +++++++- tests_integration/tests/filament/test_find.py | 124 +++++++++++++----- tests_integration/tests/spool/test_find.py | 29 ++-- 4 files changed, 169 insertions(+), 56 deletions(-) diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 50a70c9..be22675 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -115,12 +115,28 @@ class FilamentUpdateParameters(FilamentParameters): async def find( *, db: Annotated[AsyncSession, Depends(get_db_session)], + vendor_name_old: Optional[str] = Query( + alias="vendor_name", + default=None, + title="Vendor Name", + description="Partial case-insensitive search term for the filament vendor name.", + deprecated=True, + ), + vendor_id_old: Optional[int] = Query( + alias="vendor_id", + default=None, + title="Vendor ID", + description="Match an exact vendor ID.", + deprecated=True, + ), vendor_name: Optional[str] = Query( + alias="vendor.name", default=None, title="Vendor Name", description="Partial case-insensitive search term for the filament vendor name.", ), vendor_id: Optional[int] = Query( + alias="vendor.id", default=None, title="Vendor ID", description="Match an exact vendor ID.", @@ -167,8 +183,8 @@ async def find( db_items, total_count = await filament.find( db=db, - vendor_name=vendor_name, - vendor_id=vendor_id, + vendor_name=vendor_name if vendor_name is not None else vendor_name_old, + vendor_id=vendor_id if vendor_id is not None else vendor_id_old, name=name, material=material, article_number=article_number, diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 3eefc25..dc7762a 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -73,27 +73,67 @@ class SpoolUseParameters(BaseModel): async def find( *, db: Annotated[AsyncSession, Depends(get_db_session)], + filament_name_old: Optional[str] = Query( + alias="filament_name", + default=None, + title="Filament Name", + description="Partial case-insensitive search term for the filament name.", + deprecated=True, + ), + filament_id_old: Optional[int] = Query( + alias="filament_id", + default=None, + title="Filament ID", + description="Match an exact filament ID.", + deprecated=True, + ), + filament_material_old: Optional[str] = Query( + alias="filament_material", + default=None, + title="Filament Material", + description="Partial case-insensitive search term for the filament material.", + deprecated=True, + ), + vendor_name_old: Optional[str] = Query( + alias="vendor_name", + default=None, + title="Vendor Name", + description="Partial case-insensitive search term for the filament vendor name.", + deprecated=True, + ), + vendor_id_old: Optional[int] = Query( + alias="vendor_id", + default=None, + title="Vendor ID", + description="Match an exact vendor ID.", + deprecated=True, + ), filament_name: Optional[str] = Query( + alias="filament.name", default=None, title="Filament Name", description="Partial case-insensitive search term for the filament name.", ), filament_id: Optional[int] = Query( + alias="filament.id", default=None, title="Filament ID", description="Match an exact filament ID.", ), filament_material: Optional[str] = Query( + alias="filament.material", default=None, title="Filament Material", description="Partial case-insensitive search term for the filament material.", ), vendor_name: Optional[str] = Query( + alias="vendor.name", default=None, title="Vendor Name", description="Partial case-insensitive search term for the filament vendor name.", ), vendor_id: Optional[int] = Query( + alias="vendor.id", default=None, title="Vendor ID", description="Match an exact vendor ID.", @@ -119,7 +159,7 @@ async def find( description=( 'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.' ), - example="filament.name:asc,location:desc", + example="filament.name:asc,vendor.id:asc,location:desc", ), limit: Optional[int] = Query( default=None, @@ -140,11 +180,11 @@ async def find( db_items, total_count = await spool.find( db=db, - filament_name=filament_name, - filament_id=filament_id, - filament_material=filament_material, - vendor_name=vendor_name, - vendor_id=vendor_id, + filament_name=filament_name if filament_name is not None else filament_name_old, + filament_id=filament_id if filament_id is not None else filament_id_old, + filament_material=filament_material if filament_material is not None else filament_material_old, + vendor_name=vendor_name if vendor_name is not None else vendor_name_old, + vendor_id=vendor_id if vendor_id is not None else vendor_id_old, location=location, lot_nr=lot_nr, allow_archived=allow_archived, diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index a833add..91ad44d 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -1,15 +1,24 @@ """Integration tests for the Filament API endpoint.""" +from collections.abc import Iterable +from dataclasses import dataclass from typing import Any import httpx +import pytest URL = "http://spoolman:8000" -def test_find_filaments(random_vendor: dict[str, Any]): - """Test finding filaments from the database.""" - # Setup +@dataclass +class Fixture: + filaments: list[dict[str, Any]] + filaments_by_id: dict[str, dict[str, Any]] + + +@pytest.fixture() +def filaments(random_vendor: dict[str, Any]) -> Iterable[Fixture]: + """Add some filaments to the database.""" result = httpx.post( f"{URL}/api/v1/filament", json={ @@ -46,24 +55,57 @@ def test_find_filaments(random_vendor: dict[str, Any]): result.raise_for_status() filament_2 = result.json() + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "name": "Filament Y", + "material": "PETG", + "price": 200, + "density": 1.25, + "diameter": 1.75, + "weight": 1000, + "spool_weight": 250, + "article_number": "abc", + "comment": "abcdefghåäö", + }, + ) + result.raise_for_status() + filament_3 = result.json() + added_filaments_by_id = { filament_1["id"]: filament_1, filament_2["id"]: filament_2, + filament_3["id"]: filament_3, } - # Execute - find all filaments + yield Fixture( + filaments=[filament_1, filament_2, filament_3], + filaments_by_id=added_filaments_by_id, + ) + + httpx.delete(f"{URL}/api/v1/filament/{filament_1['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_2['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_3['id']}").raise_for_status() + + +def test_find_all_filaments(filaments: Fixture): + """Test finding filaments from the database.""" + # Execute result = httpx.get( f"{URL}/api/v1/filament", ) result.raise_for_status() # Verify - filaments = result.json() - assert len(filaments) == 2 - for filament in filaments: - assert filament == added_filaments_by_id[filament["id"]] + filaments_result = result.json() + assert len(filaments_result) == 3 + for filament in filaments_result: + assert filament == filaments.filaments_by_id[filament["id"]] - # Execute - find filaments by name + +def test_find_filaments_by_name(filaments: Fixture): + """Test finding filaments from the database.""" + # Execute result = httpx.get( f"{URL}/api/v1/filament", params={"name": "Filament X"}, @@ -71,11 +113,14 @@ def test_find_filaments(random_vendor: dict[str, Any]): result.raise_for_status() # Verify - filaments = result.json() - assert len(filaments) == 1 - assert filaments[0] == added_filaments_by_id[filament_1["id"]] + filaments_result = result.json() + assert len(filaments_result) == 1 + assert filaments_result[0] == filaments.filaments[0] - # Execute - find filaments by material + +def test_find_filaments_by_material(filaments: Fixture): + """Test finding filaments from the database.""" + # Execute result = httpx.get( f"{URL}/api/v1/filament", params={"material": "abs"}, @@ -83,37 +128,48 @@ def test_find_filaments(random_vendor: dict[str, Any]): result.raise_for_status() # Verify - filaments = result.json() - assert len(filaments) == 1 - assert filaments[0] == added_filaments_by_id[filament_2["id"]] + filaments_result = result.json() + assert len(filaments_result) == 1 + assert filaments_result[0] == filaments.filaments[1] - # Execute - find filaments by vendor id + +@pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) +def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str): + """Test finding filaments from the database.""" + # Execute result = httpx.get( f"{URL}/api/v1/filament", - params={"vendor_id": random_vendor["id"]}, + params={field_name: filaments.filaments[0]["vendor"]["id"]}, ) result.raise_for_status() # Verify - filaments = result.json() - assert len(filaments) == 2 - for filament in filaments: - assert filament == added_filaments_by_id[filament["id"]] + filaments_result = result.json() + assert len(filaments_result) == 2 + for filament in filaments_result: + assert filament == filaments.filaments_by_id[filament["id"]] - # Execute - find filaments by vendor name + +@pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) +def test_find_filaments_by_vendor_name(filaments: Fixture, field_name: str): + """Test finding filaments from the database.""" + # Execute result = httpx.get( f"{URL}/api/v1/filament", - params={"vendor_name": random_vendor["name"]}, + params={field_name: filaments.filaments[0]["vendor"]["name"]}, ) result.raise_for_status() # Verify - filaments = result.json() - assert len(filaments) == 2 - for filament in filaments: - assert filament == added_filaments_by_id[filament["id"]] + filaments_result = result.json() + assert len(filaments_result) == 2 + for filament in filaments_result: + assert filament == filaments.filaments_by_id[filament["id"]] - # Execute - find filaments by article number + +def test_find_filaments_by_article_number(filaments: Fixture): + """Test finding filaments from the database.""" + # Execute result = httpx.get( f"{URL}/api/v1/filament", params={"article_number": "321"}, @@ -121,10 +177,6 @@ def test_find_filaments(random_vendor: dict[str, Any]): result.raise_for_status() # Verify - filaments = result.json() - assert len(filaments) == 1 - assert filaments[0] == added_filaments_by_id[filament_2["id"]] - - # Clean up - httpx.delete(f"{URL}/api/v1/filament/{filament_1['id']}").raise_for_status() - httpx.delete(f"{URL}/api/v1/filament/{filament_2['id']}").raise_for_status() + filaments_result = result.json() + assert len(filaments_result) == 1 + assert filaments_result[0] == filaments.filaments[1] diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 2126dcd..4559769 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -1,6 +1,6 @@ """Integration tests for the Spool API endpoint.""" -from collections.abc import Iterator +from collections.abc import Iterable from dataclasses import dataclass from typing import Any @@ -18,7 +18,7 @@ class Fixture: @pytest.fixture() -def spools(random_filament: dict[str, Any]) -> Iterator[Fixture]: +def spools(random_filament: dict[str, Any]) -> Iterable[Fixture]: """Add some spools to the database.""" result = httpx.post( f"{URL}/api/v1/spool", @@ -99,12 +99,13 @@ def test_find_all_spools_including_archived(spools: Fixture): assert spool == spools.spools_by_id[spool["id"]] -def test_find_spools_by_filament_name(spools: Fixture): +@pytest.mark.parametrize("field_name", ["filament_name", "filament.name"]) +def test_find_spools_by_filament_name(spools: Fixture, field_name: str): """Test finding spools by filament name.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"filament_name": spools.filament["name"]}, + params={field_name: spools.filament["name"]}, ) result.raise_for_status() @@ -115,12 +116,13 @@ def test_find_spools_by_filament_name(spools: Fixture): assert spool == spools.spools_by_id[spool["id"]] -def test_find_spools_by_filament_id(spools: Fixture): +@pytest.mark.parametrize("field_name", ["filament_id", "filament.id"]) +def test_find_spools_by_filament_id(spools: Fixture, field_name: str): """Test finding spools by filament id.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"filament_id": spools.filament["id"]}, + params={field_name: spools.filament["id"]}, ) result.raise_for_status() @@ -131,12 +133,13 @@ def test_find_spools_by_filament_id(spools: Fixture): assert spool == spools.spools_by_id[spool["id"]] -def test_find_spools_by_filament_material(spools: Fixture): +@pytest.mark.parametrize("field_name", ["filament_material", "filament.material"]) +def test_find_spools_by_filament_material(spools: Fixture, field_name: str): """Test finding spools by filament material.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"filament_material": spools.filament["material"]}, + params={field_name: spools.filament["material"]}, ) result.raise_for_status() @@ -147,12 +150,13 @@ def test_find_spools_by_filament_material(spools: Fixture): assert spool == spools.spools_by_id[spool["id"]] -def test_find_spools_by_filament_vendor_name(spools: Fixture): +@pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) +def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str): """Test finding spools by filament vendor name.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"vendor_name": spools.filament["vendor"]["name"]}, + params={field_name: spools.filament["vendor"]["name"]}, ) result.raise_for_status() @@ -163,12 +167,13 @@ def test_find_spools_by_filament_vendor_name(spools: Fixture): assert spool == spools.spools_by_id[spool["id"]] -def test_find_spools_by_filament_vendor_id(spools: Fixture): +@pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) +def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str): """Test finding spools by filament vendor id.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"vendor_id": spools.filament["vendor"]["id"]}, + params={field_name: spools.filament["vendor"]["id"]}, ) result.raise_for_status() From f62f9d899b6f654bc59fcab397aad65e20a03923 Mon Sep 17 00:00:00 2001 From: Donkie Date: Thu, 7 Sep 2023 21:24:40 +0200 Subject: [PATCH 11/31] Client: Now uses server side sorting Still some stuff left to do #98 --- client/src/components/column.tsx | 55 ++++++-- client/src/components/dataProvider.ts | 54 +++++++- client/src/components/otherModels.tsx | 152 +++++++++++++++++++++ client/src/components/spoolSelectModal.tsx | 40 ++---- client/src/pages/filaments/list.tsx | 101 +++++++------- client/src/pages/spools/list.tsx | 95 +++++++------ client/src/pages/vendors/list.tsx | 69 ++++------ client/src/utils/filtering.ts | 88 ------------ client/src/utils/sorting.ts | 56 -------- 9 files changed, 387 insertions(+), 323 deletions(-) create mode 100644 client/src/components/otherModels.tsx diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index e9252a6..04163ed 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -1,7 +1,7 @@ import { Col, Row, Table } from "antd"; import { ColumnProps as AntdColumnProps } from "antd/es/table"; import { ColumnFilterItem } from "antd/es/table/interface"; -import { getFiltersForField, typeFilters, useListFiltersForField } from "../utils/filtering"; +import { getFiltersForField, typeFilters } from "../utils/filtering"; import { TableState } from "../utils/saveload"; import { getSortOrderForField, typeSorters } from "../utils/sorting"; import { NumberFieldUnit } from "./numberField"; @@ -12,12 +12,14 @@ import Icon from "@ant-design/icons"; import { ReactComponent as SpoolIcon } from "../icon_spool.svg"; import { useTranslate } from "@refinedev/core"; import { enrichText } from "../utils/parsing"; +import { UseQueryResult } from "@tanstack/react-query"; dayjs.extend(utc); interface BaseColumnProps { id: keyof Obj & string; - i18ncat: string; + i18ncat?: string; + i18nkey?: string; dataSource: Obj[]; tableState: TableState; } @@ -25,6 +27,7 @@ interface BaseColumnProps { interface FilteredColumnProps { filters?: ColumnFilterItem[]; filteredValue?: string[]; + allowMultipleFilters?: boolean; } interface CustomColumnProps { @@ -45,9 +48,10 @@ function Column(props: BaseColumnProps & FilteredColumnProps & CustomC const typedSorters = typeSorters(props.tableState.sorters); const columnProps: AntdColumnProps = { dataIndex: props.id, - title: t(`${props.i18ncat}.fields.${props.id}`), + title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), sorter: true, sortOrder: getSortOrderForField(typedSorters, props.id), + filterMultiple: props.allowMultipleFilters ?? true, }; if (props.filters && props.filteredValue) { columnProps.filters = props.filters; @@ -75,10 +79,29 @@ export function RichColumn(props: BaseColumnProps) { }); } -export function FilteredColumn(props: BaseColumnProps) { - const typedFilters = typeFilters(props.tableState.filters); +interface FilteredQueryColumnProps extends BaseColumnProps { + filterValueQuery: UseQueryResult; + allowMultipleFilters?: boolean; +} - const filters = useListFiltersForField(props.dataSource, props.id); +export function FilteredQueryColumn(props: FilteredQueryColumnProps) { + const query = props.filterValueQuery; + + let filters: ColumnFilterItem[] = []; + if (query.data) { + filters = query.data.map((item) => { + return { + text: item, + value: item, + }; + }); + } + filters.push({ + text: "", + value: "", + }); + + const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, props.id); return Column({ ...props, filters, filteredValue }); @@ -127,14 +150,28 @@ export function DateColumn(props: BaseColumnProps) { }); } -interface SpoolIconColumnProps extends BaseColumnProps { +interface SpoolIconColumnProps extends FilteredQueryColumnProps { color: (record: Obj) => string | undefined; } export function SpoolIconColumn(props: SpoolIconColumnProps) { - const typedFilters = typeFilters(props.tableState.filters); + const query = props.filterValueQuery; - const filters = useListFiltersForField(props.dataSource, props.id); + let filters: ColumnFilterItem[] = []; + if (query.data) { + filters = query.data.map((item) => { + return { + text: item, + value: item, + }; + }); + } + filters.push({ + text: "", + value: "", + }); + + const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, props.id); return Column({ diff --git a/client/src/components/dataProvider.ts b/client/src/components/dataProvider.ts index 4e93b26..e532513 100644 --- a/client/src/components/dataProvider.ts +++ b/client/src/components/dataProvider.ts @@ -16,7 +16,8 @@ const dataProvider = ( getList: async ({ resource, meta, pagination, sorters, filters }) => { const url = `${apiUrl}/${resource}`; - const { headers: headersFromMeta, method, queryParams } = meta ?? {}; + const { headers: headersFromMeta, method } = meta ?? {}; + const queryParams: Record = meta?.queryParams ?? {}; const requestMethod = (method as MethodTypes) ?? "get"; if (pagination && pagination.mode == "server") { @@ -28,22 +29,60 @@ const dataProvider = ( if (sorters && sorters.length > 0) { queryParams["sort"] = sorters.map((sort) => { - const field = sort.field.replace("_", ".") + const field = sort.field return `${field}:${sort.order}`; }).join(",") } if (filters && filters.length > 0) { + // Pre-process the filters and handle the special case where we want to split the filament name into vendor and filament name + if (resource === "spool") { + filters = structuredClone(filters) + + for (let i = 0; i < filters.length; i++) { + const filter = filters[i] + if (("field" in filter) && filter.field === "filament.name") { + const filamentNames: string[] = [] + const vendorNames: string[] = [] + if (Array.isArray(filter.value)) { + filter.value.forEach((value: string) => { + const split = value.split(" - ") + if (split.length === 1) { + filamentNames.push(split[0]) + } else { + vendorNames.push(split[0]) + filamentNames.push(split[1]) + } + }) + } else { + filamentNames.push(filter.value.split(" - ")[1]) + vendorNames.push(filter.value.split(" - ")[0]) + } + filter.value = filamentNames + + filters.push({ + field: "vendor.name", + operator: "in", + value: vendorNames, + }) + break + } + } + } + filters.forEach(filter => { if (!("field" in filter)) { throw Error("Filter must be a LogicalFilter.") } - const field = filter.field.replace(".", "_") - queryParams[field] = filter.value + const field = filter.field + const fieldValue = Array.isArray(filter.value) ? filter.value.join(",") : filter.value + if (fieldValue.length > 0) { + queryParams[field] = fieldValue + } }); } - const { data } = await httpClient[requestMethod]( + const { data, headers } = await httpClient[requestMethod]( `${url}`, { headers: headersFromMeta, @@ -51,10 +90,11 @@ const dataProvider = ( }, ); + // console.log(url, requestMethod, queryParams, data, headers) + return { data, - total: 100, - //TODO: total: data.length, + total: parseInt(headers["x-total-count"]) ?? 100, }; }, diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx new file mode 100644 index 0000000..178fbd0 --- /dev/null +++ b/client/src/components/otherModels.tsx @@ -0,0 +1,152 @@ +import { useQuery } from "@tanstack/react-query"; +import { IFilament } from "../pages/filaments/model"; +import { IVendor } from "../pages/vendors/model"; + +export function useSpoolmanFilamentFullNames() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["filaments"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/filament"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + // Concatenate vendor name and filament name + let names = data + .filter((filament) => { + return filament.name !== null && filament.name !== undefined && filament.name !== ""; + }) + .map((filament) => { + if (filament.vendor?.name) { + return `${filament.vendor.name} - ${filament.name ?? ""}`; + } else { + return `${filament.name ?? ""}`; + } + }) + .sort(); + // Remove duplicates + names = [...new Set(names)]; + return names; + }, + }); +} + +export function useSpoolmanFilamentNames() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["filaments"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/filament"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + // Concatenate vendor name and filament name + let names = data + .filter((filament) => { + return filament.name !== null && filament.name !== undefined && filament.name !== ""; + }) + .map((filament) => { + return filament.name ?? ""; + }) + .sort(); + // Remove duplicates + names = [...new Set(names)]; + return names; + }, + }); +} + +export function useSpoolmanVendors() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["vendors"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/vendor"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data + .map((vendor) => { + return vendor.name ?? `ID ${vendor.id}`; + }) + .sort(); + }, + }); +} + +export function useSpoolmanMaterials() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["materials"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/material"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.sort(); + }, + }); +} + +export function useSpoolmanArticleNumbers() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["articleNumbers"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/article-number"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.sort(); + }, + }); +} + +export function useSpoolmanLotNumbers() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["lotNumbers"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/lot-number"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.sort(); + }, + }); +} + +export function useSpoolmanLocations() { + const apiEndpoint = import.meta.env.VITE_APIURL; + return useQuery({ + queryKey: ["locations"], + queryFn: async () => { + const response = await fetch(apiEndpoint + "/location"); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); + }, + select: (data) => { + return data.sort(); + }, + }); +} diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx index 1f935c5..a944151 100644 --- a/client/src/components/spoolSelectModal.tsx +++ b/client/src/components/spoolSelectModal.tsx @@ -1,12 +1,11 @@ import React, { useState } from "react"; import { Modal, Table, Checkbox, Space, Row, Col, message } from "antd"; import { ISpool } from "../pages/spools/model"; -import { FilteredColumn, SortedColumn, SpoolIconColumn } from "./column"; +import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "./column"; import { TableState } from "../utils/saveload"; import { useTable } from "@refinedev/antd"; -import { genericSorter, typeSorters } from "../utils/sorting"; -import { genericFilterer, typeFilters } from "../utils/filtering"; import { t } from "i18next"; +import { useSpoolmanFilamentFullNames, useSpoolmanMaterials } from "./otherModels"; interface Props { visible: boolean; @@ -16,8 +15,8 @@ interface Props { } interface ISpoolCollapsed extends ISpool { - filament_name: string; - material?: string; + "filament.name": string; + "filament.material"?: string; } const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onContinue }) => { @@ -64,28 +63,17 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC } return { ...element, - filament_name, - material: element.filament.material, + "filament.name": filament_name, + "filament.material": element.filament.material, }; }), [tableProps.dataSource] ); - // Type the sorters and filters - const typedSorters = typeSorters(sorters); - const typedFilters = typeFilters(filters); - - // Filter and sort the dataSource - const filteredDataSource = React.useMemo(() => { - const filtered = dataSource.filter(genericFilterer(typedFilters)); - filtered.sort(genericSorter(typedSorters)); - return filtered; - }, [dataSource, typedFilters, typedSorters]); - // Function to add/remove all filtered items from selected items const selectUnselectFiltered = (select: boolean) => { setSelectedItems((prevSelected) => { - const filtered = filteredDataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool)); + const filtered = dataSource.map((spool) => spool.id).filter((spool) => !prevSelected.includes(spool)); return select ? [...prevSelected, ...filtered] : filtered; }); }; @@ -98,7 +86,7 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC }; // State for the select/unselect all checkbox - const isAllFilteredSelected = filteredDataSource.every((spool) => selectedItems.includes(spool.id)); + const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); return ( = ({ visible, description, onCancel, onC
@@ -140,17 +128,19 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC tableState, })} {SpoolIconColumn({ - id: "filament_name", + id: "filament.name", i18ncat: "spool", color: (record: ISpoolCollapsed) => record.filament.color_hex, dataSource, tableState, + filterValueQuery: useSpoolmanFilamentFullNames(), })} - {FilteredColumn({ - id: "material", - i18ncat: "spool", + {FilteredQueryColumn({ + id: "filament.material", + i18nkey: "spool.fields.material", dataSource, tableState, + filterValueQuery: useSpoolmanMaterials(), })}
diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 65d739c..74893c9 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -5,54 +5,59 @@ import { Table, Space, Button, Dropdown } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import { IFilament } from "./model"; -import { genericSorter, typeSorters } from "../../utils/sorting"; -import { genericFilterer, typeFilters } from "../../utils/filtering"; import { EditOutlined, FilterOutlined } from "@ant-design/icons"; import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; import { DateColumn, - FilteredColumn, + FilteredQueryColumn, NumberColumn, RichColumn, SortedColumn, SpoolIconColumn, } from "../../components/column"; +import { + useSpoolmanArticleNumbers, + useSpoolmanFilamentNames, + useSpoolmanMaterials, + useSpoolmanVendors, +} from "../../components/otherModels"; dayjs.extend(utc); interface IFilamentCollapsed extends Omit { - vendor_name: string | null; + "vendor.name": string | null; } +const namespace = "filamentList-v2"; + export const FilamentList: React.FC = () => { const t = useTranslate(); // Load initial state - const initialState = useInitialTableState("filamentList"); + const initialState = useInitialTableState(namespace); // Fetch data from the API - const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } = - useTable({ - syncWithLocation: false, - pagination: { - mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work. - current: initialState.pagination.current, - pageSize: initialState.pagination.pageSize, - }, - sorters: { - mode: "off", // Disable server-side sorting - initial: initialState.sorters, - }, - filters: { - mode: "off", // Disable server-side filtering - initial: initialState.filters, - }, - }); + const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable({ + syncWithLocation: false, + pagination: { + mode: "server", + current: initialState.pagination.current, + pageSize: initialState.pagination.pageSize, + }, + sorters: { + mode: "server", + initial: initialState.sorters, + }, + filters: { + mode: "server", + initial: initialState.filters, + }, + }); // Create state for the columns to show const allColumns: (keyof IFilamentCollapsed & string)[] = [ "id", - "vendor_name", + "vendor.name", "name", "material", "price", @@ -72,10 +77,6 @@ export const FilamentList: React.FC = () => { const [showColumns, setShowColumns] = React.useState(initialState.showColumns ?? defaultColumns); - // Type the sorters and filters - const typedSorters = typeSorters(sorters); - const typedFilters = typeFilters(filters); - // Store state in local storage const tableState: TableState = { sorters, @@ -83,7 +84,7 @@ export const FilamentList: React.FC = () => { pagination: { current, pageSize }, showColumns, }; - useStoreInitialState("filamentList", tableState); + useStoreInitialState(namespace, tableState); // Collapse the dataSource to a mutable list and add a filament_name field const dataSource: IFilamentCollapsed[] = React.useMemo( @@ -95,17 +96,14 @@ export const FilamentList: React.FC = () => { } else { vendor_name = null; } - return { ...element, vendor_name }; + return { ...element, "vendor.name": vendor_name }; }), [tableProps.dataSource] ); - // Filter and sort the dataSource - const filteredDataSource = React.useMemo(() => { - const filtered = dataSource.filter(genericFilterer(typedFilters)); - filtered.sort(genericSorter(typedSorters)); - return filtered; - }, [dataSource, typedFilters, typedSorters]); + if (tableProps.pagination) { + tableProps.pagination.showSizeChanger = true; + } return ( = () => { )} > - { - setCurrent(page); - setPageSize(pageSize); - }, - }} - rowKey="id" - > +
{SortedColumn({ id: "id", i18ncat: "filament", dataSource, tableState, })} - {FilteredColumn({ - id: "vendor_name", - i18ncat: "filament", + {FilteredQueryColumn({ + id: "vendor.name", + i18nkey: "filament.fields.vendor_name", dataSource, tableState, + filterValueQuery: useSpoolmanVendors(), + allowMultipleFilters: false, })} {SpoolIconColumn({ id: "name", @@ -180,12 +167,16 @@ export const FilamentList: React.FC = () => { color: (record: IFilamentCollapsed) => record.color_hex, dataSource, tableState, + filterValueQuery: useSpoolmanFilamentNames(), + allowMultipleFilters: false, })} - {FilteredColumn({ + {FilteredQueryColumn({ id: "material", i18ncat: "filament", dataSource, tableState, + filterValueQuery: useSpoolmanMaterials(), + allowMultipleFilters: false, })} {SortedColumn({ id: "price", @@ -225,11 +216,13 @@ export const FilamentList: React.FC = () => { dataSource, tableState, })} - {FilteredColumn({ + {FilteredQueryColumn({ id: "article_number", i18ncat: "filament", dataSource, tableState, + filterValueQuery: useSpoolmanArticleNumbers(), + allowMultipleFilters: false, })} {NumberColumn({ id: "settings_extruder_temp", diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 5adb287..5b72bba 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -4,14 +4,12 @@ import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/ import { Table, Space, Button, Dropdown, Modal } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import { genericSorter, typeSorters } from "../../utils/sorting"; -import { genericFilterer, typeFilters } from "../../utils/filtering"; import { ISpool } from "./model"; import { TableState, useInitialTableState, useSavedState, useStoreInitialState } from "../../utils/saveload"; import { EditOutlined, FilterOutlined, InboxOutlined, ToTopOutlined } from "@ant-design/icons"; import { DateColumn, - FilteredColumn, + FilteredQueryColumn, NumberColumn, RichColumn, SortedColumn, @@ -19,55 +17,62 @@ import { } from "../../components/column"; import { setSpoolArchived } from "./functions"; import SelectAndPrint from "../../components/selectAndPrintDialog"; +import { + useSpoolmanFilamentFullNames, + useSpoolmanLocations, + useSpoolmanLotNumbers, + useSpoolmanMaterials, +} from "../../components/otherModels"; dayjs.extend(utc); const { confirm } = Modal; interface ISpoolCollapsed extends ISpool { - filament_name: string; - filament_material?: string; + "filament.name": string; + "filament.material"?: string; } +const namespace = "spoolList-v2"; + export const SpoolList: React.FC = () => { const t = useTranslate(); const invalidate = useInvalidate(); // Load initial state - const initialState = useInitialTableState("spoolList"); + const initialState = useInitialTableState(namespace); // State for the switch to show archived spools const [showArchived, setShowArchived] = useSavedState("spoolList-showArchived", false); // Fetch data from the API - const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } = - useTable({ - meta: { - queryParams: { - ["allow_archived"]: showArchived, - }, + const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable({ + meta: { + queryParams: { + ["allow_archived"]: showArchived, }, - syncWithLocation: false, - pagination: { - mode: "server", - current: initialState.pagination.current, - pageSize: initialState.pagination.pageSize, - }, - sorters: { - mode: "server", - initial: initialState.sorters, - }, - filters: { - mode: "server", - initial: initialState.filters, - }, - }); + }, + syncWithLocation: false, + pagination: { + mode: "server", + current: initialState.pagination.current, + pageSize: initialState.pagination.pageSize, + }, + sorters: { + mode: "server", + initial: initialState.sorters, + }, + filters: { + mode: "server", + initial: initialState.filters, + }, + }); // Create state for the columns to show const allColumns: (keyof ISpoolCollapsed & string)[] = [ "id", - "filament_name", - "filament_material", + "filament.name", + "filament.material", "used_weight", "remaining_weight", "used_length", @@ -91,7 +96,7 @@ export const SpoolList: React.FC = () => { pagination: { current, pageSize }, showColumns, }; - useStoreInitialState("spoolList", tableState); + useStoreInitialState(namespace, tableState); // Collapse the dataSource to a mutable list and add a filament_name field const dataSource: ISpoolCollapsed[] = React.useMemo( @@ -105,8 +110,8 @@ export const SpoolList: React.FC = () => { } return { ...element, - filament_name, - filament_material: element.filament.material, + "filament.name": filament_name, + "filament.material": element.filament.material, }; }), [tableProps.dataSource] @@ -143,6 +148,10 @@ export const SpoolList: React.FC = () => { } }; + if (tableProps.pagination) { + tableProps.pagination.showSizeChanger = true; + } + return ( ( @@ -219,17 +228,21 @@ export const SpoolList: React.FC = () => { tableState, })} {SpoolIconColumn({ - id: "filament_name", - i18ncat: "spool", + id: "filament.name", + i18nkey: "spool.fields.filament_name", color: (record: ISpoolCollapsed) => record.filament.color_hex, dataSource, tableState, + filterValueQuery: useSpoolmanFilamentFullNames(), + allowMultipleFilters: false, })} - {FilteredColumn({ - id: "filament_material", - i18ncat: "spool", + {FilteredQueryColumn({ + id: "filament.material", + i18nkey: "spool.fields.material", dataSource, tableState, + filterValueQuery: useSpoolmanMaterials(), + allowMultipleFilters: false, })} {NumberColumn({ id: "used_weight", @@ -265,17 +278,21 @@ export const SpoolList: React.FC = () => { dataSource, tableState, })} - {FilteredColumn({ + {FilteredQueryColumn({ id: "location", i18ncat: "spool", dataSource, tableState, + filterValueQuery: useSpoolmanLocations(), + allowMultipleFilters: false, })} - {FilteredColumn({ + {FilteredQueryColumn({ id: "lot_nr", i18ncat: "spool", dataSource, tableState, + filterValueQuery: useSpoolmanLotNumbers(), + allowMultipleFilters: false, })} {DateColumn({ id: "first_used", diff --git a/client/src/pages/vendors/list.tsx b/client/src/pages/vendors/list.tsx index 345e5d5..36739da 100644 --- a/client/src/pages/vendors/list.tsx +++ b/client/src/pages/vendors/list.tsx @@ -4,8 +4,6 @@ import { useTable, List, EditButton, ShowButton, CloneButton } from "@refinedev/ import { Table, Space, Button, Dropdown } from "antd"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import { genericSorter, typeSorters } from "../../utils/sorting"; -import { genericFilterer, typeFilters } from "../../utils/filtering"; import { IVendor } from "./model"; import { TableState, useInitialTableState, useStoreInitialState } from "../../utils/saveload"; import { EditOutlined, FilterOutlined } from "@ant-design/icons"; @@ -13,39 +11,36 @@ import { DateColumn, RichColumn, SortedColumn } from "../../components/column"; dayjs.extend(utc); +const namespace = "vendorList-v2"; + export const VendorList: React.FC = () => { const t = useTranslate(); // Load initial state - const initialState = useInitialTableState("vendorList"); + const initialState = useInitialTableState(namespace); // Fetch data from the API - const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent, setPageSize } = - useTable({ - syncWithLocation: false, - pagination: { - mode: "off", // Perform pagination in antd's Table instead. Otherwise client-side sorting/filtering doesn't work. - current: initialState.pagination.current, - pageSize: initialState.pagination.pageSize, - }, - sorters: { - mode: "off", // Disable server-side sorting - initial: initialState.sorters, - }, - filters: { - mode: "off", // Disable server-side filtering - initial: initialState.filters, - }, - }); + const { tableProps, sorters, setSorters, filters, setFilters, current, pageSize, setCurrent } = useTable({ + syncWithLocation: false, + pagination: { + mode: "server", + current: initialState.pagination.current, + pageSize: initialState.pagination.pageSize, + }, + sorters: { + mode: "server", + initial: initialState.sorters, + }, + filters: { + mode: "server", + initial: initialState.filters, + }, + }); // Create state for the columns to show const allColumns: (keyof IVendor & string)[] = ["id", "name", "registered", "comment"]; const [showColumns, setShowColumns] = React.useState(initialState.showColumns ?? allColumns); - // Type the sorters and filters - const typedSorters = typeSorters(sorters); - const typedFilters = typeFilters(filters); - // Store state in local storage const tableState: TableState = { sorters, @@ -53,7 +48,7 @@ export const VendorList: React.FC = () => { pagination: { current, pageSize }, showColumns, }; - useStoreInitialState("vendorList", tableState); + useStoreInitialState(namespace, tableState); // Collapse the dataSource to a mutable list const dataSource: IVendor[] = React.useMemo( @@ -61,12 +56,9 @@ export const VendorList: React.FC = () => { [tableProps.dataSource] ); - // Filter and sort the dataSource - const filteredDataSource = React.useMemo(() => { - const filtered = dataSource.filter(genericFilterer(typedFilters)); - filtered.sort(genericSorter(typedSorters)); - return filtered; - }, [dataSource, typedFilters, typedSorters]); + if (tableProps.pagination) { + tableProps.pagination.showSizeChanger = true; + } return ( = () => { )} > -
{ - setCurrent(page); - setPageSize(pageSize); - }, - }} - rowKey="id" - > +
{SortedColumn({ id: "id", i18ncat: "vendor", diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index 893139a..1ce7e8b 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -1,6 +1,4 @@ import { CrudFilter, CrudOperators } from "@refinedev/core"; -import { ColumnFilterItem } from "antd/es/table/interface"; -import React from "react"; interface TypedCrudFilter { field: keyof Obj; @@ -12,92 +10,6 @@ export function typeFilters(filters: CrudFilter[]): TypedCrudFilter[] return filters as TypedCrudFilter[]; // <-- Unsafe cast } -/** - * Returns a filtering function that can be used to filter an array of objects based on the provided filters. - * @param filters An array of `CrudFilter` objects that define the filtering criteria. - * @returns A function that returns a boolean value based on whether the provided object matches the filtering criteria. - */ -export function genericFilterer(filters: TypedCrudFilter[]) { - return (record: Obj) => { - for (const filter of filters) { - if (!("field" in filter)) { - console.error("Filter must be of type LogicalFilter"); - return false; - } - if (!Array.isArray(filter.value)) { - console.error("Filter value must be an array of strings."); - return false; - } - if (!filter.value.length) { - continue; - } - const value = record[filter.field]; - let strValue: string; - if (value === undefined || value === null) { - strValue = ""; - } else { - if (typeof value !== "string") { - console.error("Only string fields can be filtered, field is of type " + typeof value); - return false; - } else { - strValue = value; - } - } - - switch (filter.operator) { - case "in": - if (!filter.value.includes(strValue)) { - return false; - } - break; - default: - console.error(`Not implemented operator: ${filter.operator}`); - return false; - } - } - return true; - } -} - -/** - * Populates an array of `ColumnFilterItem` objects based on the unique values of a given field in an array of objects. - * @param dataSource An array of objects to filter. - * @param field The name of the field to filter on. - * @returns An array of `ColumnFilterItem` objects representing the unique values of the specified field in the input array. - */ -export function useListFiltersForField( - dataSource: Obj[], - field: Field): ColumnFilterItem[] { - return React.useMemo(() => { - const filters: ColumnFilterItem[] = []; - dataSource.forEach((element) => { - const value = element[field]; - if (typeof value === "string" && value !== "") { - // Make sure it's not already in the filters - if (filters.find((f) => f.value === value)) { - return; - } - filters.push({ - text: value, - value: value, - }); - } - }); - // Sort the filters - filters.sort((a, b) => { - if (typeof a.text !== "string" || typeof b.text !== "string") { - return 0; - } - return a.text.localeCompare(b.text); - }); - filters.push({ - text: "", - value: "", - }); - return filters; - }, [dataSource, field]); -} - /** * Returns an array of filter values for a given field based on the provided filters. * @param filters An array of `CrudFilter` objects that define the filtering criteria. diff --git a/client/src/utils/sorting.ts b/client/src/utils/sorting.ts index 3855d68..c43b525 100644 --- a/client/src/utils/sorting.ts +++ b/client/src/utils/sorting.ts @@ -7,62 +7,6 @@ interface TypedCrudSort { order: "asc" | "desc"; } -/** - * Returns a sorting function that can be used to sort an array of objects based on the provided sorters. - * @param sorters An array of `CrudSort` objects that define the sorting criteria. - * @returns A sorting function that can be used to sort an array of objects based on the provided sorters. - */ -export function genericSorter(sorters: TypedCrudSort[]) { - return (a: Obj, b: Obj) => { - for (const sorter of sorters) { - const aValue = a[sorter.field]; - const bValue = b[sorter.field]; - if (aValue === bValue) { - continue; - } - // Send empty fields to the bottom - if (aValue === undefined || aValue === null) { - return 1; - } - if (bValue === undefined || bValue === null) { - return -1; - } - // Perform sorting based on type of the fields - if (typeof aValue === "string" && typeof bValue === "string") { - // Try to sort them as dates if possible - const aDate = new Date(aValue); - const bDate = new Date(bValue); - if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) { - return sorter.order === "asc" - ? aDate.getTime() - bDate.getTime() - : bDate.getTime() - aDate.getTime(); - } - - return sorter.order === "asc" - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue); - } - if (typeof aValue === "number" && typeof bValue === "number") { - return sorter.order === "asc" ? aValue - bValue : bValue - aValue; - } - if (typeof aValue === "boolean" && typeof bValue === "boolean") { - return sorter.order === "asc" ? +aValue - +bValue : +bValue - +aValue; - } - if (aValue instanceof Date && bValue instanceof Date) { - return sorter.order === "asc" - ? aValue.getTime() - bValue.getTime() - : bValue.getTime() - aValue.getTime(); - } - if (typeof aValue === "object" && typeof bValue === "object") { - return sorter.order === "asc" - ? JSON.stringify(aValue).localeCompare(JSON.stringify(bValue)) - : JSON.stringify(bValue).localeCompare(JSON.stringify(aValue)); - } - } - return 0; - }; -} - /** * Returns the sort order for a given field based on the provided sorters. * @param sorters An array of `CrudSort` objects that define the sorting criteria. From de16dd7be055cbeedf008fa47d2ee47eb823c901 Mon Sep 17 00:00:00 2001 From: Donkie Date: Thu, 7 Sep 2023 21:50:12 +0200 Subject: [PATCH 12/31] Added database support for finding empty fields --- pyproject.toml | 1 + spoolman/database/filament.py | 26 ++++++++++--------- spoolman/database/spool.py | 33 ++++++++++++------------ spoolman/database/utils.py | 48 +++++++++++++++++++++++++++++++++-- spoolman/database/vendor.py | 6 ++--- 5 files changed, 81 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef63865..aa77f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ ignore = [ "EM102", "DTZ003", "PLR0913", + "SIM108", ] line-length = 120 target-version = "py39" diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 04c7cbb..657101f 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import models, vendor -from spoolman.database.utils import SortOrder, parse_nested_field +from spoolman.database.utils import ( + SortOrder, + add_where_clause, + add_where_clause_str, + add_where_clause_str_opt, + parse_nested_field, +) from spoolman.exceptions import ItemDeleteError, ItemNotFoundError @@ -67,7 +73,7 @@ async def get_by_id(db: AsyncSession, filament_id: int) -> models.Filament: return filament -async def find( # noqa: C901 +async def find( *, db: AsyncSession, vendor_name: Optional[str] = None, @@ -91,16 +97,12 @@ async def find( # noqa: C901 .options(contains_eager(models.Filament.vendor)) .join(models.Filament.vendor, isouter=True) ) - if vendor_name is not None: - stmt = stmt.where(models.Vendor.name.ilike(f"%{vendor_name}%")) - if vendor_id is not None: - stmt = stmt.where(models.Filament.vendor_id == vendor_id) - if name is not None: - stmt = stmt.where(models.Filament.name.ilike(f"%{name}%")) - if material is not None: - stmt = stmt.where(models.Filament.material.ilike(f"%{material}%")) - if article_number is not None: - stmt = stmt.where(models.Filament.article_number.ilike(f"%{article_number}%")) + + stmt = add_where_clause(stmt, models.Filament.vendor_id, vendor_id) + stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_str_opt(stmt, models.Filament.name, name) + stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) + stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number) total_count = None diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 467b8fe..d6a9a83 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -10,7 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import filament, models -from spoolman.database.utils import SortOrder, parse_nested_field +from spoolman.database.utils import ( + SortOrder, + add_where_clause, + add_where_clause_str, + add_where_clause_str_opt, + parse_nested_field, +) from spoolman.exceptions import ItemCreateError, ItemNotFoundError from spoolman.math import weight_from_length @@ -76,7 +82,7 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool: return spool -async def find( # noqa: C901, PLR0912 +async def find( *, db: AsyncSession, filament_name: Optional[str] = None, @@ -104,20 +110,15 @@ async def find( # noqa: C901, PLR0912 .join(models.Filament.vendor, isouter=True) .options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor)) ) - if filament_name is not None: - stmt = stmt.where(models.Filament.name.ilike(f"%{filament_name}%")) - if filament_id is not None: - stmt = stmt.where(models.Spool.filament_id == filament_id) - if filament_material is not None: - stmt = stmt.where(models.Filament.material.ilike(f"%{filament_material}%")) - if vendor_name is not None: - stmt = stmt.where(models.Vendor.name.ilike(f"%{vendor_name}%")) - if vendor_id is not None: - stmt = stmt.where(models.Filament.vendor_id == vendor_id) - if location is not None: - stmt = stmt.where(models.Spool.location.ilike(f"%{location}%")) - if lot_nr is not None: - stmt = stmt.where(models.Spool.lot_nr.ilike(f"%{lot_nr}%")) + + stmt = add_where_clause(stmt, models.Spool.filament_id, filament_id) + stmt = add_where_clause(stmt, models.Filament.vendor_id, vendor_id) + stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) + stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name) + stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) + stmt = add_where_clause_str_opt(stmt, models.Spool.location, location) + stmt = add_where_clause_str_opt(stmt, models.Spool.lot_nr, lot_nr) + if not allow_archived: # Since the archived field is nullable, and default is false, we need to check for both false or null stmt = stmt.where( diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 5f95ad2..54e0c90 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -1,9 +1,11 @@ """Utility functions for the database module.""" from enum import Enum -from typing import Any +from typing import Any, Optional, TypeVar import sqlalchemy +from sqlalchemy import Select +from sqlalchemy.orm import attributes from spoolman.database import models @@ -13,7 +15,7 @@ class SortOrder(Enum): DESC = 2 -def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column[Any]: +def parse_nested_field(base_obj: type[models.Base], field: str) -> attributes.InstrumentedAttribute[Any]: """Parse a nested field string into a sqlalchemy field object.""" fields = field.split(".") if not hasattr(base_obj, fields[0]): @@ -33,3 +35,45 @@ def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Co raise ValueError(f"Field '{fields[0]}' does not have any nested fields") return getattr(base_obj, fields[0]) + + +def add_where_clause_str_opt( + stmt: Select, + field: attributes.InstrumentedAttribute[Optional[str]], + value: Optional[str], +) -> Select: + """Add a where clause to a select statement for an optional string field.""" + if value is not None: + if len(value) == 0: + stmt = stmt.where(sqlalchemy.or_(field.is_(None), field == "")) + else: + stmt = stmt.where(field.ilike(f"%{value}%")) + return stmt + + +def add_where_clause_str( + stmt: Select, + field: attributes.InstrumentedAttribute[str], + value: Optional[str], +) -> Select: + """Add a where clause to a select statement for a string field.""" + if value is not None: + if len(value) == 0: + stmt = stmt.where(field == "") + else: + stmt = stmt.where(field.ilike(f"%{value}%")) + return stmt + + +T = TypeVar("T") + + +def add_where_clause( + stmt: Select, + field: attributes.InstrumentedAttribute[T], + value: Optional[T], +) -> Select: + """Add a where clause to a select statement for a field.""" + if value is not None: + stmt = stmt.where(field == value) + return stmt diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index a9e66a9..120c061 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -6,7 +6,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from spoolman.database import models -from spoolman.database.utils import SortOrder +from spoolman.database.utils import SortOrder, add_where_clause_str from spoolman.exceptions import ItemNotFoundError @@ -47,8 +47,8 @@ async def find( Returns a tuple containing the list of items and the total count of matching items. """ stmt = select(models.Vendor) - if name is not None: - stmt = stmt.where(models.Vendor.name.ilike(f"%{name}%")) + + stmt = add_where_clause_str(stmt, models.Vendor.name, name) total_count = None From 3b8db0b5f7b955859c88eec5c5bef824dddc7746 Mon Sep 17 00:00:00 2001 From: Donkie Date: Thu, 7 Sep 2023 21:50:26 +0200 Subject: [PATCH 13/31] Expose X-Total-Count header --- spoolman/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spoolman/main.py b/spoolman/main.py index 1e16948..b04dac4 100644 --- a/spoolman/main.py +++ b/spoolman/main.py @@ -56,6 +56,7 @@ if env.is_debug_mode(): allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Total-Count"], ) From f47eacd2a9029285d687fe2fe3dfeb8edab0595a Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 20:31:50 +0200 Subject: [PATCH 14/31] Added support for finding empty vendor fields --- spoolman/database/filament.py | 4 ++-- spoolman/database/spool.py | 7 ++++--- spoolman/database/utils.py | 25 ++++++++++++++++++------- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 657101f..8ce8384 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import models, vendor from spoolman.database.utils import ( SortOrder, - add_where_clause, + add_where_clause_int_opt, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -98,7 +98,7 @@ async def find( .join(models.Filament.vendor, isouter=True) ) - stmt = add_where_clause(stmt, models.Filament.vendor_id, vendor_id) + stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) stmt = add_where_clause_str_opt(stmt, models.Filament.name, name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, material) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index d6a9a83..08d97f7 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -12,7 +12,8 @@ from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import filament, models from spoolman.database.utils import ( SortOrder, - add_where_clause, + add_where_clause_int, + add_where_clause_int_opt, add_where_clause_str, add_where_clause_str_opt, parse_nested_field, @@ -111,8 +112,8 @@ async def find( .options(contains_eager(models.Spool.filament).contains_eager(models.Filament.vendor)) ) - stmt = add_where_clause(stmt, models.Spool.filament_id, filament_id) - stmt = add_where_clause(stmt, models.Filament.vendor_id, vendor_id) + stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id) + stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id) stmt = add_where_clause_str(stmt, models.Vendor.name, vendor_name) stmt = add_where_clause_str_opt(stmt, models.Filament.name, filament_name) stmt = add_where_clause_str_opt(stmt, models.Filament.material, filament_material) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 54e0c90..03cb441 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -1,7 +1,7 @@ """Utility functions for the database module.""" from enum import Enum -from typing import Any, Optional, TypeVar +from typing import Any, Optional import sqlalchemy from sqlalchemy import Select @@ -65,15 +65,26 @@ def add_where_clause_str( return stmt -T = TypeVar("T") - - -def add_where_clause( +def add_where_clause_int( stmt: Select, - field: attributes.InstrumentedAttribute[T], - value: Optional[T], + field: attributes.InstrumentedAttribute[int], + value: Optional[int], ) -> Select: """Add a where clause to a select statement for a field.""" if value is not None: stmt = stmt.where(field == value) return stmt + + +def add_where_clause_int_opt( + stmt: Select, + field: attributes.InstrumentedAttribute[Optional[int]], + value: Optional[int], +) -> Select: + """Add a where clause to a select statement for a field.""" + if value is not None: + if value == -1: + stmt = stmt.where(field.is_(None)) + else: + stmt = stmt.where(field == value) + return stmt From 9a7375d781b0c8674efb046feb054aefe240bc27 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 20:32:19 +0200 Subject: [PATCH 15/31] Added test cases for finding empty spool fields --- pyproject.toml | 2 +- tests_integration/tests/conftest.py | 58 ++++++++ tests_integration/tests/spool/test_find.py | 157 +++++++++++++++++---- 3 files changed, 188 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aa77f3e..169bfd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ line-length = 120 target-version = "py39" [tool.ruff.per-file-ignores] -"tests*/*" = ["ANN201", "S101", "PLR2004"] +"tests*/*" = ["ANN201", "S101", "PLR2004", "D103"] "migrations/versions/*" = ["N999"] [tool.black] diff --git a/tests_integration/tests/conftest.py b/tests_integration/tests/conftest.py index 640363d..ffd0e0f 100644 --- a/tests_integration/tests/conftest.py +++ b/tests_integration/tests/conftest.py @@ -67,6 +67,23 @@ def random_vendor(): httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status() +@pytest.fixture() +def random_empty_vendor(): + """Return a random vendor with only required fields specified.""" + # Add vendor + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": ""}, + ) + result.raise_for_status() + + vendor = result.json() + yield vendor + + # Delete vendor + httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status() + + @pytest.fixture() def random_filament(random_vendor: dict[str, Any]): """Return a random filament.""" @@ -95,6 +112,47 @@ def random_filament(random_vendor: dict[str, Any]): httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() +@pytest.fixture() +def random_empty_filament(): + """Return a random filament with only required fields specified.""" + # Add filament + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "density": 1.25, + "diameter": 1.75, + }, + ) + result.raise_for_status() + + filament = result.json() + yield filament + + # Delete filament + httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() + + +@pytest.fixture() +def random_empty_filament_empty_vendor(random_empty_vendor: dict[str, Any]): + """Return a random filament with only required fields specified and a vendor with only required fields specified.""" + # Add filament + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": random_empty_vendor["id"], + "density": 1.25, + "diameter": 1.75, + }, + ) + result.raise_for_status() + + filament = result.json() + yield filament + + # Delete filament + httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() + + def length_from_weight(*, weight: float, diameter: float, density: float) -> float: """Calculate the length of a piece of filament. diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 4559769..05700e8 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -10,6 +10,11 @@ import pytest URL = "http://spoolman:8000" +def spool_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool: + """Compare two lists of spools where the order of the spools is not guaranteed.""" + return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"]) + + @dataclass class Fixture: spools: list[dict[str, Any]] @@ -18,7 +23,11 @@ class Fixture: @pytest.fixture() -def spools(random_filament: dict[str, Any]) -> Iterable[Fixture]: +def spools( + random_filament: dict[str, Any], + random_empty_filament: dict[str, Any], + random_empty_filament_empty_vendor: dict[str, Any], +) -> Iterable[Fixture]: """Add some spools to the database.""" result = httpx.post( f"{URL}/api/v1/spool", @@ -55,14 +64,34 @@ def spools(random_filament: dict[str, Any]) -> Iterable[Fixture]: result.raise_for_status() spool_3 = result.json() + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_empty_filament["id"], + }, + ) + result.raise_for_status() + spool_4 = result.json() + + result = httpx.post( + f"{URL}/api/v1/spool", + json={ + "filament_id": random_empty_filament_empty_vendor["id"], + }, + ) + result.raise_for_status() + spool_5 = result.json() + added_spools_by_id = { spool_1["id"]: spool_1, spool_2["id"]: spool_2, spool_3["id"]: spool_3, + spool_4["id"]: spool_4, + spool_5["id"]: spool_5, } yield Fixture( - spools=[spool_1, spool_2, spool_3], + spools=[spool_1, spool_2, spool_3, spool_4, spool_5], spools_by_id=added_spools_by_id, filament=random_filament, ) @@ -70,6 +99,8 @@ def spools(random_filament: dict[str, Any]) -> Iterable[Fixture]: httpx.delete(f"{URL}/api/v1/spool/{spool_1['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_2['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/spool/{spool_3['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_4['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/spool/{spool_5['id']}").raise_for_status() def test_find_all_spools(spools: Fixture): @@ -80,10 +111,7 @@ def test_find_all_spools(spools: Fixture): # Verify spools_result = result.json() - assert len(spools_result) == 2 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] - assert spool["archived"] is False + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3], spools.spools[4])) def test_find_all_spools_including_archived(spools: Fixture): @@ -94,9 +122,16 @@ def test_find_all_spools_including_archived(spools: Fixture): # Verify spools_result = result.json() - assert len(spools_result) == 3 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] + assert spool_lists_equal( + spools_result, + ( + spools.spools[0], + spools.spools[1], + spools.spools[2], + spools.spools[3], + spools.spools[4], + ), + ) @pytest.mark.parametrize("field_name", ["filament_name", "filament.name"]) @@ -111,9 +146,20 @@ def test_find_spools_by_filament_name(spools: Fixture, field_name: str): # Verify spools_result = result.json() - assert len(spools_result) == 2 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) + + +def test_find_spools_by_empty_filament_name(spools: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/spool", + params={"filament.name": ""}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) @pytest.mark.parametrize("field_name", ["filament_id", "filament.id"]) @@ -128,9 +174,7 @@ def test_find_spools_by_filament_id(spools: Fixture, field_name: str): # Verify spools_result = result.json() - assert len(spools_result) == 2 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) @pytest.mark.parametrize("field_name", ["filament_material", "filament.material"]) @@ -145,9 +189,20 @@ def test_find_spools_by_filament_material(spools: Fixture, field_name: str): # Verify spools_result = result.json() - assert len(spools_result) == 2 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) + + +def test_find_spools_by_empty_filament_material(spools: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/spool", + params={"filament.material": ""}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) @pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) @@ -162,9 +217,20 @@ def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str): # Verify spools_result = result.json() - assert len(spools_result) == 2 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) + + +def test_find_spools_by_empty_filament_vendor_name(spools: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/spool", + params={"vendor.name": ""}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spools_result == [spools.spools[4]] @pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) @@ -179,9 +245,20 @@ def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str): # Verify spools_result = result.json() - assert len(spools_result) == 2 - for spool in spools_result: - assert spool == spools.spools_by_id[spool["id"]] + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) + + +def test_find_spools_by_empty_filament_vendor_id(spools: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/spool", + params={"vendor.id": -1}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spools_result == [spools.spools[3]] def test_find_spools_by_location(spools: Fixture): @@ -195,8 +272,20 @@ def test_find_spools_by_location(spools: Fixture): # Verify spools_result = result.json() - assert len(spools_result) == 1 - assert spools_result[0] == spools.spools[0] + assert spools_result == [spools.spools[0]] + + +def test_find_spools_by_empty_location(spools: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/spool", + params={"location": ""}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) def test_find_spools_by_lot_nr(spools: Fixture): @@ -210,5 +299,17 @@ def test_find_spools_by_lot_nr(spools: Fixture): # Verify spools_result = result.json() - assert len(spools_result) == 1 - assert spools_result[0] == spools.spools[0] + assert spools_result == [spools.spools[0]] + + +def test_find_spools_by_empty_lot_nr(spools: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/spool", + params={"lot_nr": ""}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) From e436aa4975b44ede62c00274ccddd164d036fdef Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 20:49:08 +0200 Subject: [PATCH 16/31] Added tests for spool find sorting --- spoolman/api/v1/spool.py | 2 +- spoolman/database/utils.py | 2 +- tests_integration/tests/spool/test_find.py | 76 +++++++++++++++++++--- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index dc7762a..12b8081 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -159,7 +159,7 @@ async def find( description=( 'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.' ), - example="filament.name:asc,vendor.id:asc,location:desc", + example="filament.name:asc,filament.vendor.id:asc,location:desc", ), limit: Optional[int] = Query( default=None, diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 03cb441..54fb3d8 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -19,7 +19,7 @@ def parse_nested_field(base_obj: type[models.Base], field: str) -> attributes.In """Parse a nested field string into a sqlalchemy field object.""" fields = field.split(".") if not hasattr(base_obj, fields[0]): - raise ValueError(f"Invalid field name '{field}'") + raise ValueError(f"Invalid field name '{field}', '{fields[0]}' is not a valid field on '{base_obj.__name__}'.") if fields[0] == "filament" and len(fields) == 1: raise ValueError("No field specified for filament") diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 05700e8..a288e49 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -104,7 +104,6 @@ def spools( def test_find_all_spools(spools: Fixture): - """Test finding all non-archived spools in the database.""" # Execute result = httpx.get(f"{URL}/api/v1/spool") result.raise_for_status() @@ -115,7 +114,6 @@ def test_find_all_spools(spools: Fixture): def test_find_all_spools_including_archived(spools: Fixture): - """Test finding all spools in the database, including archived ones.""" # Execute result = httpx.get(f"{URL}/api/v1/spool?allow_archived=true") result.raise_for_status() @@ -134,9 +132,75 @@ def test_find_all_spools_including_archived(spools: Fixture): ) +def test_find_all_spools_sort_asc(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=location:asc") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 4 + assert spools_result[3] == spools.spools[0] + + +def test_find_all_spools_sort_desc(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=location:desc") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 4 + assert spools_result[0] == spools.spools[0] + + +@pytest.mark.parametrize( + "field_name", + [ + "id", + "registered", + "first_used", + "last_used", + "filament_id", + "used_weight", + "location", + "lot_nr", + "comment", + "archived", + "filament.id", + "filament.registered", + "filament.name", + "filament.vendor_id", + "filament.material", + "filament.price", + "filament.density", + "filament.diameter", + "filament.weight", + "filament.spool_weight", + "filament.article_number", + "filament.comment", + "filament.settings_extruder_temp", + "filament.settings_bed_temp", + "filament.color_hex", + "filament.vendor.id", + "filament.vendor.registered", + "filament.vendor.name", + "filament.vendor.comment", + ], +) +def test_find_all_spools_sort_fields(spools: Fixture, field_name: str): # noqa: ARG001 + """Test sorting by all fields.""" + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort={field_name}:asc") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 4 + + @pytest.mark.parametrize("field_name", ["filament_name", "filament.name"]) def test_find_spools_by_filament_name(spools: Fixture, field_name: str): - """Test finding spools by filament name.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", @@ -164,7 +228,6 @@ def test_find_spools_by_empty_filament_name(spools: Fixture): @pytest.mark.parametrize("field_name", ["filament_id", "filament.id"]) def test_find_spools_by_filament_id(spools: Fixture, field_name: str): - """Test finding spools by filament id.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", @@ -179,7 +242,6 @@ def test_find_spools_by_filament_id(spools: Fixture, field_name: str): @pytest.mark.parametrize("field_name", ["filament_material", "filament.material"]) def test_find_spools_by_filament_material(spools: Fixture, field_name: str): - """Test finding spools by filament material.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", @@ -207,7 +269,6 @@ def test_find_spools_by_empty_filament_material(spools: Fixture): @pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str): - """Test finding spools by filament vendor name.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", @@ -235,7 +296,6 @@ def test_find_spools_by_empty_filament_vendor_name(spools: Fixture): @pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str): - """Test finding spools by filament vendor id.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", @@ -262,7 +322,6 @@ def test_find_spools_by_empty_filament_vendor_id(spools: Fixture): def test_find_spools_by_location(spools: Fixture): - """Test finding spools by location.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", @@ -289,7 +348,6 @@ def test_find_spools_by_empty_location(spools: Fixture): def test_find_spools_by_lot_nr(spools: Fixture): - """Test finding spools by lot nr.""" # Execute result = httpx.get( f"{URL}/api/v1/spool", From 13994c486f37fd7afd87507f6df17657434aa2f4 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 21:00:47 +0200 Subject: [PATCH 17/31] Added module-level fixtures in tests --- tests_integration/tests/conftest.py | 167 +++++++++++++----- tests_integration/tests/filament/test_find.py | 8 +- tests_integration/tests/spool/test_find.py | 20 +-- 3 files changed, 134 insertions(+), 61 deletions(-) diff --git a/tests_integration/tests/conftest.py b/tests_integration/tests/conftest.py index ffd0e0f..a87de9f 100644 --- a/tests_integration/tests/conftest.py +++ b/tests_integration/tests/conftest.py @@ -3,6 +3,7 @@ import math import os import time +from contextlib import contextmanager from enum import StrEnum from typing import Any @@ -50,8 +51,8 @@ def _wait_for_server(): # noqa: ANN202 break -@pytest.fixture() -def random_vendor(): +@contextmanager +def random_vendor_impl(): """Return a random vendor.""" # Add vendor result = httpx.post( @@ -60,15 +61,15 @@ def random_vendor(): ) result.raise_for_status() - vendor = result.json() + vendor: dict[str, Any] = result.json() yield vendor # Delete vendor httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status() -@pytest.fixture() -def random_empty_vendor(): +@contextmanager +def random_empty_vendor_impl(): """Return a random vendor with only required fields specified.""" # Add vendor result = httpx.post( @@ -77,43 +78,44 @@ def random_empty_vendor(): ) result.raise_for_status() - vendor = result.json() + vendor: dict[str, Any] = result.json() yield vendor # Delete vendor httpx.delete(f"{URL}/api/v1/vendor/{vendor['id']}").raise_for_status() -@pytest.fixture() -def random_filament(random_vendor: dict[str, Any]): +@contextmanager +def random_filament_impl(): """Return a random filament.""" - # Add filament - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "name": "Filament X", - "vendor_id": random_vendor["id"], - "material": "PLA", - "price": 100, - "density": 1.25, - "diameter": 1.75, - "weight": 1000, - "spool_weight": 250, - "article_number": "123456789", - "comment": "abcdefghåäö", - }, - ) - result.raise_for_status() + with random_vendor_impl() as random_vendor: + # Add filament + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "name": "Filament X", + "vendor_id": random_vendor["id"], + "material": "PLA", + "price": 100, + "density": 1.25, + "diameter": 1.75, + "weight": 1000, + "spool_weight": 250, + "article_number": "123456789", + "comment": "abcdefghåäö", + }, + ) + result.raise_for_status() - filament = result.json() - yield filament + filament: dict[str, Any] = result.json() + yield filament - # Delete filament - httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() + # Delete filament + httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() -@pytest.fixture() -def random_empty_filament(): +@contextmanager +def random_empty_filament_impl(): """Return a random filament with only required fields specified.""" # Add filament result = httpx.post( @@ -125,32 +127,103 @@ def random_empty_filament(): ) result.raise_for_status() - filament = result.json() + filament: dict[str, Any] = result.json() yield filament # Delete filament httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() +@contextmanager +def random_empty_filament_empty_vendor_impl(): + """Return a random filament with only required fields specified and a vendor with only required fields specified.""" + with random_empty_vendor_impl() as random_empty_vendor: + # Add filament + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": random_empty_vendor["id"], + "density": 1.25, + "diameter": 1.75, + }, + ) + result.raise_for_status() + + filament: dict[str, Any] = result.json() + yield filament + + # Delete filament + httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() + + @pytest.fixture() -def random_empty_filament_empty_vendor(random_empty_vendor: dict[str, Any]): +def random_vendor(): + """Return a random vendor.""" + with random_vendor_impl() as random_vendor: + yield random_vendor + + +@pytest.fixture() +def random_empty_vendor(): + """Return a random vendor with only required fields specified.""" + with random_empty_vendor_impl() as random_empty_vendor: + yield random_empty_vendor + + +@pytest.fixture() +def random_filament(): + """Return a random filament.""" + with random_filament_impl() as random_filament: + yield random_filament + + +@pytest.fixture() +def random_empty_filament(): + """Return a random filament with only required fields specified.""" + with random_empty_filament_impl() as random_empty_filament: + yield random_empty_filament + + +@pytest.fixture() +def random_empty_filament_empty_vendor(): """Return a random filament with only required fields specified and a vendor with only required fields specified.""" - # Add filament - result = httpx.post( - f"{URL}/api/v1/filament", - json={ - "vendor_id": random_empty_vendor["id"], - "density": 1.25, - "diameter": 1.75, - }, - ) - result.raise_for_status() + with random_empty_filament_empty_vendor_impl() as random_empty_filament_empty_vendor: + yield random_empty_filament_empty_vendor - filament = result.json() - yield filament - # Delete filament - httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status() +@pytest.fixture(scope="module") +def random_vendor_mod(): + """Return a random vendor.""" + with random_vendor_impl() as random_vendor: + yield random_vendor + + +@pytest.fixture(scope="module") +def random_empty_vendor_mod(): + """Return a random vendor with only required fields specified.""" + with random_empty_vendor_impl() as random_empty_vendor: + yield random_empty_vendor + + +@pytest.fixture(scope="module") +def random_filament_mod(): + """Return a random filament.""" + with random_filament_impl() as random_filament: + yield random_filament + + +@pytest.fixture(scope="module") +def random_empty_filament_mod(): + """Return a random filament with only required fields specified.""" + with random_empty_filament_impl() as random_empty_filament: + yield random_empty_filament + + +@pytest.fixture(scope="module") +def random_empty_filament_empty_vendor_mod(): + """Return a random filament with only required fields specified and a vendor with only required fields specified.""" + with random_empty_filament_empty_vendor_impl() as random_empty_filament_empty_vendor: + yield random_empty_filament_empty_vendor def length_from_weight(*, weight: float, diameter: float, density: float) -> float: diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index 91ad44d..6232ea0 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -16,14 +16,14 @@ class Fixture: filaments_by_id: dict[str, dict[str, Any]] -@pytest.fixture() -def filaments(random_vendor: dict[str, Any]) -> Iterable[Fixture]: +@pytest.fixture(scope="module") +def filaments(random_vendor_mod: dict[str, Any]) -> Iterable[Fixture]: """Add some filaments to the database.""" result = httpx.post( f"{URL}/api/v1/filament", json={ "name": "Filament X", - "vendor_id": random_vendor["id"], + "vendor_id": random_vendor_mod["id"], "material": "PLA", "price": 100, "density": 1.25, @@ -41,7 +41,7 @@ def filaments(random_vendor: dict[str, Any]) -> Iterable[Fixture]: f"{URL}/api/v1/filament", json={ "name": "Filament Y", - "vendor_id": random_vendor["id"], + "vendor_id": random_vendor_mod["id"], "material": "ABS", "price": 200, "density": 1.25, diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index a288e49..9c6dc1d 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -22,17 +22,17 @@ class Fixture: filament: dict[str, Any] -@pytest.fixture() +@pytest.fixture(scope="module") def spools( - random_filament: dict[str, Any], - random_empty_filament: dict[str, Any], - random_empty_filament_empty_vendor: dict[str, Any], + random_filament_mod: dict[str, Any], + random_empty_filament_mod: dict[str, Any], + random_empty_filament_empty_vendor_mod: dict[str, Any], ) -> Iterable[Fixture]: """Add some spools to the database.""" result = httpx.post( f"{URL}/api/v1/spool", json={ - "filament_id": random_filament["id"], + "filament_id": random_filament_mod["id"], "remaining_weight": 1000, "location": "The Pantry", "lot_nr": "123456789", @@ -44,7 +44,7 @@ def spools( result = httpx.post( f"{URL}/api/v1/spool", json={ - "filament_id": random_filament["id"], + "filament_id": random_filament_mod["id"], "remaining_weight": 1000, "location": "Living Room", "lot_nr": "987654321", @@ -56,7 +56,7 @@ def spools( result = httpx.post( f"{URL}/api/v1/spool", json={ - "filament_id": random_filament["id"], + "filament_id": random_filament_mod["id"], "remaining_weight": 1000, "archived": True, }, @@ -67,7 +67,7 @@ def spools( result = httpx.post( f"{URL}/api/v1/spool", json={ - "filament_id": random_empty_filament["id"], + "filament_id": random_empty_filament_mod["id"], }, ) result.raise_for_status() @@ -76,7 +76,7 @@ def spools( result = httpx.post( f"{URL}/api/v1/spool", json={ - "filament_id": random_empty_filament_empty_vendor["id"], + "filament_id": random_empty_filament_empty_vendor_mod["id"], }, ) result.raise_for_status() @@ -93,7 +93,7 @@ def spools( yield Fixture( spools=[spool_1, spool_2, spool_3, spool_4, spool_5], spools_by_id=added_spools_by_id, - filament=random_filament, + filament=random_filament_mod, ) httpx.delete(f"{URL}/api/v1/spool/{spool_1['id']}").raise_for_status() From aa28e51c289f018bed77568f03deab076f15b293 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 21:11:16 +0200 Subject: [PATCH 18/31] Added offset and limit tests for spools --- tests_integration/tests/spool/test_find.py | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 9c6dc1d..f3b9c5c 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -154,6 +154,60 @@ def test_find_all_spools_sort_desc(spools: Fixture): assert spools_result[0] == spools.spools[0] +def test_find_all_spools_limit_asc(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 2 + assert spools_result == [spools.spools[0], spools.spools[1]] + + +def test_find_all_spools_limit_desc(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc&limit=2") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 2 + assert spools_result == [spools.spools[-1], spools.spools[-2]] + + +def test_find_all_spools_limit_asc_offset(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2&offset=1&allow_archived=true") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 2 + assert spools_result == [spools.spools[1], spools.spools[2]] + + +def test_find_all_spools_limit_desc_offset(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc&limit=2&offset=1&allow_archived=true") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 2 + assert spools_result == [spools.spools[-2], spools.spools[-3]] + + +def test_find_all_spools_limit_asc_offset_outside_range(spools: Fixture): # noqa: ARG001 + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2&offset=100") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 0 + + @pytest.mark.parametrize( "field_name", [ From acc6ab56a594e0737670f803e91eac409c3544b9 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 21:14:39 +0200 Subject: [PATCH 19/31] Fixed tests for non-sqlite dbs --- tests_integration/tests/spool/test_find.py | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index f3b9c5c..84544df 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -134,18 +134,7 @@ def test_find_all_spools_including_archived(spools: Fixture): def test_find_all_spools_sort_asc(spools: Fixture): # Execute - result = httpx.get(f"{URL}/api/v1/spool?sort=location:asc") - result.raise_for_status() - - # Verify - spools_result = result.json() - assert len(spools_result) == 4 - assert spools_result[3] == spools.spools[0] - - -def test_find_all_spools_sort_desc(spools: Fixture): - # Execute - result = httpx.get(f"{URL}/api/v1/spool?sort=location:desc") + result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc") result.raise_for_status() # Verify @@ -154,6 +143,17 @@ def test_find_all_spools_sort_desc(spools: Fixture): assert spools_result[0] == spools.spools[0] +def test_find_all_spools_sort_desc(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 4 + assert spools_result[-1] == spools.spools[0] + + def test_find_all_spools_limit_asc(spools: Fixture): # Execute result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2") From 978773583a7009b8b5f5e3bf47c3c2540a25c5f9 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 21:34:52 +0200 Subject: [PATCH 20/31] Full testing done for filaments Also added tests for having multiple sorts --- tests_integration/tests/filament/test_find.py | 250 ++++++++++++++++-- tests_integration/tests/spool/test_find.py | 26 +- 2 files changed, 248 insertions(+), 28 deletions(-) diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index 6232ea0..61f1c83 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -10,6 +10,11 @@ import pytest URL = "http://spoolman:8000" +def filament_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool: + """Compare two lists of filaments where the order of the filaments is not guaranteed.""" + return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"]) + + @dataclass class Fixture: filaments: list[dict[str, Any]] @@ -17,7 +22,7 @@ class Fixture: @pytest.fixture(scope="module") -def filaments(random_vendor_mod: dict[str, Any]) -> Iterable[Fixture]: +def filaments(random_vendor_mod: dict[str, Any], random_empty_vendor_mod: dict[str, Any]) -> Iterable[Fixture]: """Add some filaments to the database.""" result = httpx.post( f"{URL}/api/v1/filament", @@ -58,7 +63,7 @@ def filaments(random_vendor_mod: dict[str, Any]) -> Iterable[Fixture]: result = httpx.post( f"{URL}/api/v1/filament", json={ - "name": "Filament Y", + "name": "Filament Z", "material": "PETG", "price": 200, "density": 1.25, @@ -72,24 +77,48 @@ def filaments(random_vendor_mod: dict[str, Any]) -> Iterable[Fixture]: result.raise_for_status() filament_3 = result.json() + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "density": 1.25, + "diameter": 1.75, + }, + ) + result.raise_for_status() + filament_4 = result.json() + + result = httpx.post( + f"{URL}/api/v1/filament", + json={ + "vendor_id": random_empty_vendor_mod["id"], + "density": 1.5, + "diameter": 1.75, + }, + ) + result.raise_for_status() + filament_5 = result.json() + added_filaments_by_id = { filament_1["id"]: filament_1, filament_2["id"]: filament_2, filament_3["id"]: filament_3, + filament_4["id"]: filament_4, + filament_5["id"]: filament_5, } yield Fixture( - filaments=[filament_1, filament_2, filament_3], + filaments=[filament_1, filament_2, filament_3, filament_4, filament_5], filaments_by_id=added_filaments_by_id, ) httpx.delete(f"{URL}/api/v1/filament/{filament_1['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament_2['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/filament/{filament_3['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_4['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/filament/{filament_5['id']}").raise_for_status() def test_find_all_filaments(filaments: Fixture): - """Test finding filaments from the database.""" # Execute result = httpx.get( f"{URL}/api/v1/filament", @@ -98,13 +127,138 @@ def test_find_all_filaments(filaments: Fixture): # Verify filaments_result = result.json() - assert len(filaments_result) == 3 - for filament in filaments_result: - assert filament == filaments.filaments_by_id[filament["id"]] + assert filament_lists_equal(filaments_result, filaments.filaments) + + +def test_find_all_filaments_sort_asc(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == len(filaments.filaments) + assert filaments_result[0] == filaments.filaments[0] + + +def test_find_all_filaments_sort_desc(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:desc") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == len(filaments.filaments) + assert filaments_result[-1] == filaments.filaments[0] + + +def test_find_all_filaments_sort_multiple(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=density:desc,id:asc") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == len(filaments.filaments) + assert filaments_result == [ + filaments.filaments[4], + filaments.filaments[0], + filaments.filaments[1], + filaments.filaments[2], + filaments.filaments[3], + ] + + +def test_find_all_filaments_limit_asc(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc&limit=2") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == 2 + assert filaments_result == [filaments.filaments[0], filaments.filaments[1]] + + +def test_find_all_filaments_limit_desc(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:desc&limit=2") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == 2 + assert filaments_result == [filaments.filaments[-1], filaments.filaments[-2]] + + +def test_find_all_filaments_limit_asc_offset(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc&limit=2&offset=1") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == 2 + assert filaments_result == [filaments.filaments[1], filaments.filaments[2]] + + +def test_find_all_filaments_limit_desc_offset(filaments: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:desc&limit=2&offset=1") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == 2 + assert filaments_result == [filaments.filaments[-2], filaments.filaments[-3]] + + +def test_find_all_filaments_limit_asc_offset_outside_range(filaments: Fixture): # noqa: ARG001 + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort=id:asc&limit=2&offset=100") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == 0 + + +@pytest.mark.parametrize( + "field_name", + [ + "id", + "registered", + "name", + "vendor_id", + "material", + "price", + "density", + "diameter", + "weight", + "spool_weight", + "article_number", + "comment", + "settings_extruder_temp", + "settings_bed_temp", + "color_hex", + "vendor.id", + "vendor.registered", + "vendor.name", + "vendor.comment", + ], +) +def test_find_all_filaments_sort_fields(filaments: Fixture, field_name: str): + """Test sorting by all fields.""" + # Execute + result = httpx.get(f"{URL}/api/v1/filament?sort={field_name}:asc") + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert len(filaments_result) == len(filaments.filaments) def test_find_filaments_by_name(filaments: Fixture): - """Test finding filaments from the database.""" # Execute result = httpx.get( f"{URL}/api/v1/filament", @@ -114,12 +268,23 @@ def test_find_filaments_by_name(filaments: Fixture): # Verify filaments_result = result.json() - assert len(filaments_result) == 1 assert filaments_result[0] == filaments.filaments[0] +def test_find_filaments_by_empty_name(filaments: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/filament", + params={"name": ""}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filament_lists_equal(filaments_result, filaments.filaments[3:]) + + def test_find_filaments_by_material(filaments: Fixture): - """Test finding filaments from the database.""" # Execute result = httpx.get( f"{URL}/api/v1/filament", @@ -129,13 +294,24 @@ def test_find_filaments_by_material(filaments: Fixture): # Verify filaments_result = result.json() - assert len(filaments_result) == 1 assert filaments_result[0] == filaments.filaments[1] +def test_find_filaments_by_empty_material(filaments: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/filament", + params={"material": ""}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filament_lists_equal(filaments_result, filaments.filaments[3:]) + + @pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str): - """Test finding filaments from the database.""" # Execute result = httpx.get( f"{URL}/api/v1/filament", @@ -145,14 +321,24 @@ def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str): # Verify filaments_result = result.json() - assert len(filaments_result) == 2 - for filament in filaments_result: - assert filament == filaments.filaments_by_id[filament["id"]] + assert filament_lists_equal(filaments_result, filaments.filaments[:2]) + + +def test_find_filaments_by_empty_vendor_id(filaments: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/filament", + params={"vendor.id": -1}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filament_lists_equal(filaments_result, filaments.filaments[2:4]) @pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) def test_find_filaments_by_vendor_name(filaments: Fixture, field_name: str): - """Test finding filaments from the database.""" # Execute result = httpx.get( f"{URL}/api/v1/filament", @@ -162,13 +348,23 @@ def test_find_filaments_by_vendor_name(filaments: Fixture, field_name: str): # Verify filaments_result = result.json() - assert len(filaments_result) == 2 - for filament in filaments_result: - assert filament == filaments.filaments_by_id[filament["id"]] + assert filament_lists_equal(filaments_result, filaments.filaments[:2]) + + +def test_find_filaments_by_empty_vendor_name(filaments: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/filament", + params={"vendor.name": ""}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filaments_result == [filaments.filaments[4]] def test_find_filaments_by_article_number(filaments: Fixture): - """Test finding filaments from the database.""" # Execute result = httpx.get( f"{URL}/api/v1/filament", @@ -178,5 +374,17 @@ def test_find_filaments_by_article_number(filaments: Fixture): # Verify filaments_result = result.json() - assert len(filaments_result) == 1 assert filaments_result[0] == filaments.filaments[1] + + +def test_find_filaments_by_empty_article_number(filaments: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/filament", + params={"article_number": ""}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filament_lists_equal(filaments_result, filaments.filaments[3:]) diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 84544df..993a0a2 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -68,6 +68,7 @@ def spools( f"{URL}/api/v1/spool", json={ "filament_id": random_empty_filament_mod["id"], + "used_weight": 1000, }, ) result.raise_for_status() @@ -134,26 +135,37 @@ def test_find_all_spools_including_archived(spools: Fixture): def test_find_all_spools_sort_asc(spools: Fixture): # Execute - result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc") + result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&allow_archived=true") result.raise_for_status() # Verify spools_result = result.json() - assert len(spools_result) == 4 + assert len(spools_result) == len(spools.spools) assert spools_result[0] == spools.spools[0] def test_find_all_spools_sort_desc(spools: Fixture): # Execute - result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc") + result = httpx.get(f"{URL}/api/v1/spool?sort=id:desc&allow_archived=true") result.raise_for_status() # Verify spools_result = result.json() - assert len(spools_result) == 4 + assert len(spools_result) == len(spools.spools) assert spools_result[-1] == spools.spools[0] +def test_find_all_spools_sort_multiple(spools: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/spool?sort=used_weight:desc,id:asc&allow_archived=true") + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == len(spools.spools) + assert spools_result == [spools.spools[3], spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4]] + + def test_find_all_spools_limit_asc(spools: Fixture): # Execute result = httpx.get(f"{URL}/api/v1/spool?sort=id:asc&limit=2") @@ -242,15 +254,15 @@ def test_find_all_spools_limit_asc_offset_outside_range(spools: Fixture): # noq "filament.vendor.comment", ], ) -def test_find_all_spools_sort_fields(spools: Fixture, field_name: str): # noqa: ARG001 +def test_find_all_spools_sort_fields(spools: Fixture, field_name: str): """Test sorting by all fields.""" # Execute - result = httpx.get(f"{URL}/api/v1/spool?sort={field_name}:asc") + result = httpx.get(f"{URL}/api/v1/spool?sort={field_name}:asc&allow_archived=true") result.raise_for_status() # Verify spools_result = result.json() - assert len(spools_result) == 4 + assert len(spools_result) == len(spools.spools) @pytest.mark.parametrize("field_name", ["filament_name", "filament.name"]) From 0f58ddfd88e56d0c9532e2f9b700fc279ee13041 Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 21:40:07 +0200 Subject: [PATCH 21/31] Modernized vendor tests --- tests_integration/tests/filament/test_find.py | 10 -- tests_integration/tests/spool/test_find.py | 10 -- tests_integration/tests/vendor/test_find.py | 91 ++++++++++--------- 3 files changed, 47 insertions(+), 64 deletions(-) diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index 61f1c83..c4b0194 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -18,7 +18,6 @@ def filament_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any] @dataclass class Fixture: filaments: list[dict[str, Any]] - filaments_by_id: dict[str, dict[str, Any]] @pytest.fixture(scope="module") @@ -98,17 +97,8 @@ def filaments(random_vendor_mod: dict[str, Any], random_empty_vendor_mod: dict[s result.raise_for_status() filament_5 = result.json() - added_filaments_by_id = { - filament_1["id"]: filament_1, - filament_2["id"]: filament_2, - filament_3["id"]: filament_3, - filament_4["id"]: filament_4, - filament_5["id"]: filament_5, - } - yield Fixture( filaments=[filament_1, filament_2, filament_3, filament_4, filament_5], - filaments_by_id=added_filaments_by_id, ) httpx.delete(f"{URL}/api/v1/filament/{filament_1['id']}").raise_for_status() diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 993a0a2..ae33731 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -18,7 +18,6 @@ def spool_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) @dataclass class Fixture: spools: list[dict[str, Any]] - spools_by_id: dict[str, dict[str, Any]] filament: dict[str, Any] @@ -83,17 +82,8 @@ def spools( result.raise_for_status() spool_5 = result.json() - added_spools_by_id = { - spool_1["id"]: spool_1, - spool_2["id"]: spool_2, - spool_3["id"]: spool_3, - spool_4["id"]: spool_4, - spool_5["id"]: spool_5, - } - yield Fixture( spools=[spool_1, spool_2, spool_3, spool_4, spool_5], - spools_by_id=added_spools_by_id, filament=random_filament_mod, ) diff --git a/tests_integration/tests/vendor/test_find.py b/tests_integration/tests/vendor/test_find.py index e8aac2e..a56988a 100644 --- a/tests_integration/tests/vendor/test_find.py +++ b/tests_integration/tests/vendor/test_find.py @@ -1,67 +1,70 @@ """Integration tests for the Vendor API endpoint.""" +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + import httpx +import pytest URL = "http://spoolman:8000" -def test_find_vendors(): - """Test finding vendors from the database.""" - # Setup - name_1 = "John" - comment_1 = "abcdefghåäö" +def vendor_lists_equal(a: Iterable[dict[str, Any]], b: Iterable[dict[str, Any]]) -> bool: + """Compare two lists of vendors where the order of the vendors is not guaranteed.""" + return sorted(a, key=lambda x: x["id"]) == sorted(b, key=lambda x: x["id"]) + + +@dataclass +class Fixture: + vendors: list[dict[str, Any]] + + +@pytest.fixture(scope="module") +def vendors() -> Iterable[Fixture]: + """Add some vendors to the database.""" result = httpx.post( f"{URL}/api/v1/vendor", - json={"name": name_1, "comment": comment_1}, + json={"name": "John", "comment": "abcdefghåäö"}, ) result.raise_for_status() vendor_1 = result.json() - name_2 = "Stan" - comment_2 = "gfdadfg" result = httpx.post( f"{URL}/api/v1/vendor", - json={"name": name_2, "comment": comment_2}, + json={"name": "Stan", "comment": "gfdadfg"}, ) result.raise_for_status() vendor_2 = result.json() - added_vendors_by_id = { - vendor_1["id"]: vendor_1, - vendor_2["id"]: vendor_2, - } - - # Execute - Find all vendors - result = httpx.get( - f"{URL}/api/v1/vendor", + yield Fixture( + vendors=[vendor_1, vendor_2], ) - result.raise_for_status() - # Verify - vendors = result.json() - assert len(vendors) == 2 - - for vendor in vendors: - assert vendor == added_vendors_by_id[vendor["id"]] - - # Execute - Find a specific vendor - result = httpx.get( - f"{URL}/api/v1/vendor", - params={"name": name_1}, - ) - result.raise_for_status() - - # Verify - vendors = result.json() - assert len(vendors) == 1 - - vendor = vendors[0] - - assert vendor["name"] == name_1 - assert vendor["comment"] == comment_1 - assert vendor["id"] == vendor_1["id"] - assert vendor["registered"] == vendor_1["registered"] - - # Clean up httpx.delete(f"{URL}/api/v1/vendor/{vendor_1['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor_2['id']}").raise_for_status() + + +def test_find_all_vendors(vendors: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/vendor", + ) + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert vendor_lists_equal(vendors_result, vendors.vendors) + + +def test_find_vendors_by_name(vendors: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/vendor", + params={"name": vendors.vendors[0]["name"]}, + ) + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert vendors_result == [vendors.vendors[0]] From 4e0851e7c33c3830609a2c54d04fd79003dfce8a Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 21:43:52 +0200 Subject: [PATCH 22/31] Added full tests for vendor --- tests_integration/tests/vendor/test_find.py | 119 +++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/tests_integration/tests/vendor/test_find.py b/tests_integration/tests/vendor/test_find.py index a56988a..3506f6a 100644 --- a/tests_integration/tests/vendor/test_find.py +++ b/tests_integration/tests/vendor/test_find.py @@ -37,12 +37,20 @@ def vendors() -> Iterable[Fixture]: result.raise_for_status() vendor_2 = result.json() + result = httpx.post( + f"{URL}/api/v1/vendor", + json={"name": ""}, + ) + result.raise_for_status() + vendor_3 = result.json() + yield Fixture( - vendors=[vendor_1, vendor_2], + vendors=[vendor_1, vendor_2, vendor_3], ) httpx.delete(f"{URL}/api/v1/vendor/{vendor_1['id']}").raise_for_status() httpx.delete(f"{URL}/api/v1/vendor/{vendor_2['id']}").raise_for_status() + httpx.delete(f"{URL}/api/v1/vendor/{vendor_3['id']}").raise_for_status() def test_find_all_vendors(vendors: Fixture): @@ -57,6 +65,102 @@ def test_find_all_vendors(vendors: Fixture): assert vendor_lists_equal(vendors_result, vendors.vendors) +def test_find_all_vendors_sort_asc(vendors: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == len(vendors.vendors) + assert vendors_result[0] == vendors.vendors[0] + + +def test_find_all_vendors_sort_desc(vendors: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:desc") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == len(vendors.vendors) + assert vendors_result[-1] == vendors.vendors[0] + + +def test_find_all_vendors_limit_asc(vendors: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc&limit=2") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == 2 + assert vendors_result == [vendors.vendors[0], vendors.vendors[1]] + + +def test_find_all_vendors_limit_desc(vendors: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:desc&limit=2") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == 2 + assert vendors_result == [vendors.vendors[-1], vendors.vendors[-2]] + + +def test_find_all_vendors_limit_asc_offset(vendors: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc&limit=2&offset=1") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == 2 + assert vendors_result == [vendors.vendors[1], vendors.vendors[2]] + + +def test_find_all_vendors_limit_desc_offset(vendors: Fixture): + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:desc&limit=2&offset=1") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == 2 + assert vendors_result == [vendors.vendors[-2], vendors.vendors[-3]] + + +def test_find_all_vendors_limit_asc_offset_outside_range(vendors: Fixture): # noqa: ARG001 + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort=id:asc&limit=2&offset=100") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == 0 + + +@pytest.mark.parametrize( + "field_name", + [ + "id", + "registered", + "name", + "comment", + ], +) +def test_find_all_vendors_sort_fields(vendors: Fixture, field_name: str): + """Test sorting by all fields.""" + # Execute + result = httpx.get(f"{URL}/api/v1/vendor?sort={field_name}:asc") + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert len(vendors_result) == len(vendors.vendors) + + def test_find_vendors_by_name(vendors: Fixture): # Execute result = httpx.get( @@ -68,3 +172,16 @@ def test_find_vendors_by_name(vendors: Fixture): # Verify vendors_result = result.json() assert vendors_result == [vendors.vendors[0]] + + +def test_find_vendors_by_empty_name(vendors: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/vendor", + params={"name": ""}, + ) + result.raise_for_status() + + # Verify + vendors_result = result.json() + assert vendors_result == [vendors.vendors[2]] From a4d14c5b52b412ca975a0cbe86ccb3d698fa781e Mon Sep 17 00:00:00 2001 From: Donkie Date: Fri, 8 Sep 2023 22:01:49 +0200 Subject: [PATCH 23/31] Added X-Total-Count test asserts Fixed issue with incorrect total count for vendors --- spoolman/database/filament.py | 2 +- spoolman/database/spool.py | 2 +- spoolman/database/vendor.py | 2 +- tests_integration/tests/filament/test_find.py | 5 +++++ tests_integration/tests/spool/test_find.py | 5 +++++ tests_integration/tests/vendor/test_find.py | 5 +++++ 6 files changed, 18 insertions(+), 3 deletions(-) diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 8ce8384..ae8c26e 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -107,7 +107,7 @@ async def find( total_count = None if limit is not None: - total_count_stmt = stmt.with_only_columns(func.count()) + total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True) total_count = (await db.execute(total_count_stmt)).scalar() stmt = stmt.offset(offset).limit(limit) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 08d97f7..e04538e 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -132,7 +132,7 @@ async def find( total_count = None if limit is not None: - total_count_stmt = stmt.with_only_columns(func.count()) + total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True) total_count = (await db.execute(total_count_stmt)).scalar() stmt = stmt.offset(offset).limit(limit) diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index 120c061..d0e6039 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -53,7 +53,7 @@ async def find( total_count = None if limit is not None: - total_count_stmt = stmt.with_only_columns(func.count()) + total_count_stmt = stmt.with_only_columns(func.count(), maintain_column_froms=True) total_count = (await db.execute(total_count_stmt)).scalar() stmt = stmt.offset(offset).limit(limit) diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index c4b0194..4f34fa9 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -165,6 +165,7 @@ def test_find_all_filaments_limit_asc(filaments: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" filaments_result = result.json() assert len(filaments_result) == 2 assert filaments_result == [filaments.filaments[0], filaments.filaments[1]] @@ -176,6 +177,7 @@ def test_find_all_filaments_limit_desc(filaments: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" filaments_result = result.json() assert len(filaments_result) == 2 assert filaments_result == [filaments.filaments[-1], filaments.filaments[-2]] @@ -187,6 +189,7 @@ def test_find_all_filaments_limit_asc_offset(filaments: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" filaments_result = result.json() assert len(filaments_result) == 2 assert filaments_result == [filaments.filaments[1], filaments.filaments[2]] @@ -198,6 +201,7 @@ def test_find_all_filaments_limit_desc_offset(filaments: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" filaments_result = result.json() assert len(filaments_result) == 2 assert filaments_result == [filaments.filaments[-2], filaments.filaments[-3]] @@ -209,6 +213,7 @@ def test_find_all_filaments_limit_asc_offset_outside_range(filaments: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" filaments_result = result.json() assert len(filaments_result) == 0 diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index ae33731..34d3738 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -162,6 +162,7 @@ def test_find_all_spools_limit_asc(spools: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "4" spools_result = result.json() assert len(spools_result) == 2 assert spools_result == [spools.spools[0], spools.spools[1]] @@ -173,6 +174,7 @@ def test_find_all_spools_limit_desc(spools: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "4" spools_result = result.json() assert len(spools_result) == 2 assert spools_result == [spools.spools[-1], spools.spools[-2]] @@ -184,6 +186,7 @@ def test_find_all_spools_limit_asc_offset(spools: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" spools_result = result.json() assert len(spools_result) == 2 assert spools_result == [spools.spools[1], spools.spools[2]] @@ -195,6 +198,7 @@ def test_find_all_spools_limit_desc_offset(spools: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "5" spools_result = result.json() assert len(spools_result) == 2 assert spools_result == [spools.spools[-2], spools.spools[-3]] @@ -206,6 +210,7 @@ def test_find_all_spools_limit_asc_offset_outside_range(spools: Fixture): # noq result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "4" spools_result = result.json() assert len(spools_result) == 0 diff --git a/tests_integration/tests/vendor/test_find.py b/tests_integration/tests/vendor/test_find.py index 3506f6a..49dff8a 100644 --- a/tests_integration/tests/vendor/test_find.py +++ b/tests_integration/tests/vendor/test_find.py @@ -93,6 +93,7 @@ def test_find_all_vendors_limit_asc(vendors: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "3" vendors_result = result.json() assert len(vendors_result) == 2 assert vendors_result == [vendors.vendors[0], vendors.vendors[1]] @@ -104,6 +105,7 @@ def test_find_all_vendors_limit_desc(vendors: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "3" vendors_result = result.json() assert len(vendors_result) == 2 assert vendors_result == [vendors.vendors[-1], vendors.vendors[-2]] @@ -115,6 +117,7 @@ def test_find_all_vendors_limit_asc_offset(vendors: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "3" vendors_result = result.json() assert len(vendors_result) == 2 assert vendors_result == [vendors.vendors[1], vendors.vendors[2]] @@ -126,6 +129,7 @@ def test_find_all_vendors_limit_desc_offset(vendors: Fixture): result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "3" vendors_result = result.json() assert len(vendors_result) == 2 assert vendors_result == [vendors.vendors[-2], vendors.vendors[-3]] @@ -137,6 +141,7 @@ def test_find_all_vendors_limit_asc_offset_outside_range(vendors: Fixture): # n result.raise_for_status() # Verify + assert result.headers["X-Total-Count"] == "3" vendors_result = result.json() assert len(vendors_result) == 0 From 841cc8b6dfd74426fccf15eebe5bd2c123e5c52b Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 9 Sep 2023 09:37:59 +0200 Subject: [PATCH 24/31] Added sort by remaining weight --- spoolman/database/spool.py | 7 ++++++- tests_integration/tests/spool/test_find.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index e04538e..7d3a208 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -139,7 +139,12 @@ async def find( if sort_by is not None: for fieldstr, order in sort_by.items(): - field = parse_nested_field(models.Spool, fieldstr) + if fieldstr == "remaining_weight": + stmt = stmt.add_columns((models.Filament.weight - models.Spool.used_weight).label("remaining_weight")) + field = sqlalchemy.column("remaining_weight") + else: + field = parse_nested_field(models.Spool, fieldstr) + if order == SortOrder.ASC: stmt = stmt.order_by(field.asc()) elif order == SortOrder.DESC: diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 34d3738..eef3e98 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -224,6 +224,7 @@ def test_find_all_spools_limit_asc_offset_outside_range(spools: Fixture): # noq "last_used", "filament_id", "used_weight", + "remaining_weight", "location", "lot_nr", "comment", From 6d68a403132586f65767370d6b79cc35d64cc759 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 9 Sep 2023 09:48:01 +0200 Subject: [PATCH 25/31] Added support for multiple string where's --- spoolman/database/utils.py | 4 ++-- tests_integration/tests/filament/test_find.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 54fb3d8..3cab16f 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -47,7 +47,7 @@ def add_where_clause_str_opt( if len(value) == 0: stmt = stmt.where(sqlalchemy.or_(field.is_(None), field == "")) else: - stmt = stmt.where(field.ilike(f"%{value}%")) + stmt = stmt.where(sqlalchemy.or_(*(field.ilike(f"%{value}%") for value in value.split(",")))) return stmt @@ -61,7 +61,7 @@ def add_where_clause_str( if len(value) == 0: stmt = stmt.where(field == "") else: - stmt = stmt.where(field.ilike(f"%{value}%")) + stmt = stmt.where(sqlalchemy.or_(*(field.ilike(f"%{value}%") for value in value.split(",")))) return stmt diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index 4f34fa9..4f96b4b 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -289,7 +289,20 @@ def test_find_filaments_by_material(filaments: Fixture): # Verify filaments_result = result.json() - assert filaments_result[0] == filaments.filaments[1] + assert filaments_result == [filaments.filaments[1]] + + +def test_find_filaments_by_multiple_materials(filaments: Fixture): + # Execute + result = httpx.get( + f"{URL}/api/v1/filament", + params={"material": "abs,pla"}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filament_lists_equal(filaments_result, filaments.filaments[:2]) def test_find_filaments_by_empty_material(filaments: Fixture): From 6ee3b8df7007b625e07ba41096cb9da2debce761 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 9 Sep 2023 10:18:26 +0200 Subject: [PATCH 26/31] Added support for multiple int where's --- spoolman/api/v1/filament.py | 42 ++++++++--- spoolman/api/v1/spool.py | 72 ++++++++++++++----- spoolman/api/v1/vendor.py | 2 +- spoolman/database/filament.py | 5 +- spoolman/database/spool.py | 7 +- spoolman/database/utils.py | 24 ++++--- tests_integration/tests/filament/test_find.py | 22 ++++++ tests_integration/tests/spool/test_find.py | 36 ++++++++++ 8 files changed, 168 insertions(+), 42 deletions(-) diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index be22675..0203240 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -119,42 +119,55 @@ async def find( alias="vendor_name", default=None, title="Vendor Name", - description="Partial case-insensitive search term for the filament vendor name.", + description=( + "Partial case-insensitive search term for the filament vendor name. " + "Separate multiple terms with a comma." + ), deprecated=True, ), - vendor_id_old: Optional[int] = Query( + vendor_id_old: Optional[str] = Query( alias="vendor_id", default=None, title="Vendor ID", - description="Match an exact vendor ID.", + description="Match an exact vendor ID. Separate multiple IDs with a comma.", deprecated=True, + examples=["1", "1,2"], ), vendor_name: Optional[str] = Query( alias="vendor.name", default=None, title="Vendor Name", - description="Partial case-insensitive search term for the filament vendor name.", + description=( + "Partial case-insensitive search term for the filament vendor name. " + "Separate multiple terms with a comma." + ), ), - vendor_id: Optional[int] = Query( + vendor_id: Optional[str] = Query( alias="vendor.id", default=None, title="Vendor ID", - description="Match an exact vendor ID.", + description="Match an exact vendor ID. Separate multiple IDs with a comma.", + examples=["1", "1,2"], ), name: Optional[str] = Query( default=None, title="Filament Name", - description="Partial case-insensitive search term for the filament name.", + description="Partial case-insensitive search term for the filament name. Separate multiple terms with a comma.", ), material: Optional[str] = Query( default=None, title="Filament Material", - description="Partial case-insensitive search term for the filament material.", + description=( + "Partial case-insensitive search term for the filament material. Separate multiple terms with a comma." + ), ), article_number: Optional[str] = Query( default=None, title="Filament Article Number", - description="Partial case-insensitive search term for the filament article number.", + description=( + "Partial case-insensitive search term for the filament article number. " + "Separate multiple terms with a comma." + ), ), sort: Optional[str] = Query( default=None, @@ -181,10 +194,19 @@ async def find( field, direction = sort_item.split(":") sort_by[field] = SortOrder[direction.upper()] + vendor_id = vendor_id if vendor_id is not None else vendor_id_old + if vendor_id is not None: + try: + vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")] + except ValueError as e: + raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e + else: + vendor_ids = None + db_items, total_count = await filament.find( db=db, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, - vendor_id=vendor_id if vendor_id is not None else vendor_id_old, + vendor_id=vendor_ids, name=name, material=material, article_number=article_number, diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 12b8081..4107993 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -77,76 +77,92 @@ async def find( alias="filament_name", default=None, title="Filament Name", - description="Partial case-insensitive search term for the filament name.", + description="Partial case-insensitive search term for the filament name. Separate multiple terms with a comma.", deprecated=True, ), - filament_id_old: Optional[int] = Query( + filament_id_old: Optional[str] = Query( alias="filament_id", default=None, title="Filament ID", - description="Match an exact filament ID.", + description="Match an exact filament ID. Separate multiple IDs with a comma.", deprecated=True, + examples=["1", "1,2"], ), filament_material_old: Optional[str] = Query( alias="filament_material", default=None, title="Filament Material", - description="Partial case-insensitive search term for the filament material.", + description=( + "Partial case-insensitive search term for the filament material. Separate multiple terms with a comma." + ), deprecated=True, ), vendor_name_old: Optional[str] = Query( alias="vendor_name", default=None, title="Vendor Name", - description="Partial case-insensitive search term for the filament vendor name.", + description=( + "Partial case-insensitive search term for the filament vendor name. Separate multiple terms with a comma." + ), deprecated=True, ), - vendor_id_old: Optional[int] = Query( + vendor_id_old: Optional[str] = Query( alias="vendor_id", default=None, title="Vendor ID", - description="Match an exact vendor ID.", + description="Match an exact vendor ID. Separate multiple IDs with a comma.", deprecated=True, + examples=["1", "1,2"], ), filament_name: Optional[str] = Query( alias="filament.name", default=None, title="Filament Name", - description="Partial case-insensitive search term for the filament name.", + description="Partial case-insensitive search term for the filament name. Separate multiple terms with a comma.", ), - filament_id: Optional[int] = Query( + filament_id: Optional[str] = Query( alias="filament.id", default=None, title="Filament ID", - description="Match an exact filament ID.", + description="Match an exact filament ID. Separate multiple IDs with a comma.", + examples=["1", "1,2"], ), filament_material: Optional[str] = Query( alias="filament.material", default=None, title="Filament Material", - description="Partial case-insensitive search term for the filament material.", + description=( + "Partial case-insensitive search term for the filament material. Separate multiple terms with a comma." + ), ), vendor_name: Optional[str] = Query( alias="vendor.name", default=None, title="Vendor Name", - description="Partial case-insensitive search term for the filament vendor name.", + description=( + "Partial case-insensitive search term for the filament vendor name. Separate multiple terms with a comma." + ), ), - vendor_id: Optional[int] = Query( + vendor_id: Optional[str] = Query( alias="vendor.id", default=None, title="Vendor ID", - description="Match an exact vendor ID.", + description="Match an exact vendor ID. Separate multiple IDs with a comma.", + examples=["1", "1,2"], ), location: Optional[str] = Query( default=None, title="Location", - description="Partial case-insensitive search term for the spool location.", + description=( + "Partial case-insensitive search term for the spool location. Separate multiple terms with a comma." + ), ), lot_nr: Optional[str] = Query( default=None, title="Lot/Batch Number", - description="Partial case-insensitive search term for the spool lot number.", + description=( + "Partial case-insensitive search term for the spool lot number. Separate multiple terms with a comma." + ), ), allow_archived: bool = Query( default=False, @@ -178,13 +194,33 @@ async def find( field, direction = sort_item.split(":") sort_by[field] = SortOrder[direction.upper()] + filament_id = filament_id if filament_id is not None else filament_id_old + if filament_id is not None: + try: + filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")] + except ValueError as e: + raise RequestValidationError( + [ErrorWrapper(ValueError("Invalid filament_id"), ("query", "filament_id"))], + ) from e + else: + filament_ids = None + + vendor_id = vendor_id if vendor_id is not None else vendor_id_old + if vendor_id is not None: + try: + vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")] + except ValueError as e: + raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e + else: + vendor_ids = None + db_items, total_count = await spool.find( db=db, filament_name=filament_name if filament_name is not None else filament_name_old, - filament_id=filament_id if filament_id is not None else filament_id_old, + filament_id=filament_ids, filament_material=filament_material if filament_material is not None else filament_material_old, vendor_name=vendor_name if vendor_name is not None else vendor_name_old, - vendor_id=vendor_id if vendor_id is not None else vendor_id_old, + vendor_id=vendor_ids, location=location, lot_nr=lot_nr, allow_archived=allow_archived, diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index 6722c00..4a69640 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -53,7 +53,7 @@ async def find( name: Optional[str] = Query( default=None, title="Vendor Name", - description="Partial case-insensitive search term for the vendor name.", + description="Partial case-insensitive search term for the vendor name. Separate multiple terms with a comma.", ), sort: Optional[str] = Query( default=None, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index ae8c26e..59ef66b 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -1,7 +1,8 @@ """Helper functions for interacting with filament database objects.""" import logging -from typing import Optional +from collections.abc import Sequence +from typing import Optional, Union from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError @@ -77,7 +78,7 @@ async def find( *, db: AsyncSession, vendor_name: Optional[str] = None, - vendor_id: Optional[int] = None, + vendor_id: Optional[Union[int, Sequence[int]]] = None, name: Optional[str] = None, material: Optional[str] = None, article_number: Optional[str] = None, diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 7d3a208..6eb4ae7 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -1,7 +1,8 @@ """Helper functions for interacting with spool database objects.""" +from collections.abc import Sequence from datetime import datetime, timezone -from typing import Optional +from typing import Optional, Union import sqlalchemy from sqlalchemy import case, func @@ -87,10 +88,10 @@ async def find( *, db: AsyncSession, filament_name: Optional[str] = None, - filament_id: Optional[int] = None, + filament_id: Optional[Union[int, Sequence[int]]] = None, filament_material: Optional[str] = None, vendor_name: Optional[str] = None, - vendor_id: Optional[int] = None, + vendor_id: Optional[Union[int, Sequence[int]]] = None, location: Optional[str] = None, lot_nr: Optional[str] = None, allow_archived: bool = False, diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py index 3cab16f..93d0525 100644 --- a/spoolman/database/utils.py +++ b/spoolman/database/utils.py @@ -1,7 +1,8 @@ """Utility functions for the database module.""" +from collections.abc import Sequence from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Union import sqlalchemy from sqlalchemy import Select @@ -68,23 +69,30 @@ def add_where_clause_str( def add_where_clause_int( stmt: Select, field: attributes.InstrumentedAttribute[int], - value: Optional[int], + value: Optional[Union[int, Sequence[int]]], ) -> Select: """Add a where clause to a select statement for a field.""" if value is not None: - stmt = stmt.where(field == value) + if isinstance(value, int): + value = [value] + stmt = stmt.where(field.in_(value)) return stmt def add_where_clause_int_opt( stmt: Select, field: attributes.InstrumentedAttribute[Optional[int]], - value: Optional[int], + value: Optional[Union[int, Sequence[int]]], ) -> Select: """Add a where clause to a select statement for a field.""" if value is not None: - if value == -1: - stmt = stmt.where(field.is_(None)) - else: - stmt = stmt.where(field == value) + if isinstance(value, int): + value = [value] + statements = [] + for value_part in value: + if value_part == -1: + statements.append(field.is_(None)) + else: + statements.append(field == value_part) + stmt = stmt.where(sqlalchemy.or_(*statements)) return stmt diff --git a/tests_integration/tests/filament/test_find.py b/tests_integration/tests/filament/test_find.py index 4f96b4b..a5920fd 100644 --- a/tests_integration/tests/filament/test_find.py +++ b/tests_integration/tests/filament/test_find.py @@ -332,6 +332,28 @@ def test_find_filaments_by_vendor_id(filaments: Fixture, field_name: str): assert filament_lists_equal(filaments_result, filaments.filaments[:2]) +def test_find_filaments_by_multiple_vendor_ids(filaments: Fixture): + # Execute + vendor_1 = filaments.filaments[0]["vendor"]["id"] + vendor_2 = filaments.filaments[4]["vendor"]["id"] + result = httpx.get( + f"{URL}/api/v1/filament", + params={"vendor.id": f"{vendor_1},{vendor_2}"}, + ) + result.raise_for_status() + + # Verify + filaments_result = result.json() + assert filament_lists_equal( + filaments_result, + [ + filaments.filaments[0], + filaments.filaments[1], + filaments.filaments[4], + ], + ) + + def test_find_filaments_by_empty_vendor_id(filaments: Fixture): # Execute result = httpx.get( diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index eef3e98..0d23e18 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -302,6 +302,22 @@ def test_find_spools_by_filament_id(spools: Fixture, field_name: str): assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) +def test_find_spools_by_multiple_filament_ids(spools: Fixture): + # Execute + filament_1 = spools.spools[0]["filament"]["id"] + filament_2 = spools.spools[3]["filament"]["id"] + + result = httpx.get( + f"{URL}/api/v1/spool", + params={"filament.id": f"{filament_1},{filament_2}"}, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[3])) + + @pytest.mark.parametrize("field_name", ["filament_material", "filament.material"]) def test_find_spools_by_filament_material(spools: Fixture, field_name: str): # Execute @@ -370,6 +386,26 @@ def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str): assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1])) +def test_find_spools_by_multiple_vendor_ids(spools: Fixture): + # Execute + vendor_1 = spools.spools[0]["filament"]["vendor"]["id"] + vendor_2 = spools.spools[4]["filament"]["vendor"]["id"] + + result = httpx.get( + f"{URL}/api/v1/spool", + params={ + "vendor.id": f"{vendor_1},{vendor_2}", + "allow_archived": True, + }, + ) + result.raise_for_status() + + # Verify + spools_result = result.json() + assert len(spools_result) == 4 + assert spool_lists_equal(spools_result, (spools.spools[0], spools.spools[1], spools.spools[2], spools.spools[4])) + + def test_find_spools_by_empty_filament_vendor_id(spools: Fixture): # Execute result = httpx.get( From 6f94c170504984bf781fcf6ad5feecc7d191528e Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 9 Sep 2023 10:20:56 +0200 Subject: [PATCH 27/31] Renamed vendor.x to filament.vendor.x in spool API --- spoolman/api/v1/spool.py | 20 ++++++++++---------- tests_integration/tests/spool/test_find.py | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 4107993..269c301 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -135,16 +135,16 @@ async def find( "Partial case-insensitive search term for the filament material. Separate multiple terms with a comma." ), ), - vendor_name: Optional[str] = Query( - alias="vendor.name", + filament_vendor_name: Optional[str] = Query( + alias="filament.vendor.name", default=None, title="Vendor Name", description=( "Partial case-insensitive search term for the filament vendor name. Separate multiple terms with a comma." ), ), - vendor_id: Optional[str] = Query( - alias="vendor.id", + filament_vendor_id: Optional[str] = Query( + alias="filament.vendor.id", default=None, title="Vendor ID", description="Match an exact vendor ID. Separate multiple IDs with a comma.", @@ -205,22 +205,22 @@ async def find( else: filament_ids = None - vendor_id = vendor_id if vendor_id is not None else vendor_id_old - if vendor_id is not None: + filament_vendor_id = filament_vendor_id if filament_vendor_id is not None else vendor_id_old + if filament_vendor_id is not None: try: - vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")] + filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")] except ValueError as e: raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e else: - vendor_ids = None + filament_vendor_ids = None db_items, total_count = await spool.find( db=db, filament_name=filament_name if filament_name is not None else filament_name_old, filament_id=filament_ids, filament_material=filament_material if filament_material is not None else filament_material_old, - vendor_name=vendor_name if vendor_name is not None else vendor_name_old, - vendor_id=vendor_ids, + vendor_name=filament_vendor_name if filament_vendor_name is not None else vendor_name_old, + vendor_id=filament_vendor_ids, location=location, lot_nr=lot_nr, allow_archived=allow_archived, diff --git a/tests_integration/tests/spool/test_find.py b/tests_integration/tests/spool/test_find.py index 0d23e18..7ddf553 100644 --- a/tests_integration/tests/spool/test_find.py +++ b/tests_integration/tests/spool/test_find.py @@ -345,7 +345,7 @@ def test_find_spools_by_empty_filament_material(spools: Fixture): assert spool_lists_equal(spools_result, (spools.spools[3], spools.spools[4])) -@pytest.mark.parametrize("field_name", ["vendor_name", "vendor.name"]) +@pytest.mark.parametrize("field_name", ["vendor_name", "filament.vendor.name"]) def test_find_spools_by_filament_vendor_name(spools: Fixture, field_name: str): # Execute result = httpx.get( @@ -363,7 +363,7 @@ def test_find_spools_by_empty_filament_vendor_name(spools: Fixture): # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"vendor.name": ""}, + params={"filament.vendor.name": ""}, ) result.raise_for_status() @@ -372,7 +372,7 @@ def test_find_spools_by_empty_filament_vendor_name(spools: Fixture): assert spools_result == [spools.spools[4]] -@pytest.mark.parametrize("field_name", ["vendor_id", "vendor.id"]) +@pytest.mark.parametrize("field_name", ["vendor_id", "filament.vendor.id"]) def test_find_spools_by_filament_vendor_id(spools: Fixture, field_name: str): # Execute result = httpx.get( @@ -394,7 +394,7 @@ def test_find_spools_by_multiple_vendor_ids(spools: Fixture): result = httpx.get( f"{URL}/api/v1/spool", params={ - "vendor.id": f"{vendor_1},{vendor_2}", + "filament.vendor.id": f"{vendor_1},{vendor_2}", "allow_archived": True, }, ) @@ -410,7 +410,7 @@ def test_find_spools_by_empty_filament_vendor_id(spools: Fixture): # Execute result = httpx.get( f"{URL}/api/v1/spool", - params={"vendor.id": -1}, + params={"filament.vendor.id": -1}, ) result.raise_for_status() From dc5e3a0fbd32f9a276e4fe3a347a7edaee440ac3 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 16 Sep 2023 10:56:27 +0200 Subject: [PATCH 28/31] Client: Filament name filtering/sorting now works --- client/src/components/column.tsx | 34 ++++++++++++++-------- client/src/components/otherModels.tsx | 25 +++++++++++----- client/src/components/spoolSelectModal.tsx | 19 +++++++----- client/src/pages/filaments/list.tsx | 6 +--- client/src/pages/spools/list.tsx | 26 +++++++---------- 5 files changed, 62 insertions(+), 48 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 04163ed..8ccd11c 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -18,6 +18,7 @@ dayjs.extend(utc); interface BaseColumnProps { id: keyof Obj & string; + dataId?: keyof Obj & string; i18ncat?: string; i18nkey?: string; dataSource: Obj[]; @@ -50,12 +51,15 @@ function Column(props: BaseColumnProps & FilteredColumnProps & CustomC dataIndex: props.id, title: t(props.i18nkey ?? `${props.i18ncat}.fields.${props.id}`), sorter: true, - sortOrder: getSortOrderForField(typedSorters, props.id), + sortOrder: getSortOrderForField(typedSorters, props.dataId ?? props.id), filterMultiple: props.allowMultipleFilters ?? true, }; if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; + if (props.dataId) { + columnProps.key = props.dataId; + } } if (props.render) { columnProps.render = props.render; @@ -80,7 +84,7 @@ export function RichColumn(props: BaseColumnProps) { } interface FilteredQueryColumnProps extends BaseColumnProps { - filterValueQuery: UseQueryResult; + filterValueQuery: UseQueryResult; allowMultipleFilters?: boolean; } @@ -90,10 +94,13 @@ export function FilteredQueryColumn(props: FilteredQueryColumnProps) { let filters: ColumnFilterItem[] = []; if (query.data) { filters = query.data.map((item) => { - return { - text: item, - value: item, - }; + if (typeof item === "string") { + return { + text: item, + value: item, + }; + } + return item; }); } filters.push({ @@ -102,7 +109,7 @@ export function FilteredQueryColumn(props: FilteredQueryColumnProps) { }); const typedFilters = typeFilters(props.tableState.filters); - const filteredValue = getFiltersForField(typedFilters, props.id); + const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id); return Column({ ...props, filters, filteredValue }); } @@ -160,10 +167,13 @@ export function SpoolIconColumn(props: SpoolIconColumnProps) { let filters: ColumnFilterItem[] = []; if (query.data) { filters = query.data.map((item) => { - return { - text: item, - value: item, - }; + if (typeof item === "string") { + return { + text: item, + value: item, + }; + } + return item; }); } filters.push({ @@ -172,7 +182,7 @@ export function SpoolIconColumn(props: SpoolIconColumnProps) { }); const typedFilters = typeFilters(props.tableState.filters); - const filteredValue = getFiltersForField(typedFilters, props.id); + const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id); return Column({ ...props, diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 178fbd0..19c01f5 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { IFilament } from "../pages/filaments/model"; import { IVendor } from "../pages/vendors/model"; +import { ColumnFilterItem } from "antd/es/table/interface"; -export function useSpoolmanFilamentFullNames() { +export function useSpoolmanFilamentFilter() { const apiEndpoint = import.meta.env.VITE_APIURL; - return useQuery({ + return useQuery({ queryKey: ["filaments"], queryFn: async () => { const response = await fetch(apiEndpoint + "/filament"); @@ -15,20 +16,28 @@ export function useSpoolmanFilamentFullNames() { }, select: (data) => { // Concatenate vendor name and filament name - let names = data + const names = data + // Remove empty names .filter((filament) => { return filament.name !== null && filament.name !== undefined && filament.name !== ""; }) + // Transform to ColumnFilterItem .map((filament) => { + let name = ""; if (filament.vendor?.name) { - return `${filament.vendor.name} - ${filament.name ?? ""}`; + name = `${filament.vendor.name} - ${filament.name ?? ""}`; } else { - return `${filament.name ?? ""}`; + name = `${filament.name ?? ""}`; } + return { + text: name, + value: filament.id, + }; }) - .sort(); - // Remove duplicates - names = [...new Set(names)]; + // Remove duplicates + .filter((item, index, self) => self.findIndex((t) => t.value === item.value) === index) + // Sort by name + .sort((a, b) => a.text.localeCompare(b.text)); return names; }, }); diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx index a944151..b5863e0 100644 --- a/client/src/components/spoolSelectModal.tsx +++ b/client/src/components/spoolSelectModal.tsx @@ -5,7 +5,7 @@ import { FilteredQueryColumn, SortedColumn, SpoolIconColumn } from "./column"; import { TableState } from "../utils/saveload"; import { useTable } from "@refinedev/antd"; import { t } from "i18next"; -import { useSpoolmanFilamentFullNames, useSpoolmanMaterials } from "./otherModels"; +import { useSpoolmanFilamentFilter, useSpoolmanMaterials } from "./otherModels"; interface Props { visible: boolean; @@ -15,7 +15,8 @@ interface Props { } interface ISpoolCollapsed extends ISpool { - "filament.name": string; + combined_name: string; + "filament.id": number; "filament.material"?: string; } @@ -37,10 +38,10 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC pageSize: 10, }, sorters: { - mode: "off", + mode: "server", }, filters: { - mode: "off", + mode: "server", }, }); @@ -63,7 +64,8 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC } return { ...element, - "filament.name": filament_name, + combined_name: filament_name, + "filament.id": element.filament.id, "filament.material": element.filament.material, }; }), @@ -128,12 +130,13 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC tableState, })} {SpoolIconColumn({ - id: "filament.name", - i18ncat: "spool", + id: "combined_name", + dataId: "filament.id", + i18nkey: "spool.fields.filament_name", color: (record: ISpoolCollapsed) => record.filament.color_hex, dataSource, tableState, - filterValueQuery: useSpoolmanFilamentFullNames(), + filterValueQuery: useSpoolmanFilamentFilter(), })} {FilteredQueryColumn({ id: "filament.material", diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx index 74893c9..7be2c0c 100644 --- a/client/src/pages/filaments/list.tsx +++ b/client/src/pages/filaments/list.tsx @@ -125,7 +125,7 @@ export const FilamentList: React.FC = () => { menu={{ items: allColumns.map((column_id) => ({ key: column_id, - label: t(`filament.fields.${column_id}`), + label: t(`filament.fields.${column_id.replace(".", "_")}`), })), selectedKeys: showColumns, selectable: true, @@ -159,7 +159,6 @@ export const FilamentList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanVendors(), - allowMultipleFilters: false, })} {SpoolIconColumn({ id: "name", @@ -168,7 +167,6 @@ export const FilamentList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanFilamentNames(), - allowMultipleFilters: false, })} {FilteredQueryColumn({ id: "material", @@ -176,7 +174,6 @@ export const FilamentList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanMaterials(), - allowMultipleFilters: false, })} {SortedColumn({ id: "price", @@ -222,7 +219,6 @@ export const FilamentList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanArticleNumbers(), - allowMultipleFilters: false, })} {NumberColumn({ id: "settings_extruder_temp", diff --git a/client/src/pages/spools/list.tsx b/client/src/pages/spools/list.tsx index 5b72bba..5fce1b9 100644 --- a/client/src/pages/spools/list.tsx +++ b/client/src/pages/spools/list.tsx @@ -18,7 +18,7 @@ import { import { setSpoolArchived } from "./functions"; import SelectAndPrint from "../../components/selectAndPrintDialog"; import { - useSpoolmanFilamentFullNames, + useSpoolmanFilamentFilter, useSpoolmanLocations, useSpoolmanLotNumbers, useSpoolmanMaterials, @@ -29,7 +29,8 @@ dayjs.extend(utc); const { confirm } = Modal; interface ISpoolCollapsed extends ISpool { - "filament.name": string; + combined_name: string; // Eg. "Prusa - PLA Red" + "filament.id": number; "filament.material"?: string; } @@ -71,7 +72,7 @@ export const SpoolList: React.FC = () => { // Create state for the columns to show const allColumns: (keyof ISpoolCollapsed & string)[] = [ "id", - "filament.name", + "combined_name", "filament.material", "used_weight", "remaining_weight", @@ -110,16 +111,14 @@ export const SpoolList: React.FC = () => { } return { ...element, - "filament.name": filament_name, + combined_name: filament_name, + "filament.id": element.filament.id, "filament.material": element.filament.material, }; }), [tableProps.dataSource] ); - // Filter and sort the dataSource - const filteredDataSource = dataSource; - // Function for opening an ant design modal that asks for confirmation for archiving a spool const archiveSpool = async (spool: ISpoolCollapsed, archive: boolean) => { await setSpoolArchived(spool, archive); @@ -182,7 +181,7 @@ export const SpoolList: React.FC = () => { menu={{ items: allColumns.map((column) => ({ key: column, - label: t(`spool.fields.${column}`), + label: t(`spool.fields.${column.replace(".", "_")}`), })), selectedKeys: showColumns, selectable: true, @@ -205,7 +204,7 @@ export const SpoolList: React.FC = () => { >
{ @@ -228,13 +227,13 @@ export const SpoolList: React.FC = () => { tableState, })} {SpoolIconColumn({ - id: "filament.name", + id: "combined_name", i18nkey: "spool.fields.filament_name", color: (record: ISpoolCollapsed) => record.filament.color_hex, dataSource, tableState, - filterValueQuery: useSpoolmanFilamentFullNames(), - allowMultipleFilters: false, + dataId: "filament.id", + filterValueQuery: useSpoolmanFilamentFilter(), })} {FilteredQueryColumn({ id: "filament.material", @@ -242,7 +241,6 @@ export const SpoolList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanMaterials(), - allowMultipleFilters: false, })} {NumberColumn({ id: "used_weight", @@ -284,7 +282,6 @@ export const SpoolList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanLocations(), - allowMultipleFilters: false, })} {FilteredQueryColumn({ id: "lot_nr", @@ -292,7 +289,6 @@ export const SpoolList: React.FC = () => { dataSource, tableState, filterValueQuery: useSpoolmanLotNumbers(), - allowMultipleFilters: false, })} {DateColumn({ id: "first_used", From 9c2504d07303e7752847296e00b22617455debd9 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 16 Sep 2023 10:56:55 +0200 Subject: [PATCH 29/31] Client: Added indeterminate state to spool select --- client/src/components/spoolSelectModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx index b5863e0..c9477d1 100644 --- a/client/src/components/spoolSelectModal.tsx +++ b/client/src/components/spoolSelectModal.tsx @@ -89,6 +89,8 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC // State for the select/unselect all checkbox const isAllFilteredSelected = dataSource.every((spool) => selectedItems.includes(spool.id)); + const isSomeButNotAllFilteredSelected = + dataSource.some((spool) => selectedItems.includes(spool.id)) && !isAllFilteredSelected; return ( = ({ visible, description, onCancel, onC { selectUnselectFiltered(e.target.checked); }} From 58fdbb4d85ff598f73bacb924764f48084940afd Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 16 Sep 2023 11:01:40 +0200 Subject: [PATCH 30/31] Client: Fixed spool select table formatting --- client/src/components/column.tsx | 4 ++++ client/src/components/spoolSelectModal.tsx | 9 ++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index 8ccd11c..a57bd62 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -23,6 +23,7 @@ interface BaseColumnProps { i18nkey?: string; dataSource: Obj[]; tableState: TableState; + width?: number; } interface FilteredColumnProps { @@ -54,6 +55,9 @@ function Column(props: BaseColumnProps & FilteredColumnProps & CustomC sortOrder: getSortOrderForField(typedSorters, props.dataId ?? props.id), filterMultiple: props.allowMultipleFilters ?? true, }; + if (props.width) { + columnProps.width = props.width; + } if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; diff --git a/client/src/components/spoolSelectModal.tsx b/client/src/components/spoolSelectModal.tsx index c9477d1..5740313 100644 --- a/client/src/components/spoolSelectModal.tsx +++ b/client/src/components/spoolSelectModal.tsx @@ -112,13 +112,7 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC {contextHolder} {description &&
{description}
} -
+
( @@ -130,6 +124,7 @@ const SpoolSelectModal: React.FC = ({ visible, description, onCancel, onC i18ncat: "spool", dataSource, tableState, + width: 80, })} {SpoolIconColumn({ id: "combined_name", From 91e4171ff89dfca8d760be4aeea3da2cf736f6d6 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 16 Sep 2023 11:11:48 +0200 Subject: [PATCH 31/31] Client: Added lazy-loading for filter drop downs --- client/src/components/column.tsx | 35 +++++++++++++++++++++++++-- client/src/components/otherModels.tsx | 7 ++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/client/src/components/column.tsx b/client/src/components/column.tsx index a57bd62..7b4ef16 100644 --- a/client/src/components/column.tsx +++ b/client/src/components/column.tsx @@ -1,4 +1,4 @@ -import { Col, Row, Table } from "antd"; +import { Col, Row, Spin, Table } from "antd"; import { ColumnProps as AntdColumnProps } from "antd/es/table"; import { ColumnFilterItem } from "antd/es/table/interface"; import { getFiltersForField, typeFilters } from "../utils/filtering"; @@ -16,6 +16,17 @@ import { UseQueryResult } from "@tanstack/react-query"; dayjs.extend(utc); +const FilterDropdownLoading = () => { + return ( + + + Loading... + + + + ); +}; + interface BaseColumnProps { id: keyof Obj & string; dataId?: keyof Obj & string; @@ -30,6 +41,8 @@ interface FilteredColumnProps { filters?: ColumnFilterItem[]; filteredValue?: string[]; allowMultipleFilters?: boolean; + onFilterDropdownOpen?: () => void; + loadingFilters?: boolean; } interface CustomColumnProps { @@ -61,6 +74,14 @@ function Column(props: BaseColumnProps & FilteredColumnProps & CustomC if (props.filters && props.filteredValue) { columnProps.filters = props.filters; columnProps.filteredValue = props.filteredValue; + if (props.loadingFilters) { + columnProps.filterDropdown = ; + } + columnProps.onFilterDropdownOpenChange = (open) => { + if (open && props.onFilterDropdownOpen) { + props.onFilterDropdownOpen(); + } + }; if (props.dataId) { columnProps.key = props.dataId; } @@ -115,7 +136,11 @@ export function FilteredQueryColumn(props: FilteredQueryColumnProps) { const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id); - return Column({ ...props, filters, filteredValue }); + const onFilterDropdownOpen = () => { + query.refetch(); + }; + + return Column({ ...props, filters, filteredValue, onFilterDropdownOpen, loadingFilters: query.isLoading }); } interface NumberColumnProps extends BaseColumnProps { @@ -188,10 +213,16 @@ export function SpoolIconColumn(props: SpoolIconColumnProps) { const typedFilters = typeFilters(props.tableState.filters); const filteredValue = getFiltersForField(typedFilters, props.dataId ?? props.id); + const onFilterDropdownOpen = () => { + query.refetch(); + }; + return Column({ ...props, filters, filteredValue, + onFilterDropdownOpen, + loadingFilters: query.isLoading, onCell: () => { return { style: { diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx index 19c01f5..02feebb 100644 --- a/client/src/components/otherModels.tsx +++ b/client/src/components/otherModels.tsx @@ -6,6 +6,7 @@ import { ColumnFilterItem } from "antd/es/table/interface"; export function useSpoolmanFilamentFilter() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["filaments"], queryFn: async () => { const response = await fetch(apiEndpoint + "/filament"); @@ -46,6 +47,7 @@ export function useSpoolmanFilamentFilter() { export function useSpoolmanFilamentNames() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["filaments"], queryFn: async () => { const response = await fetch(apiEndpoint + "/filament"); @@ -74,6 +76,7 @@ export function useSpoolmanFilamentNames() { export function useSpoolmanVendors() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["vendors"], queryFn: async () => { const response = await fetch(apiEndpoint + "/vendor"); @@ -95,6 +98,7 @@ export function useSpoolmanVendors() { export function useSpoolmanMaterials() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["materials"], queryFn: async () => { const response = await fetch(apiEndpoint + "/material"); @@ -112,6 +116,7 @@ export function useSpoolmanMaterials() { export function useSpoolmanArticleNumbers() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["articleNumbers"], queryFn: async () => { const response = await fetch(apiEndpoint + "/article-number"); @@ -129,6 +134,7 @@ export function useSpoolmanArticleNumbers() { export function useSpoolmanLotNumbers() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["lotNumbers"], queryFn: async () => { const response = await fetch(apiEndpoint + "/lot-number"); @@ -146,6 +152,7 @@ export function useSpoolmanLotNumbers() { export function useSpoolmanLocations() { const apiEndpoint = import.meta.env.VITE_APIURL; return useQuery({ + enabled: false, queryKey: ["locations"], queryFn: async () => { const response = await fetch(apiEndpoint + "/location");