From 77ac9d6a5c645224e81ee0681c4bd0b6d7d96038 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sat, 6 Jan 2024 16:22:35 +0100 Subject: [PATCH] Added custom extra fields to backend No integration tests yet, TBD --- ...04_2209-b8881bdb716c_added_extra_fields.py | 74 +++++++ spoolman/api/v1/field.py | 98 +++++++++ spoolman/api/v1/filament.py | 41 +++- spoolman/api/v1/models.py | 21 ++ spoolman/api/v1/router.py | 3 +- spoolman/api/v1/spool.py | 23 +- spoolman/api/v1/vendor.py | 41 +++- spoolman/database/filament.py | 6 +- spoolman/database/models.py | 45 +++- spoolman/database/spool.py | 6 +- spoolman/database/vendor.py | 9 +- spoolman/extra_fields.py | 199 ++++++++++++++++++ spoolman/settings.py | 4 + tests_integration/tests/setting/test_get.py | 11 +- 14 files changed, 556 insertions(+), 25 deletions(-) create mode 100644 migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py create mode 100644 spoolman/api/v1/field.py create mode 100644 spoolman/extra_fields.py diff --git a/migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py b/migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py new file mode 100644 index 0000000..50a2b79 --- /dev/null +++ b/migrations/versions/2024_01_04_2209-b8881bdb716c_added_extra_fields.py @@ -0,0 +1,74 @@ +"""Added extra fields. + +Revision ID: b8881bdb716c +Revises: ccbb17aeda7c +Create Date: 2024-01-04 22:09:34.417527 +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b8881bdb716c" +down_revision = "ccbb17aeda7c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Perform the upgrade.""" + op.create_table( + "vendor_field", + sa.Column("vendor_id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=64), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["vendor_id"], + ["vendor.id"], + ), + sa.PrimaryKeyConstraint("vendor_id", "key"), + ) + op.create_index(op.f("ix_vendor_field_key"), "vendor_field", ["key"], unique=False) + op.create_index(op.f("ix_vendor_field_vendor_id"), "vendor_field", ["vendor_id"], unique=False) + + op.create_table( + "filament_field", + sa.Column("filament_id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=64), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["filament_id"], + ["filament.id"], + ), + sa.PrimaryKeyConstraint("filament_id", "key"), + ) + op.create_index(op.f("ix_filament_field_filament_id"), "filament_field", ["filament_id"], unique=False) + op.create_index(op.f("ix_filament_field_key"), "filament_field", ["key"], unique=False) + + op.create_table( + "spool_field", + sa.Column("spool_id", sa.Integer(), nullable=False), + sa.Column("key", sa.String(length=64), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.ForeignKeyConstraint( + ["spool_id"], + ["spool.id"], + ), + sa.PrimaryKeyConstraint("spool_id", "key"), + ) + op.create_index(op.f("ix_spool_field_key"), "spool_field", ["key"], unique=False) + op.create_index(op.f("ix_spool_field_spool_id"), "spool_field", ["spool_id"], unique=False) + + +def downgrade() -> None: + """Perform the downgrade.""" + op.drop_index(op.f("ix_spool_field_spool_id"), table_name="spool_field") + op.drop_index(op.f("ix_spool_field_key"), table_name="spool_field") + op.drop_table("spool_field") + + op.drop_index(op.f("ix_filament_field_key"), table_name="filament_field") + op.drop_index(op.f("ix_filament_field_filament_id"), table_name="filament_field") + op.drop_table("filament_field") + + op.drop_index(op.f("ix_vendor_field_vendor_id"), table_name="vendor_field") + op.drop_index(op.f("ix_vendor_field_key"), table_name="vendor_field") + op.drop_table("vendor_field") diff --git a/spoolman/api/v1/field.py b/spoolman/api/v1/field.py new file mode 100644 index 0000000..0f1504c --- /dev/null +++ b/spoolman/api/v1/field.py @@ -0,0 +1,98 @@ +"""Vendor related endpoints.""" + +import logging +from typing import Annotated, Union + +from fastapi import APIRouter, Depends, Path +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.api.v1.models import Message +from spoolman.database.database import get_db_session +from spoolman.exceptions import ItemNotFoundError +from spoolman.extra_fields import ( + EntityType, + ExtraField, + ExtraFieldParameters, + add_or_update_extra_field, + delete_extra_field, + get_extra_fields, +) + +router = APIRouter( + prefix="/field", + tags=["field"], +) + +# ruff: noqa: D103,B008 + +logger = logging.getLogger(__name__) + + +@router.get( + "/{entity_type}", + name="Get extra fields", + description="Get all extra fields for a specific entity type.", + response_model_exclude_none=True, +) +async def get( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this field is for")], +) -> list[ExtraField]: + return await get_extra_fields(db, entity_type) + + +@router.post( + "/{entity_type}/{key}", + name="Add or update extra field", + description=( + "Add or update an extra field for a specific entity type. " + "Returns the full list of extra fields for the entity type." + ), + response_model_exclude_none=True, + response_model=list[ExtraField], + responses={400: {"model": Message}}, +) +async def update( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this field is for")], + key: Annotated[str, Path(min_length=1, max_length=64, regex="^[a-z0-9_]+$")], + body: ExtraFieldParameters, +) -> Union[list[ExtraField], JSONResponse]: + body_with_key = ExtraField.parse_obj(body.copy(update={"key": key})) + + try: + await add_or_update_extra_field(db, entity_type, body_with_key) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + + return await get_extra_fields(db, entity_type) + + +@router.delete( + "/{entity_type}/{key}", + name="Delete extra field", + description=( + "Delete an extra field for a specific entity type. " + "Returns the full list of extra fields for the entity type." + ), + response_model_exclude_none=True, + response_model=list[ExtraField], + responses={404: {"model": Message}}, +) +async def delete( + db: Annotated[AsyncSession, Depends(get_db_session)], + entity_type: Annotated[EntityType, Path(description="Entity type this field is for")], + key: Annotated[str, Path(min_length=1, max_length=64, regex="^[a-z0-9_]+$")], +) -> Union[list[ExtraField], JSONResponse]: + try: + await delete_extra_field(db, entity_type, key) + except ItemNotFoundError: + return JSONResponse( + status_code=404, + content=Message( + message=f"Extra field with key {key} does not exist for entity type {entity_type.name}", + ).dict(), + ) + + return await get_extra_fields(db, entity_type) diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 451f446..a414a18 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -17,6 +17,7 @@ from spoolman.database import filament from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemDeleteError +from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict from spoolman.ws import websocket_manager logger = logging.getLogger(__name__) @@ -81,6 +82,10 @@ class FilamentParameters(BaseModel): description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.", example="FF0000", ) + extra: Optional[dict[str, str]] = Field( + None, + description="Extra fields for this filament.", + ) @validator("color_hex") @classmethod @@ -323,11 +328,20 @@ async def notify( name="Add filament", description="Add a new filament to the database.", response_model_exclude_none=True, + response_model=Filament, + responses={400: {"model": Message}}, ) -async def create( +async def create( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], body: FilamentParameters, -) -> Filament: +): + if body.extra: + all_fields = await get_extra_fields(db, EntityType.filament) + try: + validate_extra_field_dict(all_fields, body.extra) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + db_item = await filament.create( db=db, density=body.density, @@ -343,6 +357,7 @@ async def create( settings_extruder_temp=body.settings_extruder_temp, settings_bed_temp=body.settings_bed_temp, color_hex=body.color_hex, + extra=body.extra, ) return Filament.from_db(db_item) @@ -351,15 +366,22 @@ async def create( @router.patch( "/{filament_id}", name="Update filament", - description="Update any attribute of a filament. Only fields specified in the request will be affected.", + description=( + "Update any attribute of a filament. Only fields specified in the request will be affected. " + "If extra is set, all existing extra fields will be removed and replaced with the new ones." + ), response_model_exclude_none=True, - responses={404: {"model": Message}}, + response_model=Filament, + responses={ + 400: {"model": Message}, + 404: {"model": Message}, + }, ) -async def update( +async def update( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], filament_id: int, body: FilamentUpdateParameters, -) -> Filament: +): patch_data = body.dict(exclude_unset=True) if "density" in patch_data and body.density is None: @@ -367,6 +389,13 @@ async def update( if "diameter" in patch_data and body.diameter is None: raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))]) + if body.extra: + all_fields = await get_extra_fields(db, EntityType.filament) + try: + validate_extra_field_dict(all_fields, body.extra) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + db_item = await filament.update( db=db, filament_id=filament_id, diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index 4288e12..3d36e9b 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -60,6 +60,12 @@ class Vendor(BaseModel): registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.") name: str = Field(max_length=64, description="Vendor name.", example="Polymaker") comment: Optional[str] = Field(max_length=1024, description="Free text comment about this vendor.", example="") + extra: dict[str, str] = Field( + description=( + "Extra fields for this vendor. All values are JSON-encoded data. " + "Query the /fields endpoint for more details about the fields." + ), + ) @staticmethod def from_db(item: models.Vendor) -> "Vendor": @@ -69,6 +75,7 @@ class Vendor(BaseModel): registered=item.registered, name=item.name, comment=item.comment, + extra={field.key: field.value for field in item.extra}, ) @@ -128,6 +135,12 @@ class Filament(BaseModel): description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.", example="FF0000", ) + extra: dict[str, str] = Field( + description=( + "Extra fields for this filament. All values are JSON-encoded data. " + "Query the /fields endpoint for more details about the fields." + ), + ) @staticmethod def from_db(item: models.Filament) -> "Filament": @@ -148,6 +161,7 @@ class Filament(BaseModel): settings_extruder_temp=item.settings_extruder_temp, settings_bed_temp=item.settings_bed_temp, color_hex=item.color_hex, + extra={field.key: field.value for field in item.extra}, ) @@ -198,6 +212,12 @@ class Spool(BaseModel): example="", ) archived: bool = Field(description="Whether this spool is archived and should not be used anymore.") + extra: dict[str, str] = Field( + description=( + "Extra fields for this spool. All values are JSON-encoded data. " + "Query the /fields endpoint for more details about the fields." + ), + ) @staticmethod def from_db(item: models.Spool) -> "Spool": @@ -235,6 +255,7 @@ class Spool(BaseModel): lot_nr=item.lot_nr, comment=item.comment, archived=item.archived if item.archived is not None else False, + extra={field.key: field.value for field in item.extra}, ) diff --git a/spoolman/api/v1/router.py b/spoolman/api/v1/router.py index 812db8a..fd9949e 100644 --- a/spoolman/api/v1/router.py +++ b/spoolman/api/v1/router.py @@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db from spoolman.exceptions import ItemNotFoundError from spoolman.ws import websocket_manager -from . import filament, models, other, setting, spool, vendor +from . import field, filament, models, other, setting, spool, vendor logger = logging.getLogger(__name__) @@ -108,4 +108,5 @@ app.include_router(filament.router) app.include_router(spool.router) app.include_router(vendor.router) app.include_router(setting.router) +app.include_router(field.router) app.include_router(other.router) diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index 35ec5ea..1391dfd 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -18,6 +18,7 @@ from spoolman.database import spool from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemCreateError +from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict from spoolman.ws import websocket_manager logger = logging.getLogger(__name__) @@ -59,6 +60,10 @@ class SpoolParameters(BaseModel): example="", ) archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.") + extra: Optional[dict[str, str]] = Field( + None, + description="Extra fields for this spool.", + ) class SpoolUpdateParameters(SpoolParameters): @@ -337,6 +342,13 @@ async def create( # noqa: ANN201 content={"message": "Only specify either remaining_weight or used_weight."}, ) + if body.extra: + all_fields = await get_extra_fields(db, EntityType.spool) + try: + validate_extra_field_dict(all_fields, body.extra) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + try: db_item = await spool.create( db=db, @@ -350,6 +362,7 @@ async def create( # noqa: ANN201 lot_nr=body.lot_nr, comment=body.comment, archived=body.archived, + extra=body.extra, ) return Spool.from_db(db_item) except ItemCreateError: @@ -366,7 +379,8 @@ async def create( # noqa: ANN201 description=( "Update any attribute of a spool. " "Only fields specified in the request will be affected. " - "remaining_weight and used_weight can't be set at the same time." + "remaining_weight and used_weight can't be set at the same time. " + "If extra is set, all existing extra fields will be removed and replaced with the new ones." ), response_model_exclude_none=True, response_model=Spool, @@ -388,6 +402,13 @@ async def update( # noqa: ANN201 content={"message": "Only specify either remaining_weight or used_weight."}, ) + if body.extra: + all_fields = await get_extra_fields(db, EntityType.spool) + try: + validate_extra_field_dict(all_fields, body.extra) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + if "filament_id" in patch_data and body.filament_id is None: raise RequestValidationError( [ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))], diff --git a/spoolman/api/v1/vendor.py b/spoolman/api/v1/vendor.py index e3b72fe..260e609 100644 --- a/spoolman/api/v1/vendor.py +++ b/spoolman/api/v1/vendor.py @@ -15,6 +15,7 @@ from spoolman.api.v1.models import Message, Vendor, VendorEvent from spoolman.database import vendor from spoolman.database.database import get_db_session from spoolman.database.utils import SortOrder +from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict from spoolman.ws import websocket_manager router = APIRouter( @@ -32,6 +33,10 @@ class VendorParameters(BaseModel): description="Free text comment about this vendor.", example="", ) + extra: Optional[dict[str, str]] = Field( + None, + description="Extra fields for this vendor.", + ) class VendorUpdateParameters(VendorParameters): @@ -167,15 +172,25 @@ async def notify( name="Add vendor", description="Add a new vendor to the database.", response_model_exclude_none=True, + response_model=Vendor, + responses={400: {"model": Message}}, ) -async def create( +async def create( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], body: VendorParameters, -) -> Vendor: +): + if body.extra: + all_fields = await get_extra_fields(db, EntityType.vendor) + try: + validate_extra_field_dict(all_fields, body.extra) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + db_item = await vendor.create( db=db, name=body.name, comment=body.comment, + extra=body.extra, ) return Vendor.from_db(db_item) @@ -184,20 +199,34 @@ async def create( @router.patch( "/{vendor_id}", name="Update vendor", - description="Update any attribute of a vendor. Only fields specified in the request will be affected.", + description=( + "Update any attribute of a vendor. Only fields specified in the request will be affected. " + "If extra is set, all existing extra fields will be removed and replaced with the new ones." + ), response_model_exclude_none=True, - responses={404: {"model": Message}}, + response_model=Vendor, + responses={ + 400: {"model": Message}, + 404: {"model": Message}, + }, ) -async def update( +async def update( # noqa: ANN201 db: Annotated[AsyncSession, Depends(get_db_session)], vendor_id: int, body: VendorUpdateParameters, -) -> Vendor: +): patch_data = body.dict(exclude_unset=True) if "name" in patch_data and body.name is None: raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))]) + if body.extra: + all_fields = await get_extra_fields(db, EntityType.vendor) + try: + validate_extra_field_dict(all_fields, body.extra) + except ValueError as e: + return JSONResponse(status_code=400, content=Message(message=str(e)).dict()) + db_item = await vendor.update( db=db, vendor_id=vendor_id, diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index 05ace5f..2c69e2d 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -41,6 +41,7 @@ async def create( settings_extruder_temp: Optional[int] = None, settings_bed_temp: Optional[int] = None, color_hex: Optional[str] = None, + extra: Optional[dict[str, str]] = None, ) -> models.Filament: """Add a new filament to the database.""" vendor_item: Optional[models.Vendor] = None @@ -62,6 +63,7 @@ async def create( settings_extruder_temp=settings_extruder_temp, settings_bed_temp=settings_bed_temp, color_hex=color_hex, + extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()], ) db.add(filament) await db.commit() @@ -131,7 +133,7 @@ async def find( stmt = stmt.order_by(field.desc()) rows = await db.execute(stmt) - result = list(rows.scalars().all()) + result = list(rows.unique().scalars().all()) if total_count is None: total_count = len(result) @@ -152,6 +154,8 @@ async def update( filament.vendor = None else: filament.vendor = await vendor.get_by_id(db, v) + elif k == "extra": + filament.extra = [models.FilamentField(key=k, value=v) for k, v in v.items()] else: setattr(filament, k, v) await db.commit() diff --git a/spoolman/database/models.py b/spoolman/database/models.py index 685deb0..f06ac6f 100644 --- a/spoolman/database/models.py +++ b/spoolman/database/models.py @@ -4,10 +4,11 @@ from datetime import datetime from typing import Optional from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -class Base(DeclarativeBase): +class Base(AsyncAttrs, DeclarativeBase): pass @@ -19,6 +20,11 @@ class Vendor(Base): name: Mapped[str] = mapped_column(String(64)) comment: Mapped[Optional[str]] = mapped_column(String(1024)) filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor") + extra: Mapped[list["VendorField"]] = relationship( + back_populates="vendor", + cascade="save-update, merge, delete, delete-orphan", + lazy="joined", + ) class Filament(Base): @@ -41,6 +47,11 @@ class Filament(Base): settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.") settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.") color_hex: Mapped[Optional[str]] = mapped_column(String(8)) + extra: Mapped[list["FilamentField"]] = relationship( + back_populates="filament", + cascade="save-update, merge, delete, delete-orphan", + lazy="joined", + ) class Spool(Base): @@ -58,6 +69,11 @@ class Spool(Base): lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) comment: Mapped[Optional[str]] = mapped_column(String(1024)) archived: Mapped[Optional[bool]] = mapped_column() + extra: Mapped[list["SpoolField"]] = relationship( + back_populates="spool", + cascade="save-update, merge, delete, delete-orphan", + lazy="joined", + ) class Setting(Base): @@ -66,3 +82,30 @@ class Setting(Base): key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) value: Mapped[str] = mapped_column(Text()) last_updated: Mapped[datetime] = mapped_column() + + +class VendorField(Base): + __tablename__ = "vendor_field" + + vendor_id: Mapped[int] = mapped_column(ForeignKey("vendor.id"), primary_key=True, index=True) + vendor: Mapped["Vendor"] = relationship(back_populates="extra") + key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) + value: Mapped[str] = mapped_column(Text()) + + +class FilamentField(Base): + __tablename__ = "filament_field" + + filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"), primary_key=True, index=True) + filament: Mapped["Filament"] = relationship(back_populates="extra") + key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) + value: Mapped[str] = mapped_column(Text()) + + +class SpoolField(Base): + __tablename__ = "spool_field" + + spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), primary_key=True, index=True) + spool: Mapped["Spool"] = relationship(back_populates="extra") + key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) + value: Mapped[str] = mapped_column(Text()) diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index dc29d32..3f49670 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -43,6 +43,7 @@ async def create( lot_nr: Optional[str] = None, comment: Optional[str] = None, archived: bool = False, + extra: Optional[dict[str, str]] = None, ) -> models.Spool: """Add a new spool to the database. Leave weight empty to assume full spool.""" filament_item = await filament.get_by_id(db, filament_id) @@ -71,6 +72,7 @@ async def create( lot_nr=lot_nr, comment=comment, archived=archived, + extra=[models.SpoolField(key=k, value=v) for k, v in (extra or {}).items()], ) db.add(spool) await db.commit() @@ -158,7 +160,7 @@ async def find( stmt = stmt.order_by(field.desc()) rows = await db.execute(stmt) - result = list(rows.scalars().all()) + result = list(rows.unique().scalars().all()) if total_count is None: total_count = len(result) @@ -182,6 +184,8 @@ async def update( spool.used_weight = max(spool.filament.weight - v, 0) elif isinstance(v, datetime): setattr(spool, k, utc_timezone_naive(v)) + elif k == "extra": + spool.extra = [models.SpoolField(key=k, value=v) for k, v in v.items()] else: setattr(spool, k, v) await db.commit() diff --git a/spoolman/database/vendor.py b/spoolman/database/vendor.py index db7be09..628a8ed 100644 --- a/spoolman/database/vendor.py +++ b/spoolman/database/vendor.py @@ -18,12 +18,14 @@ async def create( db: AsyncSession, name: Optional[str] = None, comment: Optional[str] = None, + extra: Optional[dict[str, str]] = None, ) -> models.Vendor: """Add a new vendor to the database.""" vendor = models.Vendor( name=name, registered=datetime.utcnow().replace(microsecond=0), comment=comment, + extra=[models.VendorField(key=k, value=v) for k, v in (extra or {}).items()], ) db.add(vendor) await db.commit() @@ -72,7 +74,7 @@ async def find( stmt = stmt.order_by(field.desc()) rows = await db.execute(stmt) - result = list(rows.scalars().all()) + result = list(rows.unique().scalars().all()) if total_count is None: total_count = len(result) @@ -88,7 +90,10 @@ async def update( """Update the fields of a vendor object.""" vendor = await get_by_id(db, vendor_id) for k, v in data.items(): - setattr(vendor, k, v) + if k == "extra": + vendor.extra = [models.VendorField(key=k, value=v) for k, v in v.items()] + else: + setattr(vendor, k, v) await db.commit() await vendor_changed(vendor, EventType.UPDATED) return vendor diff --git a/spoolman/extra_fields.py b/spoolman/extra_fields.py new file mode 100644 index 0000000..ca003a2 --- /dev/null +++ b/spoolman/extra_fields.py @@ -0,0 +1,199 @@ +"""Custom/extra fields for spoolman entities.""" + +import json +import logging +from enum import Enum +from typing import Optional + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from spoolman.database import setting as db_setting +from spoolman.exceptions import ItemNotFoundError +from spoolman.settings import parse_setting + +logger = logging.getLogger(__name__) + + +class EntityType(Enum): + vendor = "vendor" + filament = "filament" + spool = "spool" + + +class ExtraFieldType(Enum): + text = "text" + integer = "integer" + integer_range = "integer_range" + float = "float" + float_range = "float_range" + datetime = "datetime" + boolean = "boolean" + choice = "choice" + + +class ExtraFieldParameters(BaseModel): + name: str = Field(description="Nice name", min_length=1, max_length=128) + unit: Optional[str] = Field(None, description="Unit of the value", min_length=1, max_length=16) + entity_type: EntityType = Field(description="Entity type this field is for") + field_type: ExtraFieldType = Field(description="Type of the field") + default_value: Optional[str] = Field(None, description="Default value of the field") + choices: Optional[list[str]] = Field(None, description="Choices for the field, only for field type choice") + multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected") + + +class ExtraField(ExtraFieldParameters): + key: str = Field(description="Unique key", regex="^[a-z0-9_]+$", min_length=1, max_length=64) + + +def validate_extra_field_value(field: ExtraFieldParameters, value: str) -> None: # noqa: C901, PLR0912 + """Validate that the value has the correct type.""" + try: + data = json.loads(value) + except json.JSONDecodeError: + raise ValueError("Value is not valid JSON.") from None + + if field.field_type == ExtraFieldType.text: + if not isinstance(data, str): + raise ValueError("Value is not a string.") + elif field.field_type == ExtraFieldType.integer: + if not isinstance(data, int): + raise ValueError("Value is not an integer.") + elif field.field_type == ExtraFieldType.integer_range: + if not isinstance(data, list): + raise ValueError("Value is not a list.") + if len(data) != 2: # noqa: PLR2004 + raise ValueError("Value list must have exactly two values.") + if not all(isinstance(value, int) for value in data): + raise ValueError("Value list must contain only integers.") + elif field.field_type == ExtraFieldType.float: + if not isinstance(data, (float, int)) or isinstance(data, bool): + raise ValueError("Value is not a float.") + elif field.field_type == ExtraFieldType.float_range: + if not isinstance(data, list): + raise ValueError("Value is not a list.") + if len(data) != 2: # noqa: PLR2004 + raise ValueError("Value list must have exactly two values.") + if not all(isinstance(value, (float, int)) and not isinstance(value, bool) for value in data): + raise ValueError("Value list must contain only floats.") + elif field.field_type == ExtraFieldType.datetime: + if not isinstance(data, str): + raise ValueError("Value is not a string.") + elif field.field_type == ExtraFieldType.boolean: + if not isinstance(data, bool): + raise ValueError("Value is not a boolean.") + elif field.field_type == ExtraFieldType.choice: + if field.multi_choice: + if not isinstance(data, list): + raise ValueError("Value is not a list.") + if not all(isinstance(value, str) for value in data): + raise ValueError("Value list must contain only strings.") + if field.choices is not None and not all(value in field.choices for value in data): + raise ValueError("Value list contains invalid choices.") + else: + if not isinstance(data, str): + raise ValueError("Value is not a string.") + if field.choices is not None and data not in field.choices: + raise ValueError("Value is not a valid choice.") + else: + raise ValueError(f"Unknown field type {field.field_type}.") + + +def validate_extra_field(field: ExtraFieldParameters) -> None: # noqa: C901 + """Validate an extra field.""" + # Validate choices exist if field type is choice + if field.field_type == ExtraFieldType.choice: + if field.choices is None: + raise ValueError("Choices must be set for field type choice.") + if not isinstance(field.choices, list): + raise ValueError("Choices must be a list.") + if not all(isinstance(choice, str) for choice in field.choices): + raise ValueError("Choices must be a list of strings.") + if len(field.choices) == 0: + raise ValueError("Choices must not be empty.") + if field.multi_choice is None: + raise ValueError("Multi choice must be set for field type choice.") + else: + if field.choices is not None: + raise ValueError("Choices must not be set for field type other than choice.") + if field.multi_choice is not None: + raise ValueError("Multi choice must not be set for field type other than choice.") + + # Validate default value data type + if field.default_value is not None: + try: + validate_extra_field_value(field, field.default_value) + except ValueError as e: + raise ValueError(f"Default value is not valid: {e}") from None + + +def validate_extra_field_dict(all_fields: list[ExtraField], fields_input: dict[str, str]) -> None: + """Validate a dict of extra fields.""" + all_field_lookup = {field.key: field for field in all_fields} + for key, value in fields_input.items(): + if key not in all_field_lookup: + raise ValueError(f"Unknown extra field {key}.") + field = all_field_lookup[key] + try: + validate_extra_field_value(field, value) + except ValueError as e: + raise ValueError(f"Invalid extra field for key {key}: {e!s}") from None + + +async def get_extra_fields(db: AsyncSession, entity_type: EntityType) -> list[ExtraField]: + """Get all extra fields for a specific entity type.""" + setting_def = parse_setting(f"extra_fields_{entity_type.name}") + try: + setting = await db_setting.get(db, setting_def) + setting_value = setting.value + except ItemNotFoundError: + setting_value = setting_def.default + + setting_array = json.loads(setting_value) + if not isinstance(setting_array, list): + logger.warning("Setting %s is not a list, using default.", setting_def.key) + setting_array = [] + + return [ExtraField.parse_obj(obj) for obj in setting_array] + + +async def add_or_update_extra_field(db: AsyncSession, entity_type: EntityType, extra_field: ExtraField) -> None: + """Add or update an extra field for a specific entity type.""" + validate_extra_field(extra_field) + + extra_fields = await get_extra_fields(db, entity_type) + extra_fields = [field for field in extra_fields if field.key != extra_field.key] + extra_fields.append(extra_field) + + setting_def = parse_setting(f"extra_fields_{entity_type.name}") + await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields))) + + logger.info("Added/updated extra field %s for entity type %s.", extra_field.key, entity_type.name) + + +async def delete_extra_field(db: AsyncSession, entity_type: EntityType, key: str) -> None: + """Delete an extra field for a specific entity type.""" + extra_fields = await get_extra_fields(db, entity_type) + + # Check if the field exists + if not any(field.key == key for field in extra_fields): + raise ItemNotFoundError(f"Extra field with key {key} does not exist.") + + extra_fields = [field for field in extra_fields if field.key != key] + + setting_def = parse_setting(f"extra_fields_{entity_type.name}") + await db_setting.update(db=db, definition=setting_def, value=json.dumps(jsonable_encoder(extra_fields))) + + logger.info("Deleted extra field %s for entity type %s.", key, entity_type.name) + + +async def populate_with_defaults(db: AsyncSession, entity_type: EntityType, existing: dict[str, str]) -> None: + """Populate the given list of extra fields with defaults.""" + extra_fields = await get_extra_fields(db, entity_type) + for extra_field in extra_fields: + if extra_field.default_value is None: + continue + if extra_field.key in existing: + continue + existing[extra_field.key] = extra_field.default_value diff --git a/spoolman/settings.py b/spoolman/settings.py index 4f35191..b3600bc 100644 --- a/spoolman/settings.py +++ b/spoolman/settings.py @@ -62,3 +62,7 @@ def parse_setting(key: str) -> SettingDefinition: register_setting("currency", SettingType.STRING, json.dumps("EUR")) + +register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([])) +register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([])) +register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([])) diff --git a/tests_integration/tests/setting/test_get.py b/tests_integration/tests/setting/test_get.py index bb8c006..36c5b05 100644 --- a/tests_integration/tests/setting/test_get.py +++ b/tests_integration/tests/setting/test_get.py @@ -35,10 +35,9 @@ def test_get_all(): # Verify settings = result.json() - assert settings == { - "currency": { - "value": '"EUR"', - "is_set": False, - "type": "string", - }, + assert "currency" in settings + assert settings["currency"] == { + "value": '"EUR"', + "is_set": False, + "type": "string", }