Added custom extra fields to backend

No integration tests yet, TBD
This commit is contained in:
Donkie
2024-01-06 16:22:35 +01:00
parent 71c29d2467
commit 77ac9d6a5c
14 changed files with 556 additions and 25 deletions

View File

@@ -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")

98
spoolman/api/v1/field.py Normal file
View File

@@ -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)

View File

@@ -17,6 +17,7 @@ from spoolman.database import filament
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemDeleteError from spoolman.exceptions import ItemDeleteError
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__) 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.", description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000", example="FF0000",
) )
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this filament.",
)
@validator("color_hex") @validator("color_hex")
@classmethod @classmethod
@@ -323,11 +328,20 @@ async def notify(
name="Add filament", name="Add filament",
description="Add a new filament to the database.", description="Add a new filament to the database.",
response_model_exclude_none=True, 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)], db: Annotated[AsyncSession, Depends(get_db_session)],
body: FilamentParameters, 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_item = await filament.create(
db=db, db=db,
density=body.density, density=body.density,
@@ -343,6 +357,7 @@ async def create(
settings_extruder_temp=body.settings_extruder_temp, settings_extruder_temp=body.settings_extruder_temp,
settings_bed_temp=body.settings_bed_temp, settings_bed_temp=body.settings_bed_temp,
color_hex=body.color_hex, color_hex=body.color_hex,
extra=body.extra,
) )
return Filament.from_db(db_item) return Filament.from_db(db_item)
@@ -351,15 +366,22 @@ async def create(
@router.patch( @router.patch(
"/{filament_id}", "/{filament_id}",
name="Update filament", 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, 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)], db: Annotated[AsyncSession, Depends(get_db_session)],
filament_id: int, filament_id: int,
body: FilamentUpdateParameters, body: FilamentUpdateParameters,
) -> Filament: ):
patch_data = body.dict(exclude_unset=True) patch_data = body.dict(exclude_unset=True)
if "density" in patch_data and body.density is None: 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: if "diameter" in patch_data and body.diameter is None:
raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))]) 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_item = await filament.update(
db=db, db=db,
filament_id=filament_id, filament_id=filament_id,

View File

@@ -60,6 +60,12 @@ class Vendor(BaseModel):
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.") 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") 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="") 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 @staticmethod
def from_db(item: models.Vendor) -> "Vendor": def from_db(item: models.Vendor) -> "Vendor":
@@ -69,6 +75,7 @@ class Vendor(BaseModel):
registered=item.registered, registered=item.registered,
name=item.name, name=item.name,
comment=item.comment, 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.", description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
example="FF0000", 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 @staticmethod
def from_db(item: models.Filament) -> "Filament": def from_db(item: models.Filament) -> "Filament":
@@ -148,6 +161,7 @@ class Filament(BaseModel):
settings_extruder_temp=item.settings_extruder_temp, settings_extruder_temp=item.settings_extruder_temp,
settings_bed_temp=item.settings_bed_temp, settings_bed_temp=item.settings_bed_temp,
color_hex=item.color_hex, color_hex=item.color_hex,
extra={field.key: field.value for field in item.extra},
) )
@@ -198,6 +212,12 @@ class Spool(BaseModel):
example="", example="",
) )
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.") 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 @staticmethod
def from_db(item: models.Spool) -> "Spool": def from_db(item: models.Spool) -> "Spool":
@@ -235,6 +255,7 @@ class Spool(BaseModel):
lot_nr=item.lot_nr, lot_nr=item.lot_nr,
comment=item.comment, comment=item.comment,
archived=item.archived if item.archived is not None else False, archived=item.archived if item.archived is not None else False,
extra={field.key: field.value for field in item.extra},
) )

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager 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__) logger = logging.getLogger(__name__)
@@ -108,4 +108,5 @@ app.include_router(filament.router)
app.include_router(spool.router) app.include_router(spool.router)
app.include_router(vendor.router) app.include_router(vendor.router)
app.include_router(setting.router) app.include_router(setting.router)
app.include_router(field.router)
app.include_router(other.router) app.include_router(other.router)

View File

@@ -18,6 +18,7 @@ from spoolman.database import spool
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder from spoolman.database.utils import SortOrder
from spoolman.exceptions import ItemCreateError from spoolman.exceptions import ItemCreateError
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
from spoolman.ws import websocket_manager from spoolman.ws import websocket_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,6 +60,10 @@ class SpoolParameters(BaseModel):
example="", example="",
) )
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.") 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): class SpoolUpdateParameters(SpoolParameters):
@@ -337,6 +342,13 @@ async def create( # noqa: ANN201
content={"message": "Only specify either remaining_weight or used_weight."}, 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: try:
db_item = await spool.create( db_item = await spool.create(
db=db, db=db,
@@ -350,6 +362,7 @@ async def create( # noqa: ANN201
lot_nr=body.lot_nr, lot_nr=body.lot_nr,
comment=body.comment, comment=body.comment,
archived=body.archived, archived=body.archived,
extra=body.extra,
) )
return Spool.from_db(db_item) return Spool.from_db(db_item)
except ItemCreateError: except ItemCreateError:
@@ -367,6 +380,7 @@ async def create( # noqa: ANN201
"Update any attribute of a spool. " "Update any attribute of a spool. "
"Only fields specified in the request will be affected. " "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_exclude_none=True,
response_model=Spool, response_model=Spool,
@@ -388,6 +402,13 @@ async def update( # noqa: ANN201
content={"message": "Only specify either remaining_weight or used_weight."}, 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: if "filament_id" in patch_data and body.filament_id is None:
raise RequestValidationError( raise RequestValidationError(
[ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))], [ErrorWrapper(ValueError("filament_id cannot be unset"), ("query", "filament_id"))],

View File

@@ -15,6 +15,7 @@ from spoolman.api.v1.models import Message, Vendor, VendorEvent
from spoolman.database import vendor from spoolman.database import vendor
from spoolman.database.database import get_db_session from spoolman.database.database import get_db_session
from spoolman.database.utils import SortOrder 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 from spoolman.ws import websocket_manager
router = APIRouter( router = APIRouter(
@@ -32,6 +33,10 @@ class VendorParameters(BaseModel):
description="Free text comment about this vendor.", description="Free text comment about this vendor.",
example="", example="",
) )
extra: Optional[dict[str, str]] = Field(
None,
description="Extra fields for this vendor.",
)
class VendorUpdateParameters(VendorParameters): class VendorUpdateParameters(VendorParameters):
@@ -167,15 +172,25 @@ async def notify(
name="Add vendor", name="Add vendor",
description="Add a new vendor to the database.", description="Add a new vendor to the database.",
response_model_exclude_none=True, 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)], db: Annotated[AsyncSession, Depends(get_db_session)],
body: VendorParameters, 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_item = await vendor.create(
db=db, db=db,
name=body.name, name=body.name,
comment=body.comment, comment=body.comment,
extra=body.extra,
) )
return Vendor.from_db(db_item) return Vendor.from_db(db_item)
@@ -184,20 +199,34 @@ async def create(
@router.patch( @router.patch(
"/{vendor_id}", "/{vendor_id}",
name="Update vendor", 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, 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)], db: Annotated[AsyncSession, Depends(get_db_session)],
vendor_id: int, vendor_id: int,
body: VendorUpdateParameters, body: VendorUpdateParameters,
) -> Vendor: ):
patch_data = body.dict(exclude_unset=True) patch_data = body.dict(exclude_unset=True)
if "name" in patch_data and body.name is None: if "name" in patch_data and body.name is None:
raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))]) 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_item = await vendor.update(
db=db, db=db,
vendor_id=vendor_id, vendor_id=vendor_id,

View File

@@ -41,6 +41,7 @@ async def create(
settings_extruder_temp: Optional[int] = None, settings_extruder_temp: Optional[int] = None,
settings_bed_temp: Optional[int] = None, settings_bed_temp: Optional[int] = None,
color_hex: Optional[str] = None, color_hex: Optional[str] = None,
extra: Optional[dict[str, str]] = None,
) -> models.Filament: ) -> models.Filament:
"""Add a new filament to the database.""" """Add a new filament to the database."""
vendor_item: Optional[models.Vendor] = None vendor_item: Optional[models.Vendor] = None
@@ -62,6 +63,7 @@ async def create(
settings_extruder_temp=settings_extruder_temp, settings_extruder_temp=settings_extruder_temp,
settings_bed_temp=settings_bed_temp, settings_bed_temp=settings_bed_temp,
color_hex=color_hex, color_hex=color_hex,
extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()],
) )
db.add(filament) db.add(filament)
await db.commit() await db.commit()
@@ -131,7 +133,7 @@ async def find(
stmt = stmt.order_by(field.desc()) stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
result = list(rows.scalars().all()) result = list(rows.unique().scalars().all())
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
@@ -152,6 +154,8 @@ async def update(
filament.vendor = None filament.vendor = None
else: else:
filament.vendor = await vendor.get_by_id(db, v) 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: else:
setattr(filament, k, v) setattr(filament, k, v)
await db.commit() await db.commit()

View File

@@ -4,10 +4,11 @@ from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase): class Base(AsyncAttrs, DeclarativeBase):
pass pass
@@ -19,6 +20,11 @@ class Vendor(Base):
name: Mapped[str] = mapped_column(String(64)) name: Mapped[str] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024)) comment: Mapped[Optional[str]] = mapped_column(String(1024))
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor") 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): class Filament(Base):
@@ -41,6 +47,11 @@ class Filament(Base):
settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.") settings_extruder_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden extruder temperature.")
settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.") settings_bed_temp: Mapped[Optional[int]] = mapped_column(comment="Overridden bed temperature.")
color_hex: Mapped[Optional[str]] = mapped_column(String(8)) 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): class Spool(Base):
@@ -58,6 +69,11 @@ class Spool(Base):
lot_nr: Mapped[Optional[str]] = mapped_column(String(64)) lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
comment: Mapped[Optional[str]] = mapped_column(String(1024)) comment: Mapped[Optional[str]] = mapped_column(String(1024))
archived: Mapped[Optional[bool]] = mapped_column() 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): class Setting(Base):
@@ -66,3 +82,30 @@ class Setting(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True) key: Mapped[str] = mapped_column(String(64), primary_key=True, index=True)
value: Mapped[str] = mapped_column(Text()) value: Mapped[str] = mapped_column(Text())
last_updated: Mapped[datetime] = mapped_column() 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())

View File

@@ -43,6 +43,7 @@ async def create(
lot_nr: Optional[str] = None, lot_nr: Optional[str] = None,
comment: Optional[str] = None, comment: Optional[str] = None,
archived: bool = False, archived: bool = False,
extra: Optional[dict[str, str]] = None,
) -> models.Spool: ) -> models.Spool:
"""Add a new spool to the database. Leave weight empty to assume full 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) filament_item = await filament.get_by_id(db, filament_id)
@@ -71,6 +72,7 @@ async def create(
lot_nr=lot_nr, lot_nr=lot_nr,
comment=comment, comment=comment,
archived=archived, archived=archived,
extra=[models.SpoolField(key=k, value=v) for k, v in (extra or {}).items()],
) )
db.add(spool) db.add(spool)
await db.commit() await db.commit()
@@ -158,7 +160,7 @@ async def find(
stmt = stmt.order_by(field.desc()) stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
result = list(rows.scalars().all()) result = list(rows.unique().scalars().all())
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
@@ -182,6 +184,8 @@ async def update(
spool.used_weight = max(spool.filament.weight - v, 0) spool.used_weight = max(spool.filament.weight - v, 0)
elif isinstance(v, datetime): elif isinstance(v, datetime):
setattr(spool, k, utc_timezone_naive(v)) 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: else:
setattr(spool, k, v) setattr(spool, k, v)
await db.commit() await db.commit()

View File

@@ -18,12 +18,14 @@ async def create(
db: AsyncSession, db: AsyncSession,
name: Optional[str] = None, name: Optional[str] = None,
comment: Optional[str] = None, comment: Optional[str] = None,
extra: Optional[dict[str, str]] = None,
) -> models.Vendor: ) -> models.Vendor:
"""Add a new vendor to the database.""" """Add a new vendor to the database."""
vendor = models.Vendor( vendor = models.Vendor(
name=name, name=name,
registered=datetime.utcnow().replace(microsecond=0), registered=datetime.utcnow().replace(microsecond=0),
comment=comment, comment=comment,
extra=[models.VendorField(key=k, value=v) for k, v in (extra or {}).items()],
) )
db.add(vendor) db.add(vendor)
await db.commit() await db.commit()
@@ -72,7 +74,7 @@ async def find(
stmt = stmt.order_by(field.desc()) stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt) rows = await db.execute(stmt)
result = list(rows.scalars().all()) result = list(rows.unique().scalars().all())
if total_count is None: if total_count is None:
total_count = len(result) total_count = len(result)
@@ -88,6 +90,9 @@ async def update(
"""Update the fields of a vendor object.""" """Update the fields of a vendor object."""
vendor = await get_by_id(db, vendor_id) vendor = await get_by_id(db, vendor_id)
for k, v in data.items(): for k, v in data.items():
if k == "extra":
vendor.extra = [models.VendorField(key=k, value=v) for k, v in v.items()]
else:
setattr(vendor, k, v) setattr(vendor, k, v)
await db.commit() await db.commit()
await vendor_changed(vendor, EventType.UPDATED) await vendor_changed(vendor, EventType.UPDATED)

199
spoolman/extra_fields.py Normal file
View File

@@ -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

View File

@@ -62,3 +62,7 @@ def parse_setting(key: str) -> SettingDefinition:
register_setting("currency", SettingType.STRING, json.dumps("EUR")) 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([]))

View File

@@ -35,10 +35,9 @@ def test_get_all():
# Verify # Verify
settings = result.json() settings = result.json()
assert settings == { assert "currency" in settings
"currency": { assert settings["currency"] == {
"value": '"EUR"', "value": '"EUR"',
"is_set": False, "is_set": False,
"type": "string", "type": "string",
},
} }