Added custom extra fields to backend
No integration tests yet, TBD
This commit is contained in:
98
spoolman/api/v1/field.py
Normal file
98
spoolman/api/v1/field.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
199
spoolman/extra_fields.py
Normal file
199
spoolman/extra_fields.py
Normal 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
|
||||
@@ -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([]))
|
||||
|
||||
Reference in New Issue
Block a user