Merge remote-tracking branch 'upstream/master'

This commit is contained in:
samturner3
2024-08-24 16:18:17 +10:00
159 changed files with 9157 additions and 6235 deletions

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ from spoolman.database.database import backup_global_db
from spoolman.exceptions import ItemNotFoundError
from spoolman.ws import websocket_manager
from . import 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,3 +11,7 @@ class ItemDeleteError(Exception):
class ItemCreateError(Exception):
pass
class SpoolMeasureError(Exception):
pass

206
spoolman/externaldb.py Normal file
View 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.")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]))

View File

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