Merge remote-tracking branch 'upstream/master'
This commit is contained in:
39
spoolman/api/v1/externaldb.py
Normal file
39
spoolman/api/v1/externaldb.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""External database API."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from spoolman.externaldb import ExternalFilament, ExternalMaterial, get_filaments_file, get_materials_file
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/external",
|
||||
tags=["external"],
|
||||
)
|
||||
|
||||
# ruff: noqa: D103,B008
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filament",
|
||||
name="Get all external filaments",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[ExternalFilament],
|
||||
)
|
||||
async def filaments() -> FileResponse:
|
||||
"""Get all external filaments."""
|
||||
return FileResponse(path=get_filaments_file(), media_type="application/json")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/material",
|
||||
name="Get all external materials",
|
||||
response_model_exclude_none=True,
|
||||
response_model=list[ExternalMaterial],
|
||||
)
|
||||
async def materials() -> FileResponse:
|
||||
"""Get all external materials."""
|
||||
return FileResponse(path=get_materials_file(), media_type="application/json")
|
||||
@@ -59,7 +59,10 @@ async def update(
|
||||
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, "entity_type": entity_type}))
|
||||
dict_body = body.model_dump()
|
||||
dict_body["key"] = key
|
||||
dict_body["entity_type"] = entity_type
|
||||
body_with_key = ExtraField.model_validate(dict_body)
|
||||
|
||||
try:
|
||||
await add_or_update_extra_field(db, entity_type, body_with_key)
|
||||
|
||||
@@ -6,13 +6,11 @@ from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from pydantic.error_wrappers import ErrorWrapper
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Filament, FilamentEvent, Message
|
||||
from spoolman.api.v1.models import Filament, FilamentEvent, Message, MultiColorDirection
|
||||
from spoolman.database import filament
|
||||
from spoolman.database.database import get_db_session
|
||||
from spoolman.database.utils import SortOrder
|
||||
@@ -32,84 +30,161 @@ router = APIRouter(
|
||||
|
||||
class FilamentParameters(BaseModel):
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description=(
|
||||
"Filament name, to distinguish this filament type among others from the same vendor."
|
||||
"Should contain its color for example."
|
||||
),
|
||||
example="PolyTerra™ Charcoal Black",
|
||||
examples=["PolyTerra™ Charcoal Black"],
|
||||
)
|
||||
vendor_id: Optional[int] = Field(description="The ID of the vendor of this filament type.")
|
||||
vendor_id: Optional[int] = Field(None, description="The ID of the vendor of this filament type.")
|
||||
material: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="The material of this filament, e.g. PLA.",
|
||||
example="PLA",
|
||||
examples=["PLA"],
|
||||
)
|
||||
price: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The price of this filament in the system configured currency.",
|
||||
example=20.0,
|
||||
examples=[20.0],
|
||||
)
|
||||
density: float = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
|
||||
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
|
||||
density: float = Field(gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
|
||||
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", examples=[1.75])
|
||||
weight: Optional[float] = Field(
|
||||
None,
|
||||
gt=0,
|
||||
description="The weight of the filament in a full spool, in grams. (net weight)",
|
||||
example=1000,
|
||||
examples=[1000],
|
||||
)
|
||||
spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
|
||||
spool_weight: Optional[float] = Field(None, ge=0, description="The empty spool weight, in grams.", examples=[140])
|
||||
article_number: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Vendor article number, e.g. EAN, QR code, etc.",
|
||||
example="PM70820",
|
||||
examples=["PM70820"],
|
||||
)
|
||||
comment: Optional[str] = Field(
|
||||
None,
|
||||
max_length=1024,
|
||||
description="Free text comment about this filament type.",
|
||||
example="",
|
||||
examples=[""],
|
||||
)
|
||||
settings_extruder_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden extruder temperature, in °C.",
|
||||
example=210,
|
||||
examples=[210],
|
||||
)
|
||||
settings_bed_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden bed temperature, in °C.",
|
||||
example=60,
|
||||
examples=[60],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
|
||||
example="FF0000",
|
||||
None,
|
||||
description=(
|
||||
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
|
||||
"If it's a multi-color filament, the multi_color_hexes field is used instead."
|
||||
),
|
||||
examples=["FF0000"],
|
||||
)
|
||||
multi_color_hexes: Optional[str] = Field(
|
||||
None,
|
||||
description=(
|
||||
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
|
||||
"Specifying multiple colors separated by commas. "
|
||||
"Also set the multi_color_direction field if you specify multiple colors."
|
||||
),
|
||||
examples=["FF0000,00FF00,0000FF"],
|
||||
)
|
||||
multi_color_direction: Optional[MultiColorDirection] = Field(
|
||||
None,
|
||||
description=("Type of multi-color filament. Only set if the color_hex field contains multiple colors. "),
|
||||
examples=["coaxial", "longitudinal"],
|
||||
)
|
||||
external_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=256,
|
||||
description=(
|
||||
"Set if this filament comes from an external database. This contains the ID in the external database."
|
||||
),
|
||||
examples=["polymaker_pla_polysonicblack_1000_175"],
|
||||
)
|
||||
extra: Optional[dict[str, str]] = Field(
|
||||
None,
|
||||
description="Extra fields for this filament.",
|
||||
)
|
||||
|
||||
@validator("color_hex")
|
||||
@field_validator("color_hex")
|
||||
@classmethod
|
||||
def color_hex_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
|
||||
"""Validate the color_hex field."""
|
||||
if not v:
|
||||
return None
|
||||
if v.startswith("#"):
|
||||
v = v[1:]
|
||||
v = v.upper()
|
||||
|
||||
for c in v:
|
||||
clr = v.upper()
|
||||
if clr.startswith("#"):
|
||||
clr = clr[1:]
|
||||
|
||||
for c in clr:
|
||||
if c not in "0123456789ABCDEF":
|
||||
raise ValueError("Invalid character in color code.")
|
||||
|
||||
if len(v) not in (6, 8):
|
||||
if len(clr) not in (6, 8):
|
||||
raise ValueError("Color code must be 6 or 8 characters long.")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("multi_color_hexes")
|
||||
@classmethod
|
||||
def multi_color_hexes_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
|
||||
"""Validate the multi_color_hexes field."""
|
||||
if not v:
|
||||
return None
|
||||
for clr_raw in v.split(","):
|
||||
clr = clr_raw.upper()
|
||||
if clr.startswith("#"):
|
||||
clr = clr[1:]
|
||||
|
||||
for c in clr:
|
||||
if c not in "0123456789ABCDEF":
|
||||
raise ValueError("Invalid character in color code.")
|
||||
|
||||
if len(clr) not in (6, 8):
|
||||
raise ValueError("Color code must be 6 or 8 characters long.")
|
||||
|
||||
return v
|
||||
|
||||
@model_validator(mode="after") # type: ignore[]
|
||||
def validate(self) -> "FilamentParameters":
|
||||
"""Validate the model."""
|
||||
if self.color_hex and self.multi_color_hexes:
|
||||
raise ValueError("Cannot specify both color_hex and multi_color_hexes.")
|
||||
if self.multi_color_hexes and len(self.multi_color_hexes.split(",")) < 2: # noqa: PLR2004
|
||||
raise ValueError("Must specify at least two colors in multi_color_hexes.")
|
||||
if self.multi_color_hexes and not self.multi_color_direction:
|
||||
raise ValueError("Multi-color filament must have multi_color_direction set.")
|
||||
if not self.multi_color_hexes and self.multi_color_direction:
|
||||
raise ValueError("Single-color filament must not have multi_color_direction set.")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class FilamentUpdateParameters(FilamentParameters):
|
||||
density: Optional[float] = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
|
||||
diameter: Optional[float] = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
|
||||
density: Optional[float] = Field(None, gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
|
||||
diameter: Optional[float] = Field(None, gt=0, description="The diameter of this filament in mm.", examples=[1.75])
|
||||
|
||||
@field_validator("density", "diameter")
|
||||
@classmethod
|
||||
def prevent_none(cls: type["FilamentUpdateParameters"], v: Optional[float]) -> Optional[float]:
|
||||
"""Prevent density and diameter from being None."""
|
||||
if v is None:
|
||||
raise ValueError("Value must not be None.")
|
||||
return v
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -123,7 +198,6 @@ class FilamentUpdateParameters(FilamentParameters):
|
||||
response_model_exclude_none=True,
|
||||
responses={
|
||||
200: {"model": list[Filament]},
|
||||
404: {"model": Message},
|
||||
299: {"model": FilamentEvent, "description": "Websocket message"},
|
||||
},
|
||||
)
|
||||
@@ -143,6 +217,7 @@ async def find(
|
||||
title="Vendor ID",
|
||||
description="See vendor.id.",
|
||||
deprecated=True,
|
||||
pattern=r"^-?\d+(,-?\d+)*$",
|
||||
),
|
||||
vendor_name: Optional[str] = Query(
|
||||
alias="vendor.name",
|
||||
@@ -150,7 +225,8 @@ async def find(
|
||||
title="Vendor Name",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament vendor name. "
|
||||
"Separate multiple terms with a comma. Specify an empty string to match filaments with no vendor name."
|
||||
"Separate multiple terms with a comma. Specify an empty string to match filaments with no vendor name. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
vendor_id: Optional[str] = Query(
|
||||
@@ -161,6 +237,7 @@ async def find(
|
||||
"Match an exact vendor ID. Separate multiple IDs with a comma. "
|
||||
"Specify -1 to match filaments with no vendor."
|
||||
),
|
||||
pattern=r"^-?\d+(,-?\d+)*$",
|
||||
examples=["1", "1,2"],
|
||||
),
|
||||
name: Optional[str] = Query(
|
||||
@@ -168,7 +245,8 @@ async def find(
|
||||
title="Filament Name",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament name. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match filaments with no name."
|
||||
"Specify an empty string to match filaments with no name. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
material: Optional[str] = Query(
|
||||
@@ -176,7 +254,8 @@ async def find(
|
||||
title="Filament Material",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament material. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match filaments with no material."
|
||||
"Specify an empty string to match filaments with no material. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
article_number: Optional[str] = Query(
|
||||
@@ -185,7 +264,8 @@ async def find(
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament article number. "
|
||||
"Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match filaments with no article number."
|
||||
"Specify an empty string to match filaments with no article number. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
color_hex: Optional[str] = Query(
|
||||
@@ -201,6 +281,16 @@ async def find(
|
||||
),
|
||||
example=20.0,
|
||||
),
|
||||
external_id: Optional[str] = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Find filaments imported by the given external ID. "
|
||||
"Separate multiple IDs with a comma. "
|
||||
"Specify empty string to match filaments with no external ID. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
example="polymaker_pla_polysonicblack_1000_175",
|
||||
),
|
||||
sort: Optional[str] = Query(
|
||||
default=None,
|
||||
title="Sort",
|
||||
@@ -228,10 +318,7 @@ async def find(
|
||||
|
||||
vendor_id = vendor_id if vendor_id is not None else vendor_id_old
|
||||
if vendor_id is not None:
|
||||
try:
|
||||
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
|
||||
except ValueError as e:
|
||||
raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e
|
||||
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
|
||||
else:
|
||||
vendor_ids = None
|
||||
|
||||
@@ -253,6 +340,7 @@ async def find(
|
||||
name=name,
|
||||
material=material,
|
||||
article_number=article_number,
|
||||
external_id=external_id,
|
||||
sort_by=sort_by,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -357,6 +445,9 @@ async def create( # noqa: ANN201
|
||||
settings_extruder_temp=body.settings_extruder_temp,
|
||||
settings_bed_temp=body.settings_bed_temp,
|
||||
color_hex=body.color_hex,
|
||||
multi_color_hexes=body.multi_color_hexes,
|
||||
multi_color_direction=body.multi_color_direction,
|
||||
external_id=body.external_id,
|
||||
extra=body.extra,
|
||||
)
|
||||
|
||||
@@ -382,12 +473,7 @@ async def update( # noqa: ANN201
|
||||
filament_id: int,
|
||||
body: FilamentUpdateParameters,
|
||||
):
|
||||
patch_data = body.dict(exclude_unset=True)
|
||||
|
||||
if "density" in patch_data and body.density is None:
|
||||
raise RequestValidationError([ErrorWrapper(ValueError("density cannot be unset"), ("query", "density"))])
|
||||
if "diameter" in patch_data and body.diameter is None:
|
||||
raise RequestValidationError([ErrorWrapper(ValueError("diameter cannot be unset"), ("query", "diameter"))])
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if body.extra:
|
||||
all_fields = await get_extra_fields(db, EntityType.filament)
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Literal, Optional
|
||||
from typing import Annotated, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel, Field, PlainSerializer
|
||||
|
||||
from spoolman.database import models
|
||||
from spoolman.math import length_from_weight
|
||||
@@ -19,13 +18,7 @@ def datetime_to_str(dt: datetime) -> str:
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
class BaseModel(PydanticBaseModel):
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
|
||||
json_encoders = { # noqa: RUF012
|
||||
datetime: datetime_to_str,
|
||||
}
|
||||
SpoolmanDateTime = Annotated[datetime, PlainSerializer(datetime_to_str)]
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -57,9 +50,28 @@ class SettingKV(BaseModel):
|
||||
|
||||
class Vendor(BaseModel):
|
||||
id: int = Field(description="Unique internal ID of this vendor.")
|
||||
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="")
|
||||
registered: SpoolmanDateTime = Field(description="When the vendor was registered in the database. UTC Timezone.")
|
||||
name: str = Field(max_length=64, description="Vendor name.", examples=["Polymaker"])
|
||||
comment: Optional[str] = Field(
|
||||
None,
|
||||
max_length=1024,
|
||||
description="Free text comment about this vendor.",
|
||||
examples=[""],
|
||||
)
|
||||
empty_spool_weight: Optional[float] = Field(
|
||||
None,
|
||||
gt=0,
|
||||
description="The empty spool weight, in grams.",
|
||||
examples=[140],
|
||||
)
|
||||
external_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=256,
|
||||
description=(
|
||||
"Set if this vendor comes from an external database. This contains the ID in the external database."
|
||||
),
|
||||
examples=["eSun"],
|
||||
)
|
||||
extra: dict[str, str] = Field(
|
||||
description=(
|
||||
"Extra fields for this vendor. All values are JSON-encoded data. "
|
||||
@@ -75,65 +87,109 @@ class Vendor(BaseModel):
|
||||
registered=item.registered,
|
||||
name=item.name,
|
||||
comment=item.comment,
|
||||
empty_spool_weight=item.empty_spool_weight,
|
||||
external_id=item.external_id,
|
||||
extra={field.key: field.value for field in item.extra},
|
||||
)
|
||||
|
||||
|
||||
class MultiColorDirection(Enum):
|
||||
"""Enum for multi-color direction."""
|
||||
|
||||
COAXIAL = "coaxial"
|
||||
LONGITUDINAL = "longitudinal"
|
||||
|
||||
|
||||
class Filament(BaseModel):
|
||||
id: int = Field(description="Unique internal ID of this filament type.")
|
||||
registered: datetime = Field(description="When the filament was registered in the database. UTC Timezone.")
|
||||
registered: SpoolmanDateTime = Field(description="When the filament was registered in the database. UTC Timezone.")
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description=(
|
||||
"Filament name, to distinguish this filament type among others from the same vendor."
|
||||
"Should contain its color for example."
|
||||
),
|
||||
example="PolyTerra™ Charcoal Black",
|
||||
examples=["PolyTerra™ Charcoal Black"],
|
||||
)
|
||||
vendor: Optional[Vendor] = Field(description="The vendor of this filament type.")
|
||||
vendor: Optional[Vendor] = Field(None, description="The vendor of this filament type.")
|
||||
material: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="The material of this filament, e.g. PLA.",
|
||||
example="PLA",
|
||||
examples=["PLA"],
|
||||
)
|
||||
price: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The price of this filament in the system configured currency.",
|
||||
example=20.0,
|
||||
examples=[20.0],
|
||||
)
|
||||
density: float = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
|
||||
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
|
||||
density: float = Field(gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
|
||||
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", examples=[1.75])
|
||||
weight: Optional[float] = Field(
|
||||
None,
|
||||
gt=0,
|
||||
description="The weight of the filament in a full spool, in grams.",
|
||||
example=1000,
|
||||
examples=[1000],
|
||||
)
|
||||
spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
|
||||
spool_weight: Optional[float] = Field(None, ge=0, description="The empty spool weight, in grams.", examples=[140])
|
||||
article_number: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Vendor article number, e.g. EAN, QR code, etc.",
|
||||
example="PM70820",
|
||||
examples=["PM70820"],
|
||||
)
|
||||
comment: Optional[str] = Field(
|
||||
None,
|
||||
max_length=1024,
|
||||
description="Free text comment about this filament type.",
|
||||
example="",
|
||||
examples=[""],
|
||||
)
|
||||
settings_extruder_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden extruder temperature, in °C.",
|
||||
example=210,
|
||||
examples=[210],
|
||||
)
|
||||
settings_bed_temp: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Overridden bed temperature, in °C.",
|
||||
example=60,
|
||||
examples=[60],
|
||||
)
|
||||
color_hex: Optional[str] = Field(
|
||||
None,
|
||||
min_length=6,
|
||||
max_length=8,
|
||||
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
|
||||
example="FF0000",
|
||||
description=(
|
||||
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
|
||||
"If it's a multi-color filament, the multi_color_hexes field is used instead."
|
||||
),
|
||||
examples=["FF0000"],
|
||||
)
|
||||
multi_color_hexes: Optional[str] = Field(
|
||||
None,
|
||||
min_length=6,
|
||||
description=(
|
||||
"Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end. "
|
||||
"Specifying multiple colors separated by commas. "
|
||||
"Also set the multi_color_direction field if you specify multiple colors."
|
||||
),
|
||||
examples=["FF0000,00FF00,0000FF"],
|
||||
)
|
||||
multi_color_direction: Optional[MultiColorDirection] = Field(
|
||||
None,
|
||||
description=("Type of multi-color filament. Only set if the multi_color_hexes field is set."),
|
||||
examples=["coaxial", "longitudinal"],
|
||||
)
|
||||
external_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=256,
|
||||
description=(
|
||||
"Set if this filament comes from an external database. This contains the ID in the external database."
|
||||
),
|
||||
examples=["polymaker_pla_polysonicblack_1000_175"],
|
||||
)
|
||||
extra: dict[str, str] = Field(
|
||||
description=(
|
||||
@@ -161,20 +217,32 @@ class Filament(BaseModel):
|
||||
settings_extruder_temp=item.settings_extruder_temp,
|
||||
settings_bed_temp=item.settings_bed_temp,
|
||||
color_hex=item.color_hex,
|
||||
multi_color_hexes=item.multi_color_hexes,
|
||||
multi_color_direction=(
|
||||
MultiColorDirection(item.multi_color_direction) if item.multi_color_direction is not None else None
|
||||
),
|
||||
external_id=item.external_id,
|
||||
extra={field.key: field.value for field in item.extra},
|
||||
)
|
||||
|
||||
|
||||
class Spool(BaseModel):
|
||||
id: int = Field(description="Unique internal ID of this spool of filament.")
|
||||
registered: datetime = Field(description="When the spool was registered in the database. UTC Timezone.")
|
||||
first_used: Optional[datetime] = Field(description="First logged occurence of spool usage. UTC Timezone.")
|
||||
last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage. UTC Timezone.")
|
||||
registered: SpoolmanDateTime = Field(description="When the spool was registered in the database. UTC Timezone.")
|
||||
first_used: Optional[SpoolmanDateTime] = Field(
|
||||
None,
|
||||
description="First logged occurence of spool usage. UTC Timezone.",
|
||||
)
|
||||
last_used: Optional[SpoolmanDateTime] = Field(
|
||||
None,
|
||||
description="Last logged occurence of spool usage. UTC Timezone.",
|
||||
)
|
||||
filament: Filament = Field(description="The filament type of this spool.")
|
||||
price: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The price of this spool in the system configured currency.",
|
||||
example=20.0,
|
||||
examples=[20.0],
|
||||
)
|
||||
remaining_weight: Optional[float] = Field(
|
||||
default=None,
|
||||
@@ -183,9 +251,25 @@ class Spool(BaseModel):
|
||||
"Estimated remaining weight of filament on the spool in grams. "
|
||||
"Only set if the filament type has a weight set."
|
||||
),
|
||||
example=500.6,
|
||||
examples=[500.6],
|
||||
)
|
||||
initial_weight: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description=("The initial weight, in grams, of the filament on the spool (net weight)."),
|
||||
examples=[1246],
|
||||
)
|
||||
spool_weight: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
description=("Weight of an empty spool (tare weight)."),
|
||||
examples=[246],
|
||||
)
|
||||
used_weight: float = Field(
|
||||
ge=0,
|
||||
description="Consumed weight of filament from the spool in grams.",
|
||||
examples=[500.3],
|
||||
)
|
||||
used_weight: float = Field(ge=0, description="Consumed weight of filament from the spool in grams.", example=500.3)
|
||||
remaining_length: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
@@ -193,23 +277,30 @@ class Spool(BaseModel):
|
||||
"Estimated remaining length of filament on the spool in millimeters."
|
||||
" Only set if the filament type has a weight set."
|
||||
),
|
||||
example=5612.4,
|
||||
examples=[5612.4],
|
||||
)
|
||||
used_length: float = Field(
|
||||
ge=0,
|
||||
description="Consumed length of filament from the spool in millimeters.",
|
||||
example=50.7,
|
||||
examples=[50.7],
|
||||
)
|
||||
location: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Where this spool can be found.",
|
||||
examples=["Shelf A"],
|
||||
)
|
||||
location: Optional[str] = Field(max_length=64, description="Where this spool can be found.", example="Shelf A")
|
||||
lot_nr: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Vendor manufacturing lot/batch number of the spool.",
|
||||
example="52342",
|
||||
examples=["52342"],
|
||||
)
|
||||
comment: Optional[str] = Field(
|
||||
None,
|
||||
max_length=1024,
|
||||
description="Free text comment about this specific spool.",
|
||||
example="",
|
||||
examples=[""],
|
||||
)
|
||||
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
|
||||
extra: dict[str, str] = Field(
|
||||
@@ -226,7 +317,15 @@ class Spool(BaseModel):
|
||||
|
||||
remaining_weight: Optional[float] = None
|
||||
remaining_length: Optional[float] = None
|
||||
if filament.weight is not None:
|
||||
|
||||
if item.initial_weight is not None:
|
||||
remaining_weight = max(item.initial_weight - item.used_weight, 0)
|
||||
remaining_length = length_from_weight(
|
||||
weight=remaining_weight,
|
||||
density=filament.density,
|
||||
diameter=filament.diameter,
|
||||
)
|
||||
elif filament.weight is not None:
|
||||
remaining_weight = max(filament.weight - item.used_weight, 0)
|
||||
remaining_length = length_from_weight(
|
||||
weight=remaining_weight,
|
||||
@@ -247,6 +346,8 @@ class Spool(BaseModel):
|
||||
last_used=item.last_used,
|
||||
filament=filament,
|
||||
price=item.price,
|
||||
initial_weight=item.initial_weight,
|
||||
spool_weight=item.spool_weight,
|
||||
used_weight=item.used_weight,
|
||||
used_length=used_length,
|
||||
remaining_weight=remaining_weight,
|
||||
@@ -260,26 +361,26 @@ class Spool(BaseModel):
|
||||
|
||||
|
||||
class Info(BaseModel):
|
||||
version: str = Field(example="0.7.0")
|
||||
debug_mode: bool = Field(example=False)
|
||||
automatic_backups: bool = Field(example=True)
|
||||
data_dir: str = Field(example="/home/app/.local/share/spoolman")
|
||||
logs_dir: str = Field(example="/home/app/.local/share/spoolman")
|
||||
backups_dir: str = Field(example="/home/app/.local/share/spoolman/backups")
|
||||
db_type: str = Field(example="sqlite")
|
||||
git_commit: Optional[str] = Field(example="a1b2c3d")
|
||||
build_date: Optional[datetime] = Field(example="2021-01-01T00:00:00Z")
|
||||
version: str = Field(examples=["0.7.0"])
|
||||
debug_mode: bool = Field(examples=[False])
|
||||
automatic_backups: bool = Field(examples=[True])
|
||||
data_dir: str = Field(examples=["/home/app/.local/share/spoolman"])
|
||||
logs_dir: str = Field(examples=["/home/app/.local/share/spoolman"])
|
||||
backups_dir: str = Field(examples=["/home/app/.local/share/spoolman/backups"])
|
||||
db_type: str = Field(examples=["sqlite"])
|
||||
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
|
||||
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
status: str = Field(example="healthy")
|
||||
status: str = Field(examples=["healthy"])
|
||||
|
||||
|
||||
class BackupResponse(BaseModel):
|
||||
path: str = Field(
|
||||
default=None,
|
||||
description="Path to the created backup file.",
|
||||
example="/home/app/.local/share/spoolman/backups/spoolman.db",
|
||||
examples=["/home/app/.local/share/spoolman/backups/spoolman.db"],
|
||||
)
|
||||
|
||||
|
||||
@@ -296,7 +397,7 @@ class Event(BaseModel):
|
||||
|
||||
type: EventType = Field(description="Event type.")
|
||||
resource: str = Field(description="Resource type.")
|
||||
date: datetime = Field(description="When the event occured. UTC Timezone.")
|
||||
date: SpoolmanDateTime = Field(description="When the event occured. UTC Timezone.")
|
||||
payload: BaseModel
|
||||
|
||||
|
||||
|
||||
@@ -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 field, filament, models, other, setting, spool, vendor
|
||||
from . import externaldb, field, filament, models, other, setting, spool, vendor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,3 +110,4 @@ app.include_router(vendor.router)
|
||||
app.include_router(setting.router)
|
||||
app.include_router(field.router)
|
||||
app.include_router(other.router)
|
||||
app.include_router(externaldb.router)
|
||||
|
||||
@@ -7,17 +7,15 @@ from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.error_wrappers import ErrorWrapper
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Message, Spool, SpoolEvent
|
||||
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.exceptions import ItemCreateError, SpoolMeasureError
|
||||
from spoolman.extra_fields import EntityType, get_extra_fields, validate_extra_field_dict
|
||||
from spoolman.ws import websocket_manager
|
||||
|
||||
@@ -32,32 +30,58 @@ router = APIRouter(
|
||||
|
||||
|
||||
class SpoolParameters(BaseModel):
|
||||
first_used: Optional[datetime] = Field(description="First logged occurence of spool usage.")
|
||||
last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage.")
|
||||
first_used: Optional[datetime] = Field(None, description="First logged occurence of spool usage.")
|
||||
last_used: Optional[datetime] = Field(None, description="Last logged occurence of spool usage.")
|
||||
filament_id: int = Field(description="The ID of the filament type of this spool.")
|
||||
price: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The price of this filament in the system configured currency.",
|
||||
example=20.0,
|
||||
examples=[20.0],
|
||||
)
|
||||
initial_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The initial weight of the filament on the spool, in grams. (net weight)",
|
||||
examples=[200],
|
||||
)
|
||||
spool_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The weight of an empty spool, in grams. (tare weight)",
|
||||
examples=[200],
|
||||
)
|
||||
remaining_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description=(
|
||||
"Remaining weight of filament on the spool. Can only be used if the filament type has a weight set."
|
||||
),
|
||||
example=800,
|
||||
examples=[800],
|
||||
)
|
||||
used_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="Used weight of filament on the spool.",
|
||||
examples=[200],
|
||||
)
|
||||
location: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Where this spool can be found.",
|
||||
examples=["Shelf A"],
|
||||
)
|
||||
used_weight: Optional[float] = Field(ge=0, description="Used weight of filament on the spool.", example=200)
|
||||
location: Optional[str] = Field(max_length=64, description="Where this spool can be found.", example="Shelf A")
|
||||
lot_nr: Optional[str] = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="Vendor manufacturing lot/batch number of the spool.",
|
||||
example="52342",
|
||||
examples=["52342"],
|
||||
)
|
||||
comment: Optional[str] = Field(
|
||||
None,
|
||||
max_length=1024,
|
||||
description="Free text comment about this specific spool.",
|
||||
example="",
|
||||
examples=[""],
|
||||
)
|
||||
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
|
||||
extra: Optional[dict[str, str]] = Field(
|
||||
@@ -67,12 +91,24 @@ class SpoolParameters(BaseModel):
|
||||
|
||||
|
||||
class SpoolUpdateParameters(SpoolParameters):
|
||||
filament_id: Optional[int] = Field(description="The ID of the filament type of this spool.")
|
||||
filament_id: Optional[int] = Field(None, description="The ID of the filament type of this spool.")
|
||||
|
||||
@field_validator("filament_id")
|
||||
@classmethod
|
||||
def prevent_none(cls: type["SpoolUpdateParameters"], v: Optional[int]) -> Optional[int]:
|
||||
"""Prevent filament_id from being None."""
|
||||
if v is None:
|
||||
raise ValueError("Value must not be None.")
|
||||
return v
|
||||
|
||||
|
||||
class SpoolUseParameters(BaseModel):
|
||||
use_length: Optional[float] = Field(description="Length of filament to reduce by, in mm.", example=2.2)
|
||||
use_weight: Optional[float] = Field(description="Filament weight to reduce by, in g.", example=5.3)
|
||||
use_length: Optional[float] = Field(None, description="Length of filament to reduce by, in mm.", examples=[2.2])
|
||||
use_weight: Optional[float] = Field(None, description="Filament weight to reduce by, in g.", examples=[5.3])
|
||||
|
||||
|
||||
class SpoolMeasureParameters(BaseModel):
|
||||
weight: float = Field(description="Current gross weight of the spool, in g.", examples=[200])
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -86,7 +122,6 @@ class SpoolUseParameters(BaseModel):
|
||||
response_model_exclude_none=True,
|
||||
responses={
|
||||
200: {"model": list[Spool]},
|
||||
404: {"model": Message},
|
||||
299: {"model": SpoolEvent, "description": "Websocket message"},
|
||||
},
|
||||
)
|
||||
@@ -106,6 +141,7 @@ async def find(
|
||||
title="Filament ID",
|
||||
description="See filament.id.",
|
||||
deprecated=True,
|
||||
pattern=r"^-?\d+(,-?\d+)*$",
|
||||
),
|
||||
filament_material_old: Optional[str] = Query(
|
||||
alias="filament_material",
|
||||
@@ -127,14 +163,16 @@ async def find(
|
||||
title="Vendor ID",
|
||||
description="See filament.vendor.id.",
|
||||
deprecated=True,
|
||||
pattern=r"^-?\d+(,-?\d+)*$",
|
||||
),
|
||||
filament_name: Optional[str] = Query(
|
||||
alias="filament.name",
|
||||
default=None,
|
||||
title="Filament Name",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament name. Separate multiple terms with a comma."
|
||||
" Specify an empty string to match spools with no filament name."
|
||||
"Partial case-insensitive search term for the filament name. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match spools with no filament name. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
filament_id: Optional[str] = Query(
|
||||
@@ -143,6 +181,7 @@ async def find(
|
||||
title="Filament ID",
|
||||
description="Match an exact filament ID. Separate multiple IDs with a comma.",
|
||||
examples=["1", "1,2"],
|
||||
pattern=r"^-?\d+(,-?\d+)*$",
|
||||
),
|
||||
filament_material: Optional[str] = Query(
|
||||
alias="filament.material",
|
||||
@@ -150,7 +189,8 @@ async def find(
|
||||
title="Filament Material",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament material. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match spools with no filament material."
|
||||
"Specify an empty string to match spools with no filament material. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
filament_vendor_name: Optional[str] = Query(
|
||||
@@ -159,7 +199,8 @@ async def find(
|
||||
title="Vendor Name",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the filament vendor name. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match spools with no vendor name."
|
||||
"Specify an empty string to match spools with no vendor name. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
filament_vendor_id: Optional[str] = Query(
|
||||
@@ -171,13 +212,15 @@ async def find(
|
||||
"Set it to -1 to match spools with filaments with no vendor."
|
||||
),
|
||||
examples=["1", "1,2"],
|
||||
pattern=r"^-?\d+(,-?\d+)*$",
|
||||
),
|
||||
location: Optional[str] = Query(
|
||||
default=None,
|
||||
title="Location",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the spool location. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match spools with no location."
|
||||
"Specify an empty string to match spools with no location. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
lot_nr: Optional[str] = Query(
|
||||
@@ -185,7 +228,8 @@ async def find(
|
||||
title="Lot/Batch Number",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the spool lot number. Separate multiple terms with a comma. "
|
||||
"Specify an empty string to match spools with no lot nr."
|
||||
"Specify an empty string to match spools with no lot nr. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
allow_archived: bool = Query(
|
||||
@@ -220,21 +264,13 @@ async def find(
|
||||
|
||||
filament_id = filament_id if filament_id is not None else filament_id_old
|
||||
if filament_id is not None:
|
||||
try:
|
||||
filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")]
|
||||
except ValueError as e:
|
||||
raise RequestValidationError(
|
||||
[ErrorWrapper(ValueError("Invalid filament_id"), ("query", "filament_id"))],
|
||||
) from e
|
||||
filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")]
|
||||
else:
|
||||
filament_ids = None
|
||||
|
||||
filament_vendor_id = filament_vendor_id if filament_vendor_id is not None else vendor_id_old
|
||||
if filament_vendor_id is not None:
|
||||
try:
|
||||
filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")]
|
||||
except ValueError as e:
|
||||
raise RequestValidationError([ErrorWrapper(ValueError("Invalid vendor_id"), ("query", "vendor_id"))]) from e
|
||||
filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")]
|
||||
else:
|
||||
filament_vendor_ids = None
|
||||
|
||||
@@ -354,6 +390,8 @@ async def create( # noqa: ANN201
|
||||
db=db,
|
||||
filament_id=body.filament_id,
|
||||
price=body.price,
|
||||
initial_weight=body.initial_weight,
|
||||
spool_weight=body.spool_weight,
|
||||
remaining_weight=body.remaining_weight,
|
||||
used_weight=body.used_weight,
|
||||
first_used=body.first_used,
|
||||
@@ -394,7 +432,7 @@ async def update( # noqa: ANN201
|
||||
spool_id: int,
|
||||
body: SpoolUpdateParameters,
|
||||
):
|
||||
patch_data = body.dict(exclude_unset=True)
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if body.remaining_weight is not None and body.used_weight is not None:
|
||||
return JSONResponse(
|
||||
@@ -409,11 +447,6 @@ async def update( # noqa: ANN201
|
||||
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"))],
|
||||
)
|
||||
|
||||
try:
|
||||
db_item = await spool.update(
|
||||
db=db,
|
||||
@@ -480,3 +513,30 @@ async def use( # noqa: ANN201
|
||||
status_code=400,
|
||||
content={"message": "Either use_weight or use_length must be specified."},
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{spool_id}/measure",
|
||||
name="Use spool filament based on the current weight measurement",
|
||||
description=("Use some weight of filament from the spool. Specify the current gross weight of the spool."),
|
||||
response_model_exclude_none=True,
|
||||
response_model=Spool,
|
||||
responses={
|
||||
400: {"model": Message},
|
||||
404: {"model": Message},
|
||||
},
|
||||
)
|
||||
async def measure( # noqa: ANN201
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)],
|
||||
spool_id: int,
|
||||
body: SpoolMeasureParameters,
|
||||
):
|
||||
try:
|
||||
db_item = await spool.measure(db, spool_id, body.weight)
|
||||
return Spool.from_db(db_item)
|
||||
except SpoolMeasureError as e:
|
||||
logger.exception("Failed to update spool measurement.")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"message": e.args[0]},
|
||||
)
|
||||
|
||||
@@ -5,10 +5,8 @@ from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic.error_wrappers import ErrorWrapper
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import Message, Vendor, VendorEvent
|
||||
@@ -27,11 +25,26 @@ router = APIRouter(
|
||||
|
||||
|
||||
class VendorParameters(BaseModel):
|
||||
name: str = Field(max_length=64, description="Vendor name.", example="Polymaker")
|
||||
name: str = Field(max_length=64, description="Vendor name.", examples=["Polymaker"])
|
||||
comment: Optional[str] = Field(
|
||||
None,
|
||||
max_length=1024,
|
||||
description="Free text comment about this vendor.",
|
||||
example="",
|
||||
examples=[""],
|
||||
)
|
||||
empty_spool_weight: Optional[float] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="The weight of an empty spool, in grams.",
|
||||
examples=[200],
|
||||
)
|
||||
external_id: Optional[str] = Field(
|
||||
None,
|
||||
max_length=256,
|
||||
description=(
|
||||
"Set if this vendor comes from an external database. This contains the ID in the external database."
|
||||
),
|
||||
examples=["eSun"],
|
||||
)
|
||||
extra: Optional[dict[str, str]] = Field(
|
||||
None,
|
||||
@@ -40,12 +53,15 @@ class VendorParameters(BaseModel):
|
||||
|
||||
|
||||
class VendorUpdateParameters(VendorParameters):
|
||||
name: Optional[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="",
|
||||
)
|
||||
name: Optional[str] = Field(None, max_length=64, description="Vendor name.", examples=["Polymaker"])
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def prevent_none(cls: type["VendorUpdateParameters"], v: Optional[str]) -> Optional[str]:
|
||||
"""Prevent name from being None."""
|
||||
if v is None:
|
||||
raise ValueError("Value must not be None.")
|
||||
return v
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -59,7 +75,6 @@ class VendorUpdateParameters(VendorParameters):
|
||||
response_model_exclude_none=True,
|
||||
responses={
|
||||
200: {"model": list[Vendor]},
|
||||
404: {"model": Message},
|
||||
299: {"model": VendorEvent, "description": "Websocket message"},
|
||||
},
|
||||
)
|
||||
@@ -68,7 +83,20 @@ async def find(
|
||||
name: Optional[str] = Query(
|
||||
default=None,
|
||||
title="Vendor Name",
|
||||
description="Partial case-insensitive search term for the vendor name. Separate multiple terms with a comma.",
|
||||
description=(
|
||||
"Partial case-insensitive search term for the vendor name. Separate multiple terms with a comma. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
external_id: Optional[str] = Query(
|
||||
default=None,
|
||||
title="Vendor External ID",
|
||||
description=(
|
||||
"Exact match for the vendor external ID. "
|
||||
"Separate multiple IDs with a comma. "
|
||||
"Specify empty string to match filaments with no external ID. "
|
||||
"Surround a term with quotes to search for the exact term."
|
||||
),
|
||||
),
|
||||
sort: Optional[str] = Query(
|
||||
default=None,
|
||||
@@ -98,6 +126,7 @@ async def find(
|
||||
db_items, total_count = await vendor.find(
|
||||
db=db,
|
||||
name=name,
|
||||
external_id=external_id,
|
||||
sort_by=sort_by,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
@@ -184,12 +213,14 @@ async def create( # noqa: ANN201
|
||||
try:
|
||||
validate_extra_field_dict(all_fields, body.extra)
|
||||
except ValueError as e:
|
||||
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
|
||||
return JSONResponse(status_code=400, content=Message(message=str(e)).model_dump())
|
||||
|
||||
db_item = await vendor.create(
|
||||
db=db,
|
||||
name=body.name,
|
||||
comment=body.comment,
|
||||
empty_spool_weight=body.empty_spool_weight,
|
||||
external_id=body.external_id,
|
||||
extra=body.extra,
|
||||
)
|
||||
|
||||
@@ -215,10 +246,7 @@ async def update( # noqa: ANN201
|
||||
vendor_id: int,
|
||||
body: VendorUpdateParameters,
|
||||
):
|
||||
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"))])
|
||||
patch_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
if body.extra:
|
||||
all_fields = await get_extra_fields(db, EntityType.vendor)
|
||||
|
||||
@@ -2,24 +2,83 @@
|
||||
|
||||
# ruff: noqa: PTH118
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Union
|
||||
from collections.abc import MutableMapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.responses import FileResponse, Response
|
||||
from starlette.staticfiles import NotModifiedResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PathLike = Union[str, "os.PathLike[str]"]
|
||||
Scope = MutableMapping[str, Any]
|
||||
|
||||
|
||||
class SinglePageApplication(StaticFiles):
|
||||
"""Serve a single page application."""
|
||||
|
||||
def __init__(self, directory: str) -> None:
|
||||
def __init__(self, directory: str, base_path: str) -> None:
|
||||
"""Construct."""
|
||||
super().__init__(directory=directory, packages=None, html=True, check_dir=True)
|
||||
self.base_path = base_path.removeprefix("/")
|
||||
|
||||
self.load_and_tweak_index_file()
|
||||
|
||||
def load_and_tweak_index_file(self) -> None:
|
||||
"""Load index.html and tweak it by replacing all asset paths."""
|
||||
# Open index.html located in self.directory/index.html
|
||||
if not self.directory:
|
||||
return
|
||||
|
||||
with (Path(self.directory) / "index.html").open() as f:
|
||||
html = f.read()
|
||||
|
||||
# Replace all paths that start with "./" with f"/{self.base_path}"
|
||||
base_path = "/" if len(self.base_path.strip()) == 0 else f"/{self.base_path}/"
|
||||
self.html = html.replace('"./', f'"{base_path}')
|
||||
|
||||
def file_response(
|
||||
self,
|
||||
full_path: PathLike,
|
||||
stat_result: os.stat_result,
|
||||
scope: Scope,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
"""Overriden default file_response.
|
||||
|
||||
Works the same way, but if the client requests any index.html, we will return our tweaked index.html.
|
||||
The tweaked index.html has all asset paths updated with the base path.
|
||||
"""
|
||||
method = scope["method"]
|
||||
request_headers = Headers(scope=scope)
|
||||
|
||||
# If full_path points to a index.html, return our tweaked index.html
|
||||
if Path(full_path).name == "index.html":
|
||||
return Response(self.html, status_code=status_code, media_type="text/html")
|
||||
|
||||
response = FileResponse(full_path, status_code=status_code, stat_result=stat_result, method=method)
|
||||
if self.is_not_modified(response.headers, request_headers):
|
||||
return NotModifiedResponse(response.headers)
|
||||
return response
|
||||
|
||||
def lookup_path(self, path: str) -> tuple[str, Union[os.stat_result, None]]:
|
||||
"""Return index.html if the requested file cannot be found."""
|
||||
path = path.removeprefix(self.base_path).removeprefix("/")
|
||||
|
||||
full_path, stat_result = super().lookup_path(path)
|
||||
|
||||
if stat_result is None:
|
||||
ext = Path(path).suffix
|
||||
# Check if user is looking for some specific non-document file
|
||||
if len(ext) > 1 and ext != ".html":
|
||||
# If so, return 404
|
||||
return ("", None)
|
||||
# Otherwise, they did look for a document, lead them to index.html
|
||||
return super().lookup_path("index.html")
|
||||
|
||||
return (full_path, stat_result)
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import URL
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from spoolman import env
|
||||
from spoolman.prometheus.metrics import filament_metrics, spool_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -119,6 +120,7 @@ class Database:
|
||||
|
||||
Returns:
|
||||
The path to the created backup or None if no backup was created.
|
||||
|
||||
"""
|
||||
if not self.is_file_based_sqlite() or self.connection_url.database is None:
|
||||
logger.info("Skipping backup as the database is not SQLite.")
|
||||
@@ -158,6 +160,7 @@ def setup_db(connection_url: URL) -> None:
|
||||
|
||||
Args:
|
||||
connection_url: The URL to connect to the database.
|
||||
|
||||
"""
|
||||
global __db # noqa: PLW0603
|
||||
__db = Database(connection_url)
|
||||
@@ -169,6 +172,7 @@ async def backup_global_db(num_backups: int = 5) -> Optional[Path]:
|
||||
|
||||
Returns:
|
||||
The path to the created backup or None if no backup was created.
|
||||
|
||||
"""
|
||||
if __db is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
@@ -183,14 +187,28 @@ async def _backup_task() -> Optional[Path]:
|
||||
return __db.backup_and_rotate(env.get_backups_dir(), num_backups=5)
|
||||
|
||||
|
||||
async def _metrics() -> None:
|
||||
"""Create some useful prometheus metrics."""
|
||||
logger.debug("Start metrics collection")
|
||||
async for session in get_db_session():
|
||||
await filament_metrics(session)
|
||||
await spool_metrics(session)
|
||||
logger.debug("End metrics collection")
|
||||
|
||||
|
||||
def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
"""Schedule tasks to be executed by the provided scheduler.
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to use for scheduling tasks.
|
||||
|
||||
"""
|
||||
if __db is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
if env.is_metrics_enabled():
|
||||
logger.info("Scheduling automatic metric collection.")
|
||||
# Run every minute, may be needs specify timer
|
||||
scheduler.minutely(datetime.time(second=0), _metrics) # type: ignore[arg-type]
|
||||
if not env.is_automatic_backup_enabled():
|
||||
return
|
||||
if "sqlite" in __db.connection_url.drivername:
|
||||
@@ -204,6 +222,7 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
Yields:
|
||||
The database session.
|
||||
|
||||
"""
|
||||
if __db is None or __db.session_maker is None:
|
||||
raise RuntimeError("DB is not setup.")
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
|
||||
from spoolman.api.v1.models import EventType, Filament, FilamentEvent
|
||||
from spoolman.api.v1.models import EventType, Filament, FilamentEvent, MultiColorDirection
|
||||
from spoolman.database import models, vendor
|
||||
from spoolman.database.utils import (
|
||||
SortOrder,
|
||||
@@ -42,12 +42,18 @@ async def create(
|
||||
settings_extruder_temp: Optional[int] = None,
|
||||
settings_bed_temp: Optional[int] = None,
|
||||
color_hex: Optional[str] = None,
|
||||
multi_color_hexes: Optional[str] = None,
|
||||
multi_color_direction: Optional[MultiColorDirection] = None,
|
||||
external_id: Optional[str] = None,
|
||||
extra: Optional[dict[str, str]] = None,
|
||||
) -> models.Filament:
|
||||
"""Add a new filament to the database."""
|
||||
vendor_item: Optional[models.Vendor] = None
|
||||
if vendor_id is not None:
|
||||
vendor_item = await vendor.get_by_id(db, vendor_id)
|
||||
# default spool weight from vendor
|
||||
if spool_weight is None and vendor_item.empty_spool_weight is not None:
|
||||
spool_weight = vendor_item.empty_spool_weight
|
||||
|
||||
filament = models.Filament(
|
||||
name=name,
|
||||
@@ -64,6 +70,9 @@ async def create(
|
||||
settings_extruder_temp=settings_extruder_temp,
|
||||
settings_bed_temp=settings_bed_temp,
|
||||
color_hex=color_hex,
|
||||
multi_color_hexes=multi_color_hexes,
|
||||
multi_color_direction=multi_color_direction.value if multi_color_direction is not None else None,
|
||||
external_id=external_id,
|
||||
extra=[models.FilamentField(key=k, value=v) for k, v in (extra or {}).items()],
|
||||
)
|
||||
db.add(filament)
|
||||
@@ -93,6 +102,7 @@ async def find(
|
||||
name: Optional[str] = None,
|
||||
material: Optional[str] = None,
|
||||
article_number: Optional[str] = None,
|
||||
external_id: Optional[str] = None,
|
||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
@@ -116,6 +126,7 @@ async def find(
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.name, name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.material, material)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.article_number, article_number)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Filament.external_id, external_id)
|
||||
|
||||
total_count = None
|
||||
|
||||
@@ -157,6 +168,8 @@ async def update(
|
||||
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()]
|
||||
elif k == "multi_color_direction":
|
||||
filament.multi_color_direction = v.value if v is not None else None
|
||||
else:
|
||||
setattr(filament, k, v)
|
||||
await db.commit()
|
||||
@@ -224,23 +237,34 @@ async def find_by_color(
|
||||
|
||||
found_filaments: list[models.Filament] = []
|
||||
for filament in filaments:
|
||||
if filament.color_hex is None:
|
||||
if filament.color_hex is not None:
|
||||
colors = [filament.color_hex]
|
||||
elif filament.multi_color_hexes is not None:
|
||||
colors = filament.multi_color_hexes.split(",")
|
||||
else:
|
||||
continue
|
||||
color_lab = rgb_to_lab(hex_to_rgb(filament.color_hex))
|
||||
if delta_e(color_query_lab, color_lab) <= similarity_threshold:
|
||||
found_filaments.append(filament)
|
||||
|
||||
for color in colors:
|
||||
color_lab = rgb_to_lab(hex_to_rgb(color))
|
||||
if delta_e(color_query_lab, color_lab) <= similarity_threshold:
|
||||
found_filaments.append(filament)
|
||||
break
|
||||
|
||||
return found_filaments
|
||||
|
||||
|
||||
async def filament_changed(filament: models.Filament, typ: EventType) -> None:
|
||||
"""Notify websocket clients that a filament has changed."""
|
||||
await websocket_manager.send(
|
||||
("filament", str(filament.id)),
|
||||
FilamentEvent(
|
||||
type=typ,
|
||||
resource="filament",
|
||||
date=datetime.utcnow(),
|
||||
payload=Filament.from_db(filament),
|
||||
),
|
||||
)
|
||||
try:
|
||||
await websocket_manager.send(
|
||||
("filament", str(filament.id)),
|
||||
FilamentEvent(
|
||||
type=typ,
|
||||
resource="filament",
|
||||
date=datetime.utcnow(),
|
||||
payload=Filament.from_db(filament),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Important to have a catch-all here since we don't want to stop the call if this fails.
|
||||
logger.exception("Failed to send websocket message")
|
||||
|
||||
@@ -18,8 +18,10 @@ class Vendor(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
registered: Mapped[datetime] = mapped_column()
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
empty_spool_weight: Mapped[Optional[float]] = mapped_column(comment="The weight of an empty spool.")
|
||||
comment: Mapped[Optional[str]] = mapped_column(String(1024))
|
||||
filaments: Mapped[list["Filament"]] = relationship(back_populates="vendor")
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
extra: Mapped[list["VendorField"]] = relationship(
|
||||
back_populates="vendor",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
@@ -47,6 +49,9 @@ 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))
|
||||
multi_color_hexes: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
multi_color_direction: Mapped[Optional[str]] = mapped_column(String(16))
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(256))
|
||||
extra: Mapped[list["FilamentField"]] = relationship(
|
||||
back_populates="filament",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
@@ -64,6 +69,8 @@ class Spool(Base):
|
||||
price: Mapped[Optional[float]] = mapped_column()
|
||||
filament_id: Mapped[int] = mapped_column(ForeignKey("filament.id"))
|
||||
filament: Mapped["Filament"] = relationship(back_populates="spools")
|
||||
initial_weight: Mapped[Optional[float]] = mapped_column()
|
||||
spool_weight: Mapped[Optional[float]] = mapped_column()
|
||||
used_weight: Mapped[float] = mapped_column()
|
||||
location: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
lot_nr: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Helper functions for interacting with spool database objects."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union
|
||||
@@ -9,6 +10,7 @@ from sqlalchemy import case, func
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager, joinedload
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
|
||||
from spoolman.api.v1.models import EventType, Spool, SpoolEvent
|
||||
from spoolman.database import filament, models
|
||||
@@ -20,10 +22,12 @@ from spoolman.database.utils import (
|
||||
add_where_clause_str_opt,
|
||||
parse_nested_field,
|
||||
)
|
||||
from spoolman.exceptions import ItemCreateError, ItemNotFoundError
|
||||
from spoolman.exceptions import ItemCreateError, ItemNotFoundError, SpoolMeasureError
|
||||
from spoolman.math import weight_from_length
|
||||
from spoolman.ws import websocket_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def utc_timezone_naive(dt: datetime) -> datetime:
|
||||
"""Convert a datetime object to UTC and remove timezone info."""
|
||||
@@ -35,6 +39,8 @@ async def create(
|
||||
db: AsyncSession,
|
||||
filament_id: int,
|
||||
remaining_weight: Optional[float] = None,
|
||||
initial_weight: Optional[float] = None,
|
||||
spool_weight: Optional[float] = None,
|
||||
used_weight: Optional[float] = None,
|
||||
first_used: Optional[datetime] = None,
|
||||
last_used: Optional[datetime] = None,
|
||||
@@ -47,11 +53,23 @@ async def create(
|
||||
) -> 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)
|
||||
|
||||
# Set spool_weight to spool_weight if spool_weight is not null and spool_weight not provided
|
||||
if spool_weight is None and filament_item.spool_weight is not None:
|
||||
spool_weight = filament_item.spool_weight
|
||||
|
||||
# Calculate initial_weight if not provided
|
||||
if initial_weight is None and filament_item.weight is not None:
|
||||
initial_weight = filament_item.weight
|
||||
|
||||
if used_weight is None:
|
||||
if remaining_weight is not None:
|
||||
if filament_item.weight is None:
|
||||
raise ItemCreateError("remaining_weight can only be used if the filament type has a weight set.")
|
||||
used_weight = max(filament_item.weight - remaining_weight, 0)
|
||||
if initial_weight is None or initial_weight == 0:
|
||||
raise ItemCreateError(
|
||||
"remaining_weight can only be used if the initial_weight is "
|
||||
"defined or the filament has a weight set.",
|
||||
)
|
||||
used_weight = max(initial_weight - remaining_weight, 0)
|
||||
else:
|
||||
used_weight = 0
|
||||
|
||||
@@ -64,6 +82,8 @@ async def create(
|
||||
spool = models.Spool(
|
||||
filament=filament_item,
|
||||
registered=datetime.utcnow().replace(microsecond=0),
|
||||
initial_weight=initial_weight,
|
||||
spool_weight=spool_weight,
|
||||
used_weight=used_weight,
|
||||
price=price,
|
||||
first_used=first_used,
|
||||
@@ -92,7 +112,7 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool:
|
||||
return spool
|
||||
|
||||
|
||||
async def find(
|
||||
async def find( # noqa: C901, PLR0912
|
||||
*,
|
||||
db: AsyncSession,
|
||||
filament_name: Optional[str] = None,
|
||||
@@ -149,11 +169,27 @@ async def find(
|
||||
if sort_by is not None:
|
||||
for fieldstr, order in sort_by.items():
|
||||
sorts = []
|
||||
if fieldstr in {"remaining_weight", "remaining_length"}:
|
||||
sorts.append(models.Filament.weight - models.Spool.used_weight)
|
||||
if fieldstr == "remaining_weight":
|
||||
sorts.append(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight)
|
||||
elif fieldstr == "remaining_length":
|
||||
# Simplified weight -> length formula. Absolute value is not correct but the proportionality is still
|
||||
# kept, which means the sort order is correct.
|
||||
sorts.append(
|
||||
(coalesce(models.Spool.initial_weight, models.Filament.weight) - models.Spool.used_weight)
|
||||
/ models.Filament.density
|
||||
/ (models.Filament.diameter * models.Filament.diameter),
|
||||
)
|
||||
elif fieldstr == "used_length":
|
||||
sorts.append(
|
||||
models.Spool.used_weight
|
||||
/ models.Filament.density
|
||||
/ (models.Filament.diameter * models.Filament.diameter),
|
||||
)
|
||||
elif fieldstr == "filament.combined_name":
|
||||
sorts.append(models.Vendor.name)
|
||||
sorts.append(models.Filament.name)
|
||||
elif fieldstr == "price":
|
||||
sorts.append(coalesce(models.Spool.price, models.Filament.price))
|
||||
else:
|
||||
sorts.append(parse_nested_field(models.Spool, fieldstr))
|
||||
|
||||
@@ -181,10 +217,14 @@ async def update(
|
||||
for k, v in data.items():
|
||||
if k == "filament_id":
|
||||
spool.filament = await filament.get_by_id(db, v)
|
||||
# If there is no initial_weight, calculate it from the filament weight
|
||||
if spool.initial_weight is None and spool.filament.weight is not None:
|
||||
spool.initial_weight = spool.filament.weight
|
||||
|
||||
elif k == "remaining_weight":
|
||||
if spool.filament.weight is None:
|
||||
raise ItemCreateError("remaining_weight can only be used if the filament type has a weight set.")
|
||||
spool.used_weight = max(spool.filament.weight - v, 0)
|
||||
if spool.initial_weight is None:
|
||||
raise ItemCreateError("remaining_weight can only be used if initial_weight is set.")
|
||||
spool.used_weight = max(spool.initial_weight - v, 0)
|
||||
elif isinstance(v, datetime):
|
||||
setattr(spool, k, utc_timezone_naive(v))
|
||||
elif k == "extra":
|
||||
@@ -199,8 +239,8 @@ async def update(
|
||||
async def delete(db: AsyncSession, spool_id: int) -> None:
|
||||
"""Delete a spool object."""
|
||||
spool = await get_by_id(db, spool_id)
|
||||
await db.delete(spool)
|
||||
await spool_changed(spool, EventType.DELETED)
|
||||
await db.delete(spool)
|
||||
|
||||
|
||||
async def clear_extra_field(db: AsyncSession, key: str) -> None:
|
||||
@@ -217,13 +257,14 @@ async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> Non
|
||||
db (AsyncSession): Database session
|
||||
spool_id (int): Spool ID
|
||||
weight (float): Filament weight to consume, in grams
|
||||
|
||||
"""
|
||||
await db.execute(
|
||||
sqlalchemy.update(models.Spool)
|
||||
.where(models.Spool.id == spool_id)
|
||||
.values(
|
||||
used_weight=case(
|
||||
(models.Spool.used_weight + weight >= 0.0, models.Spool.used_weight + weight), # noqa: PLR2004
|
||||
(models.Spool.used_weight + weight >= 0.0, models.Spool.used_weight + weight),
|
||||
else_=0.0, # Set used_weight to 0 if the result would be negative
|
||||
),
|
||||
),
|
||||
@@ -243,6 +284,7 @@ async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.S
|
||||
|
||||
Returns:
|
||||
models.Spool: Updated spool object
|
||||
|
||||
"""
|
||||
await use_weight_safe(db, spool_id, weight)
|
||||
|
||||
@@ -270,6 +312,7 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
|
||||
|
||||
Returns:
|
||||
models.Spool: Updated spool object
|
||||
|
||||
"""
|
||||
# Get filament diameter and density
|
||||
result = await db.execute(
|
||||
@@ -302,6 +345,74 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S
|
||||
return spool
|
||||
|
||||
|
||||
async def measure(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
|
||||
"""Record usage based on current gross weight of spool.
|
||||
|
||||
Increases the used_weight attribute of the spool.
|
||||
Updates the first_used and last_used attributes where appropriate.
|
||||
|
||||
Args:
|
||||
db (AsyncSession): Database session
|
||||
spool_id (int): Spool ID
|
||||
weight (float): Length of filament to consume, in mm
|
||||
|
||||
Returns:
|
||||
models.Spool: Updated spool object
|
||||
|
||||
"""
|
||||
spool_result = await db.execute(
|
||||
sqlalchemy.select(models.Spool.initial_weight, models.Spool.used_weight, models.Spool.spool_weight).where(
|
||||
models.Spool.id == spool_id,
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
spool_info = spool_result.one()
|
||||
except NoResultFound as exc:
|
||||
raise SpoolMeasureError("Spool not found.") from exc
|
||||
|
||||
initial_weight = spool_info[0]
|
||||
spool_weight = spool_info[2]
|
||||
if initial_weight is None or initial_weight == 0 or spool_weight is None or spool_weight == 0:
|
||||
# Get filament weight and spool_weight
|
||||
result = await db.execute(
|
||||
sqlalchemy.select(models.Filament.weight, models.Filament.spool_weight)
|
||||
.join(models.Spool, models.Spool.filament_id == models.Filament.id)
|
||||
.where(models.Spool.id == spool_id),
|
||||
)
|
||||
try:
|
||||
filament_info = result.one()
|
||||
except NoResultFound as exc:
|
||||
raise ItemNotFoundError("Filament not found for spool.") from exc
|
||||
|
||||
if spool_weight is None or spool_weight == 0:
|
||||
spool_weight = filament_info[1]
|
||||
|
||||
if initial_weight is None or initial_weight == 0:
|
||||
initial_weight = filament_info[0] if filament_info[0] is not None else 0
|
||||
|
||||
if initial_weight is None or initial_weight == 0:
|
||||
raise SpoolMeasureError("Initial weight is not set.")
|
||||
|
||||
initial_gross_weight = initial_weight + spool_weight
|
||||
|
||||
# if the measurement is greater than the initial weight, set the initial weight to the measurement
|
||||
if weight > initial_gross_weight:
|
||||
return await reset_initial_weight(db, spool_id, weight - spool_weight)
|
||||
|
||||
# Calculate the current net weight
|
||||
current_use = initial_gross_weight - spool_info[1]
|
||||
|
||||
# Calculate the weight used since last measure
|
||||
weight_to_use = current_use - weight
|
||||
|
||||
# If the measured weight is less than the empty weight, use the rest of the spool
|
||||
if (initial_gross_weight - weight_to_use) < spool_weight:
|
||||
weight_to_use = current_use - spool_weight
|
||||
|
||||
return await use_weight(db, spool_id, weight_to_use)
|
||||
|
||||
|
||||
async def find_locations(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -324,12 +435,27 @@ async def find_lot_numbers(
|
||||
|
||||
async def spool_changed(spool: models.Spool, typ: EventType) -> None:
|
||||
"""Notify websocket clients that a spool has changed."""
|
||||
await websocket_manager.send(
|
||||
("spool", str(spool.id)),
|
||||
SpoolEvent(
|
||||
type=typ,
|
||||
resource="spool",
|
||||
date=datetime.utcnow(),
|
||||
payload=Spool.from_db(spool),
|
||||
),
|
||||
)
|
||||
try:
|
||||
await websocket_manager.send(
|
||||
("spool", str(spool.id)),
|
||||
SpoolEvent(
|
||||
type=typ,
|
||||
resource="spool",
|
||||
date=datetime.utcnow(),
|
||||
payload=Spool.from_db(spool),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Important to have a catch-all here since we don't want to stop the call if this fails.
|
||||
logger.exception("Failed to send websocket message")
|
||||
|
||||
|
||||
async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -> models.Spool:
|
||||
"""Reset inital weight to new weight and used_weight to 0."""
|
||||
spool = await get_by_id(db, spool_id)
|
||||
|
||||
spool.initial_weight = weight
|
||||
spool.used_weight = 0
|
||||
await db.commit()
|
||||
await spool_changed(spool, EventType.UPDATED)
|
||||
return spool
|
||||
|
||||
@@ -47,9 +47,14 @@ def add_where_clause_str_opt(
|
||||
if value is not None:
|
||||
conditions = []
|
||||
for value_part in value.split(","):
|
||||
# If part is empty, search for empty fields
|
||||
if len(value_part) == 0:
|
||||
conditions.append(field.is_(None))
|
||||
conditions.append(field == "")
|
||||
# Do exact match if value_part is surrounded by quotes
|
||||
elif value_part[0] == '"' and value_part[-1] == '"':
|
||||
conditions.append(field == value_part[1:-1])
|
||||
# Do fuzzy match if value_part is not surrounded by quotes
|
||||
else:
|
||||
conditions.append(field.ilike(f"%{value_part}%"))
|
||||
|
||||
@@ -66,8 +71,13 @@ def add_where_clause_str(
|
||||
if value is not None:
|
||||
conditions = []
|
||||
for value_part in value.split(","):
|
||||
# If part is empty, search for empty fields
|
||||
if len(value_part) == 0:
|
||||
conditions.append(field == "")
|
||||
# Do exact match if value_part is surrounded by quotes
|
||||
elif value_part[0] == '"' and value_part[-1] == '"':
|
||||
conditions.append(field == value_part[1:-1])
|
||||
# Do fuzzy match if value_part is not surrounded by quotes
|
||||
else:
|
||||
conditions.append(field.ilike(f"%{value_part}%"))
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Helper functions for interacting with vendor database objects."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -9,16 +10,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from spoolman.api.v1.models import EventType, Vendor, VendorEvent
|
||||
from spoolman.database import models
|
||||
from spoolman.database.utils import SortOrder, add_where_clause_str
|
||||
from spoolman.database.utils import SortOrder, add_where_clause_str, add_where_clause_str_opt
|
||||
from spoolman.exceptions import ItemNotFoundError
|
||||
from spoolman.ws import websocket_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
name: Optional[str] = None,
|
||||
comment: Optional[str] = None,
|
||||
empty_spool_weight: Optional[float] = None,
|
||||
external_id: Optional[str] = None,
|
||||
extra: Optional[dict[str, str]] = None,
|
||||
) -> models.Vendor:
|
||||
"""Add a new vendor to the database."""
|
||||
@@ -26,6 +31,8 @@ async def create(
|
||||
name=name,
|
||||
registered=datetime.utcnow().replace(microsecond=0),
|
||||
comment=comment,
|
||||
empty_spool_weight=empty_spool_weight,
|
||||
external_id=external_id,
|
||||
extra=[models.VendorField(key=k, value=v) for k, v in (extra or {}).items()],
|
||||
)
|
||||
db.add(vendor)
|
||||
@@ -46,6 +53,7 @@ async def find(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
name: Optional[str] = None,
|
||||
external_id: Optional[str] = None,
|
||||
sort_by: Optional[dict[str, SortOrder]] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
@@ -57,6 +65,7 @@ async def find(
|
||||
stmt = select(models.Vendor)
|
||||
|
||||
stmt = add_where_clause_str(stmt, models.Vendor.name, name)
|
||||
stmt = add_where_clause_str_opt(stmt, models.Vendor.external_id, external_id)
|
||||
|
||||
total_count = None
|
||||
|
||||
@@ -116,12 +125,16 @@ async def clear_extra_field(db: AsyncSession, key: str) -> None:
|
||||
|
||||
async def vendor_changed(vendor: models.Vendor, typ: EventType) -> None:
|
||||
"""Notify websocket clients that a vendor has changed."""
|
||||
await websocket_manager.send(
|
||||
("vendor", str(vendor.id)),
|
||||
VendorEvent(
|
||||
type=typ,
|
||||
resource="vendor",
|
||||
date=datetime.utcnow(),
|
||||
payload=Vendor.from_db(vendor),
|
||||
),
|
||||
)
|
||||
try:
|
||||
await websocket_manager.send(
|
||||
("vendor", str(vendor.id)),
|
||||
VendorEvent(
|
||||
type=typ,
|
||||
resource="vendor",
|
||||
date=datetime.utcnow(),
|
||||
payload=Vendor.from_db(vendor),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
# Important to have a catch-all here since we don't want to stop the call if this fails.
|
||||
logger.exception("Failed to send websocket message")
|
||||
|
||||
@@ -23,6 +23,7 @@ def generate_openapi(app: FastAPI) -> dict[str, Any]:
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The OpenAPI document.
|
||||
|
||||
"""
|
||||
return get_openapi(
|
||||
title=app.title,
|
||||
|
||||
@@ -28,6 +28,7 @@ class DatabaseType(Enum):
|
||||
|
||||
Returns:
|
||||
str: The drivername.
|
||||
|
||||
"""
|
||||
if self is DatabaseType.POSTGRES:
|
||||
return "postgresql+asyncpg"
|
||||
@@ -47,6 +48,7 @@ def get_database_type() -> Optional[DatabaseType]:
|
||||
|
||||
Returns:
|
||||
Optional[DatabaseType]: The database type.
|
||||
|
||||
"""
|
||||
database_type = os.getenv("SPOOLMAN_DB_TYPE")
|
||||
if database_type is None:
|
||||
@@ -69,6 +71,7 @@ def get_host() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The host.
|
||||
|
||||
"""
|
||||
return os.getenv("SPOOLMAN_DB_HOST")
|
||||
|
||||
@@ -80,6 +83,7 @@ def get_port() -> Optional[int]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The port.
|
||||
|
||||
"""
|
||||
port = os.getenv("SPOOLMAN_DB_PORT")
|
||||
if port is None:
|
||||
@@ -97,6 +101,7 @@ def get_database() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The name.
|
||||
|
||||
"""
|
||||
return os.getenv("SPOOLMAN_DB_NAME")
|
||||
|
||||
@@ -108,6 +113,7 @@ def get_query() -> Optional[dict[str, str]]:
|
||||
|
||||
Returns:
|
||||
Optional[dict]: The query.
|
||||
|
||||
"""
|
||||
query = os.getenv("SPOOLMAN_DB_QUERY")
|
||||
if query is None:
|
||||
@@ -126,6 +132,7 @@ def get_username() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The username.
|
||||
|
||||
"""
|
||||
return os.getenv("SPOOLMAN_DB_USERNAME")
|
||||
|
||||
@@ -140,6 +147,7 @@ def get_password() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The password.
|
||||
|
||||
"""
|
||||
# First attempt: grab password from a file. This is the most secure way of storing passwords.
|
||||
file_path = os.getenv("SPOOLMAN_DB_PASSWORD_FILE")
|
||||
@@ -170,6 +178,7 @@ def get_logging_level() -> int:
|
||||
|
||||
Returns:
|
||||
str: The logging level.
|
||||
|
||||
"""
|
||||
log_level_str = os.getenv("SPOOLMAN_LOGGING_LEVEL", "INFO").upper()
|
||||
if log_level_str == "DEBUG":
|
||||
@@ -192,6 +201,7 @@ def is_debug_mode() -> bool:
|
||||
|
||||
Returns:
|
||||
bool: Whether debug mode is enabled.
|
||||
|
||||
"""
|
||||
debug_mode = os.getenv("SPOOLMAN_DEBUG_MODE", "FALSE").upper()
|
||||
if debug_mode in {"FALSE", "0"}:
|
||||
@@ -208,6 +218,7 @@ def is_automatic_backup_enabled() -> bool:
|
||||
|
||||
Returns:
|
||||
bool: Whether automatic backup is enabled.
|
||||
|
||||
"""
|
||||
automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper()
|
||||
if automatic_backup in {"FALSE", "0"}:
|
||||
@@ -224,6 +235,7 @@ def get_data_dir() -> Path:
|
||||
|
||||
Returns:
|
||||
Path: The data directory.
|
||||
|
||||
"""
|
||||
env_data_dir = os.getenv("SPOOLMAN_DIR_DATA")
|
||||
if env_data_dir is not None:
|
||||
@@ -239,6 +251,7 @@ def get_logs_dir() -> Path:
|
||||
|
||||
Returns:
|
||||
Path: The logs directory.
|
||||
|
||||
"""
|
||||
env_logs_dir = os.getenv("SPOOLMAN_DIR_LOGS")
|
||||
if env_logs_dir is not None:
|
||||
@@ -254,6 +267,7 @@ def get_backups_dir() -> Path:
|
||||
|
||||
Returns:
|
||||
Path: The backups directory.
|
||||
|
||||
"""
|
||||
env_backups_dir = os.getenv("SPOOLMAN_DIR_BACKUPS")
|
||||
if env_backups_dir is not None:
|
||||
@@ -264,11 +278,17 @@ def get_backups_dir() -> Path:
|
||||
return backups_dir
|
||||
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
"""Get the cache directory."""
|
||||
return get_data_dir() / "cache"
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
"""Get the version of the package.
|
||||
|
||||
Returns:
|
||||
str: The version.
|
||||
|
||||
"""
|
||||
# Read version from pyproject.toml, don't use pkg_resources because it requires the package to be installed
|
||||
with Path("pyproject.toml").open(encoding="utf-8") as f:
|
||||
@@ -285,6 +305,7 @@ def get_commit_hash() -> Optional[str]:
|
||||
|
||||
Returns:
|
||||
Optional[str]: The commit hash.
|
||||
|
||||
"""
|
||||
# Read commit has from build.txt
|
||||
# commit is written as GIT_COMMIT=<hash> in build.txt
|
||||
@@ -303,6 +324,7 @@ def get_build_date() -> Optional[datetime]:
|
||||
|
||||
Returns:
|
||||
Optional[datetime.datetime]: The build date.
|
||||
|
||||
"""
|
||||
# Read build date has from build.txt
|
||||
# build date is written as BUILD_DATE=<hash> in build.txt
|
||||
@@ -354,7 +376,7 @@ def check_write_permissions() -> None:
|
||||
|
||||
# Try fixing it by chowning the directory to the current user
|
||||
logger.warning("Data directory is not writable, trying to fix it...")
|
||||
if not chown_dir(get_data_dir()) or not can_write_to_data_dir():
|
||||
if not chown_dir(str(get_data_dir())) or not can_write_to_data_dir():
|
||||
uid = os.getuid()
|
||||
gid = os.getgid()
|
||||
|
||||
@@ -380,3 +402,39 @@ def is_data_dir_mounted() -> bool:
|
||||
mounts = subprocess.run("mount", check=True, stdout=subprocess.PIPE, text=True) # noqa: S603, S607
|
||||
data_dir = str(get_data_dir().resolve())
|
||||
return any(data_dir in line for line in mounts.stdout.splitlines())
|
||||
|
||||
|
||||
def is_metrics_enabled() -> bool:
|
||||
"""Get whether collect prometheus metrics at database is enabled.
|
||||
|
||||
Returns False if no environment variable was set for collect metrics.
|
||||
|
||||
Returns:
|
||||
bool: Whether collect metrics is enabled.
|
||||
|
||||
"""
|
||||
metrics_enabled = os.getenv("SPOOLMAN_METRICS_ENABLED", "FALSE").upper()
|
||||
if metrics_enabled in {"FALSE", "0"}:
|
||||
return False
|
||||
if metrics_enabled in {"TRUE", "1"}:
|
||||
return True
|
||||
raise ValueError(
|
||||
f"Failed to parse SPOOLMAN_METRICS_ENABLED variable: Unknown metrics enabled '{metrics_enabled}'.",
|
||||
)
|
||||
|
||||
|
||||
def get_base_path() -> str:
|
||||
"""Get the base path.
|
||||
|
||||
This is formated so that it always starts with a /, and does not end with a /
|
||||
|
||||
Returns:
|
||||
str: The base path.
|
||||
|
||||
"""
|
||||
path = os.getenv("SPOOLMAN_BASE_PATH", "")
|
||||
if len(path) == 0:
|
||||
return ""
|
||||
|
||||
# Ensure it starts with / and does not end with /
|
||||
return "/" + path.strip("/")
|
||||
|
||||
@@ -11,3 +11,7 @@ class ItemDeleteError(Exception):
|
||||
|
||||
class ItemCreateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SpoolMeasureError(Exception):
|
||||
pass
|
||||
|
||||
206
spoolman/externaldb.py
Normal file
206
spoolman/externaldb.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Functions for syncing data from an external database of manufacturers, filaments, materials, etc."""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import hishel
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import filecache
|
||||
from spoolman.env import get_cache_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_EXTERNAL_DB_URL = "https://donkie.github.io/SpoolmanDB/"
|
||||
DEFAULT_SYNC_INTERVAL = 3600
|
||||
|
||||
controller = hishel.Controller(allow_stale=True)
|
||||
try:
|
||||
cache_path = get_cache_dir() / "hishel"
|
||||
cache_storage = hishel.AsyncFileStorage(base_path=cache_path)
|
||||
except PermissionError:
|
||||
logger.warning(
|
||||
"Failed to setup disk-based cache due to permission error. Ensure the path %s is writable. "
|
||||
"Using in-memory cache instead as fallback.",
|
||||
str(cache_path.resolve()),
|
||||
)
|
||||
cache_storage = hishel.AsyncInMemoryStorage()
|
||||
|
||||
|
||||
class SpoolType(Enum):
|
||||
PLASTIC = "plastic"
|
||||
CARDBOARD = "cardboard"
|
||||
METAL = "metal"
|
||||
|
||||
|
||||
class Finish(Enum):
|
||||
MATTE = "matte"
|
||||
GLOSSY = "glossy"
|
||||
|
||||
|
||||
class MultiColorDirection(Enum):
|
||||
COAXIAL = "coaxial"
|
||||
LONGITUDINAL = "longitudinal"
|
||||
|
||||
|
||||
class Pattern(Enum):
|
||||
MARBLE = "marble"
|
||||
SPARKLE = "sparkle"
|
||||
|
||||
|
||||
class ExternalFilament(BaseModel):
|
||||
id: str = Field(description="A unique ID for this filament.", examples=["polymaker_pla_polysonicblack_1000_175"])
|
||||
manufacturer: str = Field(description="Filament manufacturer.", examples=["Polymaker"])
|
||||
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
|
||||
material: str = Field(description="Filament material.", examples=["PLA"])
|
||||
density: float = Field(description="Density in g/cm3.", examples=[1.23])
|
||||
weight: float = Field(description="Net weight of a single spool.", examples=[1000])
|
||||
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
|
||||
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
|
||||
diameter: float = Field(description="Filament in mm.", examples=[1.75])
|
||||
color_hex: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Filament color code in hex format, for single-color filaments.",
|
||||
examples=["2c3232"],
|
||||
)
|
||||
color_hexes: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="For multi-color filaments. List of hex color codes in hex format.",
|
||||
examples=[["2c3232", "5f5f5f"]],
|
||||
)
|
||||
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
|
||||
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
|
||||
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
|
||||
multi_color_direction: Optional[MultiColorDirection] = Field(
|
||||
default=None,
|
||||
description="Direction of multi-color filaments.",
|
||||
examples=[MultiColorDirection.COAXIAL],
|
||||
)
|
||||
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", examples=[Pattern.MARBLE])
|
||||
translucent: bool = Field(default=False, description="Whether the filament is translucent.")
|
||||
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
||||
|
||||
|
||||
class ExternalFilamentsFile(RootModel):
|
||||
root: list[ExternalFilament]
|
||||
|
||||
def __iter__(self) -> Iterator[ExternalFilament]:
|
||||
"""Iterate over the filaments."""
|
||||
return iter(self.root)
|
||||
|
||||
def __getitem__(self, index: int) -> ExternalFilament:
|
||||
"""Get a specific filament by index."""
|
||||
return self.root[index]
|
||||
|
||||
|
||||
class ExternalMaterial(BaseModel):
|
||||
material: str = Field(examples=["PLA"])
|
||||
density: float = Field(examples=[1.24])
|
||||
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", examples=[210])
|
||||
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
|
||||
|
||||
|
||||
class ExternalMaterialsFile(RootModel):
|
||||
root: list[ExternalMaterial]
|
||||
|
||||
def __iter__(self) -> Iterator[ExternalMaterial]:
|
||||
"""Iterate over the materials."""
|
||||
return iter(self.root)
|
||||
|
||||
def __getitem__(self, index: int) -> ExternalMaterial:
|
||||
"""Get a specific material by index."""
|
||||
return self.root[index]
|
||||
|
||||
|
||||
def get_external_db_url() -> str:
|
||||
"""Get the external database URL from environment variables. Defaults to DEFAULT_EXTERNAL_DB_URL."""
|
||||
return os.getenv("EXTERNAL_DB_URL", DEFAULT_EXTERNAL_DB_URL).strip("/") + "/"
|
||||
|
||||
|
||||
def get_external_db_sync_interval() -> int:
|
||||
"""Get the external database sync interval from environment variables. Defaults to DEFAULT_SYNC_INTERVAL."""
|
||||
return int(os.getenv("EXTERNAL_DB_SYNC_INTERVAL", DEFAULT_SYNC_INTERVAL))
|
||||
|
||||
|
||||
async def _download_file(url: str) -> bytes:
|
||||
"""Download a file from a URL and return the contents as a string.
|
||||
|
||||
Uses a file-based cache.
|
||||
"""
|
||||
async with hishel.AsyncCacheClient(storage=cache_storage, controller=controller) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.read()
|
||||
|
||||
|
||||
def _parse_filaments_from_bytes(data: bytes) -> ExternalFilamentsFile:
|
||||
"""Parse a bytes string into a list of ExternalFilament objects."""
|
||||
return ExternalFilamentsFile.parse_raw(data)
|
||||
|
||||
|
||||
def _parse_materials_from_bytes(data: bytes) -> ExternalMaterialsFile:
|
||||
"""Parse a bytes string into a list of ExternalMaterial objects."""
|
||||
return ExternalMaterialsFile.parse_raw(data)
|
||||
|
||||
|
||||
def _write_to_local_cache(filename: str, data: bytes) -> None:
|
||||
"""Write data to the local cache."""
|
||||
filecache.update_file(filename, data)
|
||||
|
||||
|
||||
def get_filaments_file() -> Path:
|
||||
"""Get the path to the filaments file."""
|
||||
return filecache.get_file("filaments.json")
|
||||
|
||||
|
||||
def get_materials_file() -> Path:
|
||||
"""Get the path to the materials file."""
|
||||
return filecache.get_file("materials.json")
|
||||
|
||||
|
||||
async def _sync() -> None:
|
||||
logger.info("Syncing external DB.")
|
||||
|
||||
url = get_external_db_url()
|
||||
|
||||
filaments = _parse_filaments_from_bytes(await _download_file(url + "filaments.json"))
|
||||
materials = _parse_materials_from_bytes(await _download_file(url + "materials.json"))
|
||||
|
||||
_write_to_local_cache("filaments.json", filaments.json().encode())
|
||||
_write_to_local_cache("materials.json", materials.json().encode())
|
||||
|
||||
logger.info(
|
||||
"External DB synced. Filaments: %d, Materials: %d",
|
||||
len(filaments.root),
|
||||
len(materials.root),
|
||||
)
|
||||
|
||||
|
||||
def schedule_tasks(scheduler: Scheduler) -> None:
|
||||
"""Schedule tasks to be executed by the provided scheduler.
|
||||
|
||||
Args:
|
||||
scheduler: The scheduler to use for scheduling tasks.
|
||||
|
||||
"""
|
||||
if len(get_external_db_url().strip()) == 0:
|
||||
logger.info("External DB URL is empty. Skipping sync.")
|
||||
return
|
||||
|
||||
logger.info("Scheduling external DB sync.")
|
||||
|
||||
# Run once on startup
|
||||
scheduler.once(datetime.timedelta(seconds=0), _sync) # type: ignore[arg-type]
|
||||
|
||||
sync_interval = get_external_db_sync_interval()
|
||||
if sync_interval > 0:
|
||||
scheduler.cyclic(datetime.timedelta(seconds=DEFAULT_SYNC_INTERVAL), _sync) # type: ignore[arg-type]
|
||||
else:
|
||||
logger.info("Sync interval is 0, skipping periodic sync of external db.")
|
||||
@@ -45,13 +45,13 @@ class ExtraFieldParameters(BaseModel):
|
||||
choices: Optional[list[str]] = Field(
|
||||
None,
|
||||
description="Choices for the field, only for field type choice",
|
||||
min_items=1,
|
||||
min_length=1,
|
||||
)
|
||||
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)
|
||||
key: str = Field(description="Unique key", pattern="^[a-z0-9_]+$", min_length=1, max_length=64)
|
||||
entity_type: EntityType = Field(description="Entity type this field is for")
|
||||
|
||||
|
||||
|
||||
25
spoolman/filecache.py
Normal file
25
spoolman/filecache.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""A file-based cache system for reading/writing files."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from spoolman.env import get_cache_dir
|
||||
|
||||
|
||||
def get_file(name: str) -> Path:
|
||||
"""Get the path to a file in the cache dir."""
|
||||
return get_cache_dir() / name
|
||||
|
||||
|
||||
def update_file(name: str, data: bytes) -> None:
|
||||
"""Update a file if it differs from the given data."""
|
||||
path = get_file(name)
|
||||
if path.exists() and path.read_bytes() == data:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
|
||||
def get_file_contents(name: str) -> bytes:
|
||||
"""Get the contents of a file."""
|
||||
path = get_file(name)
|
||||
return path.read_bytes()
|
||||
@@ -9,12 +9,15 @@ import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import PlainTextResponse, RedirectResponse, Response
|
||||
from prometheus_client import generate_latest
|
||||
from scheduler.asyncio.scheduler import Scheduler
|
||||
|
||||
from spoolman import env
|
||||
from spoolman import env, externaldb
|
||||
from spoolman.api.v1.router import app as v1_app
|
||||
from spoolman.client import SinglePageApplication
|
||||
from spoolman.database import database
|
||||
from spoolman.prometheus.metrics import registry
|
||||
|
||||
# Define a console logger
|
||||
console_handler = logging.StreamHandler()
|
||||
@@ -36,10 +39,54 @@ app = FastAPI(
|
||||
version=env.get_version(),
|
||||
)
|
||||
app.add_middleware(GZipMiddleware)
|
||||
app.mount("/api/v1", v1_app)
|
||||
app.mount("/", app=SinglePageApplication(directory="client/dist"), name="client")
|
||||
app.mount(env.get_base_path() + "/api/v1", v1_app)
|
||||
|
||||
|
||||
# WA for prometheus /metrics bind with SinglePageApp at root
|
||||
@app.get(
|
||||
env.get_base_path() + "/metrics",
|
||||
response_class=PlainTextResponse,
|
||||
name="Get metrics for prometheus",
|
||||
description=(
|
||||
"Get app metrics for prometheusIf enabled SPOOLMAN_METRICS_ENABLED returned metrics by Spools and Filaments"
|
||||
),
|
||||
)
|
||||
def get_metrics() -> bytes:
|
||||
"""Return prometheus metrics."""
|
||||
return generate_latest(registry)
|
||||
|
||||
|
||||
base_path = env.get_base_path()
|
||||
if base_path != "":
|
||||
logger.info("Base path is: %s", base_path)
|
||||
|
||||
# If base path is set, add a redirect from non-slash suffix to slash
|
||||
# suffix. Otherwise it won't work.
|
||||
@app.get(base_path)
|
||||
def root_redirect() -> Response:
|
||||
"""Redirect to base path."""
|
||||
return RedirectResponse(base_path + "/")
|
||||
|
||||
|
||||
# Return a dynamic js config file
|
||||
# This is so that the client side can access the base path variable.
|
||||
@app.get(env.get_base_path() + "/config.js")
|
||||
def get_configjs() -> Response:
|
||||
"""Return a dynamic js config file."""
|
||||
if '"' in base_path:
|
||||
raise ValueError("Base path contains quotes, which are not allowed.")
|
||||
|
||||
return Response(
|
||||
content=f"""
|
||||
window.SPOOLMAN_BASE_PATH = "{base_path}";
|
||||
""",
|
||||
media_type="text/javascript",
|
||||
)
|
||||
|
||||
|
||||
# Mount the client side app
|
||||
app.mount(base_path, app=SinglePageApplication(directory="client/dist", base_path=env.get_base_path()))
|
||||
|
||||
# Allow all origins if in debug mode
|
||||
if env.is_debug_mode():
|
||||
logger.warning("Running in debug mode, allowing all origins.")
|
||||
@@ -96,6 +143,7 @@ async def startup() -> None:
|
||||
# Setup scheduler
|
||||
schedule = Scheduler()
|
||||
database.schedule_tasks(schedule)
|
||||
externaldb.schedule_tasks(schedule)
|
||||
|
||||
logger.info("Startup complete.")
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ def weight_from_length(*, length: float, diameter: float, density: float) -> flo
|
||||
|
||||
Returns:
|
||||
float: Weight in g
|
||||
|
||||
"""
|
||||
volume_mm3 = length * math.pi * (diameter / 2) ** 2
|
||||
volume_cm3 = volume_mm3 / 1000
|
||||
@@ -31,6 +32,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
|
||||
|
||||
Returns:
|
||||
float: Length in mm
|
||||
|
||||
"""
|
||||
volume_cm3 = weight / density
|
||||
volume_mm3 = volume_cm3 * 1000
|
||||
|
||||
0
spoolman/prometheus/__init__.py
Normal file
0
spoolman/prometheus/__init__.py
Normal file
89
spoolman/prometheus/metrics.py
Normal file
89
spoolman/prometheus/metrics.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Prometheus metrics collectors."""
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
import sqlalchemy
|
||||
from prometheus_client import REGISTRY, Gauge, make_asgi_app
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import contains_eager
|
||||
|
||||
from spoolman.database import models
|
||||
|
||||
registry = REGISTRY
|
||||
|
||||
PREFIX = "spoolman"
|
||||
|
||||
SPOOL_PRICE = Gauge(f"{PREFIX}_spool_price", "Total Spool price", ["spool_id", "filament_id"])
|
||||
SPOOL_USED_WEIGHT = Gauge(f"{PREFIX}_spool_weight_used", "Spool Used Weight", ["spool_id", "filament_id"])
|
||||
FILAMENT_INFO = Gauge(
|
||||
f"{PREFIX}_filament_info",
|
||||
"Filament information",
|
||||
["filament_id", "vendor", "name", "material", "color"],
|
||||
)
|
||||
FILAMENT_DENSITY = Gauge(f"{PREFIX}_filament_density", "Density of filament", ["filament_id"])
|
||||
FILAMENT_DIAMETER = Gauge(f"{PREFIX}_filament_diameter", "Diameter of filament", ["filament_id"])
|
||||
FILAMENT_WEIGHT = Gauge(f"{PREFIX}_filament_weight", "Net weight of filament", ["filament_id"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_metrics_app() -> Callable:
|
||||
"""Start ASGI prometheus app with global registry."""
|
||||
logger.info("Start metrics app")
|
||||
return make_asgi_app(registry=registry)
|
||||
|
||||
|
||||
metrics_app = make_asgi_app()
|
||||
|
||||
|
||||
async def spool_metrics(db: AsyncSession) -> None:
|
||||
"""Get metrics by Spools from DB and write to prometheus.
|
||||
|
||||
Args:
|
||||
db: async db session
|
||||
|
||||
"""
|
||||
stmt = sqlalchemy.select(models.Spool).where(
|
||||
sqlalchemy.or_(
|
||||
models.Spool.archived.is_(False),
|
||||
models.Spool.archived.is_(None),
|
||||
),
|
||||
)
|
||||
rows = await db.execute(stmt)
|
||||
result = list(rows.unique().scalars().all())
|
||||
for row in result:
|
||||
if row.price is not None:
|
||||
SPOOL_PRICE.labels(str(row.id), str(row.filament_id)).set(row.price)
|
||||
SPOOL_USED_WEIGHT.labels(str(row.id), str(row.filament_id)).set(row.used_weight)
|
||||
|
||||
|
||||
async def filament_metrics(db: AsyncSession) -> None:
|
||||
"""Get metrics and info by Filaments from DB and write to prometheus.
|
||||
|
||||
Args:
|
||||
db: async db session
|
||||
|
||||
"""
|
||||
stmt = (
|
||||
sqlalchemy.select(models.Filament)
|
||||
.options(contains_eager(models.Filament.vendor))
|
||||
.join(models.Filament.vendor, isouter=True)
|
||||
)
|
||||
rows = await db.execute(stmt)
|
||||
result = list(rows.unique().scalars().all())
|
||||
for row in result:
|
||||
vendor_name = "-"
|
||||
if row.vendor is not None:
|
||||
vendor_name = row.vendor.name
|
||||
FILAMENT_INFO.labels(
|
||||
str(row.id),
|
||||
vendor_name,
|
||||
row.name,
|
||||
row.material,
|
||||
row.color_hex,
|
||||
).set(1)
|
||||
FILAMENT_DENSITY.labels(str(row.id)).set(row.density)
|
||||
FILAMENT_DIAMETER.labels(str(row.id)).set(row.diameter)
|
||||
if row.weight is not None:
|
||||
FILAMENT_WEIGHT.labels(str(row.id)).set(row.weight)
|
||||
@@ -62,6 +62,7 @@ def parse_setting(key: str) -> SettingDefinition:
|
||||
|
||||
|
||||
register_setting("currency", SettingType.STRING, json.dumps("EUR"))
|
||||
register_setting("print_presets", SettingType.ARRAY, json.dumps([]))
|
||||
|
||||
register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
|
||||
register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
from fastapi import WebSocket
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from spoolman.api.v1.models import Event
|
||||
|
||||
@@ -46,7 +47,22 @@ class SubscriptionTree:
|
||||
"""Send a message to all websockets in this branch of the tree."""
|
||||
# Broadcast to all subscribers on this level
|
||||
for websocket in self.subscribers:
|
||||
await websocket.send_text(evt.json())
|
||||
if (
|
||||
websocket.client_state == WebSocketState.DISCONNECTED # noqa: PLR1714
|
||||
or websocket.application_state == WebSocketState.DISCONNECTED
|
||||
):
|
||||
# A bad disconnection may have occurred
|
||||
self.remove(path, websocket)
|
||||
logger.info(
|
||||
"Forcing disconnection of client %s on pool %s",
|
||||
websocket.client.host if websocket.client else "?",
|
||||
",".join(path),
|
||||
)
|
||||
elif (
|
||||
websocket.client_state == WebSocketState.CONNECTED
|
||||
and websocket.application_state == WebSocketState.CONNECTED
|
||||
):
|
||||
await websocket.send_text(evt.json())
|
||||
|
||||
# Send the message further down the tree
|
||||
if len(path) > 0 and path[0] in self.children:
|
||||
|
||||
Reference in New Issue
Block a user