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

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.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,

View File

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

View File

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

View File

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

View File

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