Updated most python dependencies
Primarily FastAPI and Pydantic to v2. Also ruff to latest. Updated some code to support Pydantic v2
This commit is contained in:
@@ -9,16 +9,16 @@ repos:
|
|||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 23.3.0
|
rev: 24.4.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
args: ["--check"]
|
args: ["--check"]
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.0.286
|
rev: v0.4.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: ["--target-version", "py39"]
|
args: ["--target-version", "py39"]
|
||||||
- repo: https://github.com/pdm-project/pdm
|
- repo: https://github.com/pdm-project/pdm
|
||||||
rev: 2.15.2
|
rev: 2.15.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: pdm-lock-check
|
- id: pdm-lock-check
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: ${up_revision}
|
|||||||
Revises: ${down_revision | comma,n}
|
Revises: ${down_revision | comma,n}
|
||||||
Create Date: ${create_date}
|
Create Date: ${create_date}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
${imports if imports else ""}
|
${imports if imports else ""}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Revision ID: 684d32cf7e4d
|
Revision ID: 684d32cf7e4d
|
||||||
Create Date: 2023-05-27 21:46:24.361353
|
Create Date: 2023-05-27 21:46:24.361353
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.engine.reflection import Inspector
|
from sqlalchemy.engine.reflection import Inspector
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: b47376d60c6d
|
|||||||
Revises: 684d32cf7e4d
|
Revises: 684d32cf7e4d
|
||||||
Create Date: 2023-05-28 21:36:53.452067
|
Create Date: 2023-05-28 21:36:53.452067
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: db385b808a20
|
|||||||
Revises: b47376d60c6d
|
Revises: b47376d60c6d
|
||||||
Create Date: 2023-06-01 19:53:44.440616
|
Create Date: 2023-06-01 19:53:44.440616
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 92186a5f7b0f
|
|||||||
Revises: db385b808a20
|
Revises: db385b808a20
|
||||||
Create Date: 2023-07-14 12:17:13.162618
|
Create Date: 2023-07-14 12:17:13.162618
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 92793c8a937c
|
|||||||
Revises: 92186a5f7b0f
|
Revises: 92186a5f7b0f
|
||||||
Create Date: 2023-08-12 21:21:08.536216
|
Create Date: 2023-08-12 21:21:08.536216
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: b82cd9e2aa6f
|
|||||||
Revises: 92793c8a937c
|
Revises: 92793c8a937c
|
||||||
Create Date: 2023-12-30 08:39:48.430846
|
Create Date: 2023-12-30 08:39:48.430846
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: ccbb17aeda7c
|
|||||||
Revises: b82cd9e2aa6f
|
Revises: b82cd9e2aa6f
|
||||||
Create Date: 2024-01-03 13:46:41.362341
|
Create Date: 2024-01-03 13:46:41.362341
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: b8881bdb716c
|
|||||||
Revises: ccbb17aeda7c
|
Revises: ccbb17aeda7c
|
||||||
Create Date: 2024-01-04 22:09:34.417527
|
Create Date: 2024-01-04 22:09:34.417527
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: aafcd7fb0e84
|
|||||||
Revises: b8881bdb716c
|
Revises: b8881bdb716c
|
||||||
Create Date: 2024-03-26 09:48:09.930022
|
Create Date: 2024-03-26 09:48:09.930022
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 304a32906234
|
|||||||
Revises: aafcd7fb0e84
|
Revises: aafcd7fb0e84
|
||||||
Create Date: 2024-03-26 13:49:26.594399
|
Create Date: 2024-03-26 13:49:26.594399
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Revision ID: 5f069e51bd89
|
|||||||
Revises: 304a32906234
|
Revises: 304a32906234
|
||||||
Create Date: 2024-03-26 15:07:18.366290
|
Create Date: 2024-03-26 15:07:18.366290
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -6,37 +6,37 @@ authors = [
|
|||||||
{ name = "Donkie", email = "daniel.cf.hultgren@gmail.com" },
|
{ name = "Donkie", email = "daniel.cf.hultgren@gmail.com" },
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"uvicorn~=0.22.0",
|
"uvicorn~=0.29.0",
|
||||||
"httptools>=0.5.0; platform_machine != \"armv7l\"",
|
"httptools>=0.5.0; platform_machine != \"armv7l\"",
|
||||||
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; platform_machine != \"armv7l\" and sys_platform != \"win32\" and (sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\")",
|
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; platform_machine != \"armv7l\" and sys_platform != \"win32\" and (sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\")",
|
||||||
"fastapi~=0.99",
|
"fastapi~=0.110.0",
|
||||||
"SQLAlchemy[aiomysql,aiosqlite,asyncio,postgresql_asyncpg]~=2.0",
|
"SQLAlchemy[aiomysql,aiosqlite,asyncio,postgresql_asyncpg]~=2.0",
|
||||||
"pydantic~=1.10",
|
"pydantic~=2.7.1",
|
||||||
"platformdirs~=3.8",
|
"platformdirs~=4.2.2",
|
||||||
"alembic~=1.11",
|
"alembic~=1.13.1",
|
||||||
"scheduler~=0.8",
|
"scheduler~=0.8.5",
|
||||||
"sqlalchemy-cockroachdb~=2.0",
|
"sqlalchemy-cockroachdb~=2.0",
|
||||||
"asyncpg~=0.27",
|
"asyncpg~=0.27",
|
||||||
"psycopg2-binary~=2.9",
|
"psycopg2-binary~=2.9",
|
||||||
"setuptools~=68.0",
|
"setuptools~=70.0.0",
|
||||||
"WebSockets>=11.0.3",
|
"WebSockets~=12.0",
|
||||||
"prometheus-client>=0.20.0",
|
"prometheus-client~=0.20.0",
|
||||||
"httpx>=0.27.0",
|
"httpx~=0.27.0",
|
||||||
"hishel>=0.0.26",
|
"hishel~=0.0.26",
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9,<=3.12"
|
||||||
|
|
||||||
[project.license]
|
[project.license]
|
||||||
text = "MIT"
|
text = "MIT"
|
||||||
|
|
||||||
[tool.pdm.dev-dependencies]
|
[tool.pdm.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.0.286",
|
"ruff==0.4.4",
|
||||||
"black~=23.3",
|
"black~=24.4.2",
|
||||||
"pre-commit~=3.3",
|
"pre-commit~=3.7.1",
|
||||||
"pytest~=7.3",
|
"pytest~=8.2.1",
|
||||||
"pytest-asyncio~=0.21",
|
"pytest-asyncio~=0.23.7",
|
||||||
"httpx~=0.24",
|
"httpx~=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pdm.scripts.docs]
|
[tool.pdm.scripts.docs]
|
||||||
@@ -52,6 +52,10 @@ cmd = "uvicorn spoolman.main:app"
|
|||||||
cmd = "python tests_integration/run.py"
|
cmd = "python tests_integration/run.py"
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py39"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
"ALL",
|
"ALL",
|
||||||
]
|
]
|
||||||
@@ -77,10 +81,8 @@ ignore = [
|
|||||||
"TD002",
|
"TD002",
|
||||||
"TD003",
|
"TD003",
|
||||||
]
|
]
|
||||||
line-length = 120
|
|
||||||
target-version = "py39"
|
|
||||||
|
|
||||||
[tool.ruff.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests*/*" = [
|
"tests*/*" = [
|
||||||
"ANN201",
|
"ANN201",
|
||||||
"S101",
|
"S101",
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ from typing import Annotated, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from pydantic.error_wrappers import ErrorWrapper
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from spoolman.api.v1.models import Filament, FilamentEvent, Message
|
from spoolman.api.v1.models import Filament, FilamentEvent, Message
|
||||||
@@ -32,69 +30,80 @@ router = APIRouter(
|
|||||||
|
|
||||||
class FilamentParameters(BaseModel):
|
class FilamentParameters(BaseModel):
|
||||||
name: Optional[str] = Field(
|
name: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description=(
|
description=(
|
||||||
"Filament name, to distinguish this filament type among others from the same vendor."
|
"Filament name, to distinguish this filament type among others from the same vendor."
|
||||||
"Should contain its color for example."
|
"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(
|
material: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="The material of this filament, e.g. PLA.",
|
description="The material of this filament, e.g. PLA.",
|
||||||
example="PLA",
|
examples=["PLA"],
|
||||||
)
|
)
|
||||||
price: Optional[float] = Field(
|
price: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The price of this filament in the system configured currency.",
|
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)
|
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.", example=1.75)
|
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", examples=[1.75])
|
||||||
weight: Optional[float] = Field(
|
weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
gt=0,
|
gt=0,
|
||||||
description="The weight of the filament in a full spool, in grams. (net weight)",
|
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, gt=0, description="The empty spool weight, in grams.", examples=[140])
|
||||||
article_number: Optional[str] = Field(
|
article_number: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="Vendor article number, e.g. EAN, QR code, etc.",
|
description="Vendor article number, e.g. EAN, QR code, etc.",
|
||||||
example="PM70820",
|
examples=["PM70820"],
|
||||||
)
|
)
|
||||||
comment: Optional[str] = Field(
|
comment: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
description="Free text comment about this filament type.",
|
description="Free text comment about this filament type.",
|
||||||
example="",
|
examples=[""],
|
||||||
)
|
)
|
||||||
settings_extruder_temp: Optional[int] = Field(
|
settings_extruder_temp: Optional[int] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="Overridden extruder temperature, in °C.",
|
description="Overridden extruder temperature, in °C.",
|
||||||
example=210,
|
examples=[210],
|
||||||
)
|
)
|
||||||
settings_bed_temp: Optional[int] = Field(
|
settings_bed_temp: Optional[int] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="Overridden bed temperature, in °C.",
|
description="Overridden bed temperature, in °C.",
|
||||||
example=60,
|
examples=[60],
|
||||||
)
|
)
|
||||||
color_hex: Optional[str] = Field(
|
color_hex: Optional[str] = Field(
|
||||||
|
None,
|
||||||
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
|
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
|
||||||
example="FF0000",
|
examples=["FF0000"],
|
||||||
)
|
)
|
||||||
external_id: Optional[str] = Field(
|
external_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=256,
|
max_length=256,
|
||||||
description=(
|
description=(
|
||||||
"Set if this filament comes from an external database. This contains the ID in the external database."
|
"Set if this filament comes from an external database. This contains the ID in the external database."
|
||||||
),
|
),
|
||||||
example="polymaker_pla_polysonicblack_1000_175",
|
examples=["polymaker_pla_polysonicblack_1000_175"],
|
||||||
)
|
)
|
||||||
extra: Optional[dict[str, str]] = Field(
|
extra: Optional[dict[str, str]] = Field(
|
||||||
None,
|
None,
|
||||||
description="Extra fields for this filament.",
|
description="Extra fields for this filament.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@validator("color_hex")
|
@field_validator("color_hex")
|
||||||
|
@classmethod
|
||||||
@classmethod
|
@classmethod
|
||||||
def color_hex_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
|
def color_hex_validator(cls, v: Optional[str]) -> Optional[str]: # noqa: ANN102
|
||||||
"""Validate the color_hex field."""
|
"""Validate the color_hex field."""
|
||||||
@@ -115,8 +124,16 @@ class FilamentParameters(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FilamentUpdateParameters(FilamentParameters):
|
class FilamentUpdateParameters(FilamentParameters):
|
||||||
density: Optional[float] = Field(gt=0, description="The density of this filament in g/cm3.", example=1.24)
|
density: Optional[float] = Field(None, gt=0, description="The density of this filament in g/cm3.", examples=[1.24])
|
||||||
diameter: Optional[float] = Field(gt=0, description="The diameter of this filament in mm.", example=1.75)
|
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(
|
@router.get(
|
||||||
@@ -149,6 +166,7 @@ async def find(
|
|||||||
title="Vendor ID",
|
title="Vendor ID",
|
||||||
description="See vendor.id.",
|
description="See vendor.id.",
|
||||||
deprecated=True,
|
deprecated=True,
|
||||||
|
pattern=r"^-?\d+(,-?\d+)*$",
|
||||||
),
|
),
|
||||||
vendor_name: Optional[str] = Query(
|
vendor_name: Optional[str] = Query(
|
||||||
alias="vendor.name",
|
alias="vendor.name",
|
||||||
@@ -167,6 +185,7 @@ async def find(
|
|||||||
"Match an exact vendor ID. Separate multiple IDs with a comma. "
|
"Match an exact vendor ID. Separate multiple IDs with a comma. "
|
||||||
"Specify -1 to match filaments with no vendor."
|
"Specify -1 to match filaments with no vendor."
|
||||||
),
|
),
|
||||||
|
pattern=r"^-?\d+(,-?\d+)*$",
|
||||||
examples=["1", "1,2"],
|
examples=["1", "1,2"],
|
||||||
),
|
),
|
||||||
name: Optional[str] = Query(
|
name: Optional[str] = Query(
|
||||||
@@ -243,10 +262,7 @@ async def find(
|
|||||||
|
|
||||||
vendor_id = vendor_id if vendor_id is not None else vendor_id_old
|
vendor_id = vendor_id if vendor_id is not None else vendor_id_old
|
||||||
if vendor_id is not None:
|
if vendor_id is not None:
|
||||||
try:
|
vendor_ids = [int(vendor_id_item) for vendor_id_item in vendor_id.split(",")]
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
vendor_ids = None
|
vendor_ids = None
|
||||||
|
|
||||||
@@ -399,12 +415,7 @@ async def update( # noqa: ANN201
|
|||||||
filament_id: int,
|
filament_id: int,
|
||||||
body: FilamentUpdateParameters,
|
body: FilamentUpdateParameters,
|
||||||
):
|
):
|
||||||
patch_data = body.dict(exclude_unset=True)
|
patch_data = body.model_dump(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"))])
|
|
||||||
|
|
||||||
if body.extra:
|
if body.extra:
|
||||||
all_fields = await get_extra_fields(db, EntityType.filament)
|
all_fields = await get_extra_fields(db, EntityType.filament)
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Literal, Optional
|
from typing import Annotated, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel as PydanticBaseModel
|
from pydantic import BaseModel, Field, PlainSerializer
|
||||||
from pydantic import Field
|
|
||||||
|
|
||||||
from spoolman.database import models
|
from spoolman.database import models
|
||||||
from spoolman.math import length_from_weight
|
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")
|
return dt.isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(PydanticBaseModel):
|
SpoolmanDateTime = Annotated[datetime, PlainSerializer(datetime_to_str)]
|
||||||
class Config:
|
|
||||||
"""Pydantic configuration."""
|
|
||||||
|
|
||||||
json_encoders = { # noqa: RUF012
|
|
||||||
datetime: datetime_to_str,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
class Message(BaseModel):
|
||||||
@@ -57,16 +50,27 @@ class SettingKV(BaseModel):
|
|||||||
|
|
||||||
class Vendor(BaseModel):
|
class Vendor(BaseModel):
|
||||||
id: int = Field(description="Unique internal ID of this vendor.")
|
id: int = Field(description="Unique internal ID of this vendor.")
|
||||||
registered: datetime = Field(description="When the vendor was registered in the database. UTC Timezone.")
|
registered: SpoolmanDateTime = Field(description="When the vendor was registered in the database. UTC Timezone.")
|
||||||
name: str = Field(max_length=64, description="Vendor name.", example="Polymaker")
|
name: str = Field(max_length=64, description="Vendor name.", examples=["Polymaker"])
|
||||||
comment: Optional[str] = Field(max_length=1024, description="Free text comment about this vendor.", example="")
|
comment: Optional[str] = Field(
|
||||||
empty_spool_weight: Optional[float] = Field(gt=0, description="The empty spool weight, in grams.", example=140)
|
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(
|
external_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=256,
|
max_length=256,
|
||||||
description=(
|
description=(
|
||||||
"Set if this vendor comes from an external database. This contains the ID in the external database."
|
"Set if this vendor comes from an external database. This contains the ID in the external database."
|
||||||
),
|
),
|
||||||
example="eSun",
|
examples=["eSun"],
|
||||||
)
|
)
|
||||||
extra: dict[str, str] = Field(
|
extra: dict[str, str] = Field(
|
||||||
description=(
|
description=(
|
||||||
@@ -91,66 +95,76 @@ class Vendor(BaseModel):
|
|||||||
|
|
||||||
class Filament(BaseModel):
|
class Filament(BaseModel):
|
||||||
id: int = Field(description="Unique internal ID of this filament type.")
|
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(
|
name: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description=(
|
description=(
|
||||||
"Filament name, to distinguish this filament type among others from the same vendor."
|
"Filament name, to distinguish this filament type among others from the same vendor."
|
||||||
"Should contain its color for example."
|
"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(
|
material: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="The material of this filament, e.g. PLA.",
|
description="The material of this filament, e.g. PLA.",
|
||||||
example="PLA",
|
examples=["PLA"],
|
||||||
)
|
)
|
||||||
price: Optional[float] = Field(
|
price: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The price of this filament in the system configured currency.",
|
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)
|
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.", example=1.75)
|
diameter: float = Field(gt=0, description="The diameter of this filament in mm.", examples=[1.75])
|
||||||
weight: Optional[float] = Field(
|
weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
gt=0,
|
gt=0,
|
||||||
description="The weight of the filament in a full spool, in grams.",
|
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, gt=0, description="The empty spool weight, in grams.", examples=[140])
|
||||||
article_number: Optional[str] = Field(
|
article_number: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="Vendor article number, e.g. EAN, QR code, etc.",
|
description="Vendor article number, e.g. EAN, QR code, etc.",
|
||||||
example="PM70820",
|
examples=["PM70820"],
|
||||||
)
|
)
|
||||||
comment: Optional[str] = Field(
|
comment: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
description="Free text comment about this filament type.",
|
description="Free text comment about this filament type.",
|
||||||
example="",
|
examples=[""],
|
||||||
)
|
)
|
||||||
settings_extruder_temp: Optional[int] = Field(
|
settings_extruder_temp: Optional[int] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="Overridden extruder temperature, in °C.",
|
description="Overridden extruder temperature, in °C.",
|
||||||
example=210,
|
examples=[210],
|
||||||
)
|
)
|
||||||
settings_bed_temp: Optional[int] = Field(
|
settings_bed_temp: Optional[int] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="Overridden bed temperature, in °C.",
|
description="Overridden bed temperature, in °C.",
|
||||||
example=60,
|
examples=[60],
|
||||||
)
|
)
|
||||||
color_hex: Optional[str] = Field(
|
color_hex: Optional[str] = Field(
|
||||||
|
None,
|
||||||
min_length=6,
|
min_length=6,
|
||||||
max_length=8,
|
max_length=8,
|
||||||
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
|
description="Hexadecimal color code of the filament, e.g. FF0000 for red. Supports alpha channel at the end.",
|
||||||
example="FF0000",
|
examples=["FF0000"],
|
||||||
)
|
)
|
||||||
external_id: Optional[str] = Field(
|
external_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=256,
|
max_length=256,
|
||||||
description=(
|
description=(
|
||||||
"Set if this filament comes from an external database. This contains the ID in the external database."
|
"Set if this filament comes from an external database. This contains the ID in the external database."
|
||||||
),
|
),
|
||||||
example="polymaker_pla_polysonicblack_1000_175",
|
examples=["polymaker_pla_polysonicblack_1000_175"],
|
||||||
)
|
)
|
||||||
extra: dict[str, str] = Field(
|
extra: dict[str, str] = Field(
|
||||||
description=(
|
description=(
|
||||||
@@ -185,14 +199,21 @@ class Filament(BaseModel):
|
|||||||
|
|
||||||
class Spool(BaseModel):
|
class Spool(BaseModel):
|
||||||
id: int = Field(description="Unique internal ID of this spool of filament.")
|
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.")
|
registered: SpoolmanDateTime = 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.")
|
first_used: Optional[SpoolmanDateTime] = Field(
|
||||||
last_used: Optional[datetime] = Field(description="Last logged occurence of spool usage. UTC Timezone.")
|
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.")
|
filament: Filament = Field(description="The filament type of this spool.")
|
||||||
price: Optional[float] = Field(
|
price: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The price of this spool in the system configured currency.",
|
description="The price of this spool in the system configured currency.",
|
||||||
example=20.0,
|
examples=[20.0],
|
||||||
)
|
)
|
||||||
remaining_weight: Optional[float] = Field(
|
remaining_weight: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -201,21 +222,25 @@ class Spool(BaseModel):
|
|||||||
"Estimated remaining weight of filament on the spool in grams. "
|
"Estimated remaining weight of filament on the spool in grams. "
|
||||||
"Only set if the filament type has a weight set."
|
"Only set if the filament type has a weight set."
|
||||||
),
|
),
|
||||||
example=500.6,
|
examples=[500.6],
|
||||||
)
|
)
|
||||||
initial_weight: Optional[float] = Field(
|
initial_weight: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description=("The initial weight, in grams, of the filament on the spool (net weight)."),
|
description=("The initial weight, in grams, of the filament on the spool (net weight)."),
|
||||||
example=1246,
|
examples=[1246],
|
||||||
)
|
)
|
||||||
spool_weight: Optional[float] = Field(
|
spool_weight: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description=("Weight of an empty spool (tare weight)."),
|
description=("Weight of an empty spool (tare weight)."),
|
||||||
example=246,
|
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(
|
remaining_length: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=0,
|
ge=0,
|
||||||
@@ -223,23 +248,30 @@ class Spool(BaseModel):
|
|||||||
"Estimated remaining length of filament on the spool in millimeters."
|
"Estimated remaining length of filament on the spool in millimeters."
|
||||||
" Only set if the filament type has a weight set."
|
" Only set if the filament type has a weight set."
|
||||||
),
|
),
|
||||||
example=5612.4,
|
examples=[5612.4],
|
||||||
)
|
)
|
||||||
used_length: float = Field(
|
used_length: float = Field(
|
||||||
ge=0,
|
ge=0,
|
||||||
description="Consumed length of filament from the spool in millimeters.",
|
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(
|
lot_nr: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="Vendor manufacturing lot/batch number of the spool.",
|
description="Vendor manufacturing lot/batch number of the spool.",
|
||||||
example="52342",
|
examples=["52342"],
|
||||||
)
|
)
|
||||||
comment: Optional[str] = Field(
|
comment: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
description="Free text comment about this specific spool.",
|
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.")
|
archived: bool = Field(description="Whether this spool is archived and should not be used anymore.")
|
||||||
extra: dict[str, str] = Field(
|
extra: dict[str, str] = Field(
|
||||||
@@ -300,26 +332,26 @@ class Spool(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Info(BaseModel):
|
class Info(BaseModel):
|
||||||
version: str = Field(example="0.7.0")
|
version: str = Field(examples=["0.7.0"])
|
||||||
debug_mode: bool = Field(example=False)
|
debug_mode: bool = Field(examples=[False])
|
||||||
automatic_backups: bool = Field(example=True)
|
automatic_backups: bool = Field(examples=[True])
|
||||||
data_dir: str = Field(example="/home/app/.local/share/spoolman")
|
data_dir: str = Field(examples=["/home/app/.local/share/spoolman"])
|
||||||
logs_dir: str = Field(example="/home/app/.local/share/spoolman")
|
logs_dir: str = Field(examples=["/home/app/.local/share/spoolman"])
|
||||||
backups_dir: str = Field(example="/home/app/.local/share/spoolman/backups")
|
backups_dir: str = Field(examples=["/home/app/.local/share/spoolman/backups"])
|
||||||
db_type: str = Field(example="sqlite")
|
db_type: str = Field(examples=["sqlite"])
|
||||||
git_commit: Optional[str] = Field(example="a1b2c3d")
|
git_commit: Optional[str] = Field(None, examples=["a1b2c3d"])
|
||||||
build_date: Optional[datetime] = Field(example="2021-01-01T00:00:00Z")
|
build_date: Optional[SpoolmanDateTime] = Field(None, examples=["2021-01-01T00:00:00Z"])
|
||||||
|
|
||||||
|
|
||||||
class HealthCheck(BaseModel):
|
class HealthCheck(BaseModel):
|
||||||
status: str = Field(example="healthy")
|
status: str = Field(examples=["healthy"])
|
||||||
|
|
||||||
|
|
||||||
class BackupResponse(BaseModel):
|
class BackupResponse(BaseModel):
|
||||||
path: str = Field(
|
path: str = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Path to the created backup file.",
|
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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -336,7 +368,7 @@ class Event(BaseModel):
|
|||||||
|
|
||||||
type: EventType = Field(description="Event type.")
|
type: EventType = Field(description="Event type.")
|
||||||
resource: str = Field(description="Resource 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
|
payload: BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ from typing import Annotated, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from pydantic.error_wrappers import ErrorWrapper
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from spoolman.api.v1.models import Message, Spool, SpoolEvent
|
from spoolman.api.v1.models import Message, Spool, SpoolEvent
|
||||||
@@ -32,42 +30,58 @@ router = APIRouter(
|
|||||||
|
|
||||||
|
|
||||||
class SpoolParameters(BaseModel):
|
class SpoolParameters(BaseModel):
|
||||||
first_used: Optional[datetime] = Field(description="First logged occurence of spool usage.")
|
first_used: Optional[datetime] = Field(None, description="First logged occurence of spool usage.")
|
||||||
last_used: Optional[datetime] = Field(description="Last 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.")
|
filament_id: int = Field(description="The ID of the filament type of this spool.")
|
||||||
price: Optional[float] = Field(
|
price: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The price of this filament in the system configured currency.",
|
description="The price of this filament in the system configured currency.",
|
||||||
example=20.0,
|
examples=[20.0],
|
||||||
)
|
)
|
||||||
initial_weight: Optional[float] = Field(
|
initial_weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The initial weight of the filament on the spool, in grams. (net weight)",
|
description="The initial weight of the filament on the spool, in grams. (net weight)",
|
||||||
example=200,
|
examples=[200],
|
||||||
)
|
)
|
||||||
spool_weight: Optional[float] = Field(
|
spool_weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The weight of an empty spool, in grams. (tare weight)",
|
description="The weight of an empty spool, in grams. (tare weight)",
|
||||||
example=200,
|
examples=[200],
|
||||||
)
|
)
|
||||||
remaining_weight: Optional[float] = Field(
|
remaining_weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description=(
|
description=(
|
||||||
"Remaining weight of filament on the spool. Can only be used if the filament type has a weight set."
|
"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(
|
lot_nr: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
description="Vendor manufacturing lot/batch number of the spool.",
|
description="Vendor manufacturing lot/batch number of the spool.",
|
||||||
example="52342",
|
examples=["52342"],
|
||||||
)
|
)
|
||||||
comment: Optional[str] = Field(
|
comment: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
description="Free text comment about this specific spool.",
|
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.")
|
archived: bool = Field(default=False, description="Whether this spool is archived and should not be used anymore.")
|
||||||
extra: Optional[dict[str, str]] = Field(
|
extra: Optional[dict[str, str]] = Field(
|
||||||
@@ -77,16 +91,24 @@ class SpoolParameters(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SpoolUpdateParameters(SpoolParameters):
|
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):
|
class SpoolUseParameters(BaseModel):
|
||||||
use_length: Optional[float] = Field(description="Length of filament to reduce by, in mm.", example=2.2)
|
use_length: Optional[float] = Field(None, description="Length of filament to reduce by, in mm.", examples=[2.2])
|
||||||
use_weight: Optional[float] = Field(description="Filament weight to reduce by, in g.", example=5.3)
|
use_weight: Optional[float] = Field(None, description="Filament weight to reduce by, in g.", examples=[5.3])
|
||||||
|
|
||||||
|
|
||||||
class SpoolMeasureParameters(BaseModel):
|
class SpoolMeasureParameters(BaseModel):
|
||||||
weight: float = Field(description="Current gross weight of the spool, in g.", example=200)
|
weight: float = Field(description="Current gross weight of the spool, in g.", examples=[200])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -119,6 +141,7 @@ async def find(
|
|||||||
title="Filament ID",
|
title="Filament ID",
|
||||||
description="See filament.id.",
|
description="See filament.id.",
|
||||||
deprecated=True,
|
deprecated=True,
|
||||||
|
pattern=r"^-?\d+(,-?\d+)*$",
|
||||||
),
|
),
|
||||||
filament_material_old: Optional[str] = Query(
|
filament_material_old: Optional[str] = Query(
|
||||||
alias="filament_material",
|
alias="filament_material",
|
||||||
@@ -140,6 +163,7 @@ async def find(
|
|||||||
title="Vendor ID",
|
title="Vendor ID",
|
||||||
description="See filament.vendor.id.",
|
description="See filament.vendor.id.",
|
||||||
deprecated=True,
|
deprecated=True,
|
||||||
|
pattern=r"^-?\d+(,-?\d+)*$",
|
||||||
),
|
),
|
||||||
filament_name: Optional[str] = Query(
|
filament_name: Optional[str] = Query(
|
||||||
alias="filament.name",
|
alias="filament.name",
|
||||||
@@ -156,6 +180,7 @@ async def find(
|
|||||||
title="Filament ID",
|
title="Filament ID",
|
||||||
description="Match an exact filament ID. Separate multiple IDs with a comma.",
|
description="Match an exact filament ID. Separate multiple IDs with a comma.",
|
||||||
examples=["1", "1,2"],
|
examples=["1", "1,2"],
|
||||||
|
pattern=r"^-?\d+(,-?\d+)*$",
|
||||||
),
|
),
|
||||||
filament_material: Optional[str] = Query(
|
filament_material: Optional[str] = Query(
|
||||||
alias="filament.material",
|
alias="filament.material",
|
||||||
@@ -184,6 +209,7 @@ async def find(
|
|||||||
"Set it to -1 to match spools with filaments with no vendor."
|
"Set it to -1 to match spools with filaments with no vendor."
|
||||||
),
|
),
|
||||||
examples=["1", "1,2"],
|
examples=["1", "1,2"],
|
||||||
|
pattern=r"^-?\d+(,-?\d+)*$",
|
||||||
),
|
),
|
||||||
location: Optional[str] = Query(
|
location: Optional[str] = Query(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -233,21 +259,13 @@ async def find(
|
|||||||
|
|
||||||
filament_id = filament_id if filament_id is not None else filament_id_old
|
filament_id = filament_id if filament_id is not None else filament_id_old
|
||||||
if filament_id is not None:
|
if filament_id is not None:
|
||||||
try:
|
filament_ids = [int(filament_id_item) for filament_id_item in filament_id.split(",")]
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
filament_ids = None
|
filament_ids = None
|
||||||
|
|
||||||
filament_vendor_id = filament_vendor_id if filament_vendor_id is not None else vendor_id_old
|
filament_vendor_id = filament_vendor_id if filament_vendor_id is not None else vendor_id_old
|
||||||
if filament_vendor_id is not None:
|
if filament_vendor_id is not None:
|
||||||
try:
|
filament_vendor_ids = [int(vendor_id_item) for vendor_id_item in filament_vendor_id.split(",")]
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
filament_vendor_ids = None
|
filament_vendor_ids = None
|
||||||
|
|
||||||
@@ -409,7 +427,7 @@ async def update( # noqa: ANN201
|
|||||||
spool_id: int,
|
spool_id: int,
|
||||||
body: SpoolUpdateParameters,
|
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:
|
if body.remaining_weight is not None and body.used_weight is not None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -424,11 +442,6 @@ async def update( # noqa: ANN201
|
|||||||
except ValueError as e:
|
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)).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:
|
try:
|
||||||
db_item = await spool.update(
|
db_item = await spool.update(
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ from typing import Annotated, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
from pydantic.error_wrappers import ErrorWrapper
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from spoolman.api.v1.models import Message, Vendor, VendorEvent
|
from spoolman.api.v1.models import Message, Vendor, VendorEvent
|
||||||
@@ -27,23 +25,26 @@ router = APIRouter(
|
|||||||
|
|
||||||
|
|
||||||
class VendorParameters(BaseModel):
|
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(
|
comment: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
description="Free text comment about this vendor.",
|
description="Free text comment about this vendor.",
|
||||||
example="",
|
examples=[""],
|
||||||
)
|
)
|
||||||
empty_spool_weight: Optional[float] = Field(
|
empty_spool_weight: Optional[float] = Field(
|
||||||
|
None,
|
||||||
ge=0,
|
ge=0,
|
||||||
description="The weight of an empty spool, in grams.",
|
description="The weight of an empty spool, in grams.",
|
||||||
example=200,
|
examples=[200],
|
||||||
)
|
)
|
||||||
external_id: Optional[str] = Field(
|
external_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
max_length=256,
|
max_length=256,
|
||||||
description=(
|
description=(
|
||||||
"Set if this vendor comes from an external database. This contains the ID in the external database."
|
"Set if this vendor comes from an external database. This contains the ID in the external database."
|
||||||
),
|
),
|
||||||
example="eSun",
|
examples=["eSun"],
|
||||||
)
|
)
|
||||||
extra: Optional[dict[str, str]] = Field(
|
extra: Optional[dict[str, str]] = Field(
|
||||||
None,
|
None,
|
||||||
@@ -52,7 +53,15 @@ class VendorParameters(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VendorUpdateParameters(VendorParameters):
|
class VendorUpdateParameters(VendorParameters):
|
||||||
name: Optional[str] = Field(max_length=64, description="Vendor name.", example="Polymaker")
|
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(
|
@router.get(
|
||||||
@@ -233,10 +242,7 @@ async def update( # noqa: ANN201
|
|||||||
vendor_id: int,
|
vendor_id: int,
|
||||||
body: VendorUpdateParameters,
|
body: VendorUpdateParameters,
|
||||||
):
|
):
|
||||||
patch_data = body.dict(exclude_unset=True)
|
patch_data = body.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if "name" in patch_data and body.name is None:
|
|
||||||
raise RequestValidationError([ErrorWrapper(ValueError("name cannot be unset"), ("query", "name"))])
|
|
||||||
|
|
||||||
if body.extra:
|
if body.extra:
|
||||||
all_fields = await get_extra_fields(db, EntityType.vendor)
|
all_fields = await get_extra_fields(db, EntityType.vendor)
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class Database:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The path to the created backup or None if no backup was created.
|
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:
|
if not self.is_file_based_sqlite() or self.connection_url.database is None:
|
||||||
logger.info("Skipping backup as the database is not SQLite.")
|
logger.info("Skipping backup as the database is not SQLite.")
|
||||||
@@ -159,6 +160,7 @@ def setup_db(connection_url: URL) -> None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
connection_url: The URL to connect to the database.
|
connection_url: The URL to connect to the database.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
global __db # noqa: PLW0603
|
global __db # noqa: PLW0603
|
||||||
__db = Database(connection_url)
|
__db = Database(connection_url)
|
||||||
@@ -170,6 +172,7 @@ async def backup_global_db(num_backups: int = 5) -> Optional[Path]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The path to the created backup or None if no backup was created.
|
The path to the created backup or None if no backup was created.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if __db is None:
|
if __db is None:
|
||||||
raise RuntimeError("DB is not setup.")
|
raise RuntimeError("DB is not setup.")
|
||||||
@@ -198,6 +201,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
scheduler: The scheduler to use for scheduling tasks.
|
scheduler: The scheduler to use for scheduling tasks.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if __db is None:
|
if __db is None:
|
||||||
raise RuntimeError("DB is not setup.")
|
raise RuntimeError("DB is not setup.")
|
||||||
@@ -218,6 +222,7 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
The database session.
|
The database session.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if __db is None or __db.session_maker is None:
|
if __db is None or __db.session_maker is None:
|
||||||
raise RuntimeError("DB is not setup.")
|
raise RuntimeError("DB is not setup.")
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ async def use_weight_safe(db: AsyncSession, spool_id: int, weight: float) -> Non
|
|||||||
.where(models.Spool.id == spool_id)
|
.where(models.Spool.id == spool_id)
|
||||||
.values(
|
.values(
|
||||||
used_weight=case(
|
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
|
else_=0.0, # Set used_weight to 0 if the result would be negative
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def generate_openapi(app: FastAPI) -> dict[str, Any]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: The OpenAPI document.
|
dict[str, Any]: The OpenAPI document.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return get_openapi(
|
return get_openapi(
|
||||||
title=app.title,
|
title=app.title,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class DatabaseType(Enum):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The drivername.
|
str: The drivername.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if self is DatabaseType.POSTGRES:
|
if self is DatabaseType.POSTGRES:
|
||||||
return "postgresql+asyncpg"
|
return "postgresql+asyncpg"
|
||||||
@@ -47,6 +48,7 @@ def get_database_type() -> Optional[DatabaseType]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[DatabaseType]: The database type.
|
Optional[DatabaseType]: The database type.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
database_type = os.getenv("SPOOLMAN_DB_TYPE")
|
database_type = os.getenv("SPOOLMAN_DB_TYPE")
|
||||||
if database_type is None:
|
if database_type is None:
|
||||||
@@ -69,6 +71,7 @@ def get_host() -> Optional[str]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The host.
|
Optional[str]: The host.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return os.getenv("SPOOLMAN_DB_HOST")
|
return os.getenv("SPOOLMAN_DB_HOST")
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ def get_port() -> Optional[int]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The port.
|
Optional[str]: The port.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
port = os.getenv("SPOOLMAN_DB_PORT")
|
port = os.getenv("SPOOLMAN_DB_PORT")
|
||||||
if port is None:
|
if port is None:
|
||||||
@@ -97,6 +101,7 @@ def get_database() -> Optional[str]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The name.
|
Optional[str]: The name.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return os.getenv("SPOOLMAN_DB_NAME")
|
return os.getenv("SPOOLMAN_DB_NAME")
|
||||||
|
|
||||||
@@ -108,6 +113,7 @@ def get_query() -> Optional[dict[str, str]]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[dict]: The query.
|
Optional[dict]: The query.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
query = os.getenv("SPOOLMAN_DB_QUERY")
|
query = os.getenv("SPOOLMAN_DB_QUERY")
|
||||||
if query is None:
|
if query is None:
|
||||||
@@ -126,6 +132,7 @@ def get_username() -> Optional[str]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The username.
|
Optional[str]: The username.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return os.getenv("SPOOLMAN_DB_USERNAME")
|
return os.getenv("SPOOLMAN_DB_USERNAME")
|
||||||
|
|
||||||
@@ -140,6 +147,7 @@ def get_password() -> Optional[str]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The password.
|
Optional[str]: The password.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# First attempt: grab password from a file. This is the most secure way of storing passwords.
|
# First attempt: grab password from a file. This is the most secure way of storing passwords.
|
||||||
file_path = os.getenv("SPOOLMAN_DB_PASSWORD_FILE")
|
file_path = os.getenv("SPOOLMAN_DB_PASSWORD_FILE")
|
||||||
@@ -170,6 +178,7 @@ def get_logging_level() -> int:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The logging level.
|
str: The logging level.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
log_level_str = os.getenv("SPOOLMAN_LOGGING_LEVEL", "INFO").upper()
|
log_level_str = os.getenv("SPOOLMAN_LOGGING_LEVEL", "INFO").upper()
|
||||||
if log_level_str == "DEBUG":
|
if log_level_str == "DEBUG":
|
||||||
@@ -192,6 +201,7 @@ def is_debug_mode() -> bool:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: Whether debug mode is enabled.
|
bool: Whether debug mode is enabled.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
debug_mode = os.getenv("SPOOLMAN_DEBUG_MODE", "FALSE").upper()
|
debug_mode = os.getenv("SPOOLMAN_DEBUG_MODE", "FALSE").upper()
|
||||||
if debug_mode in {"FALSE", "0"}:
|
if debug_mode in {"FALSE", "0"}:
|
||||||
@@ -208,6 +218,7 @@ def is_automatic_backup_enabled() -> bool:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: Whether automatic backup is enabled.
|
bool: Whether automatic backup is enabled.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper()
|
automatic_backup = os.getenv("SPOOLMAN_AUTOMATIC_BACKUP", "TRUE").upper()
|
||||||
if automatic_backup in {"FALSE", "0"}:
|
if automatic_backup in {"FALSE", "0"}:
|
||||||
@@ -224,6 +235,7 @@ def get_data_dir() -> Path:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: The data directory.
|
Path: The data directory.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
env_data_dir = os.getenv("SPOOLMAN_DIR_DATA")
|
env_data_dir = os.getenv("SPOOLMAN_DIR_DATA")
|
||||||
if env_data_dir is not None:
|
if env_data_dir is not None:
|
||||||
@@ -239,6 +251,7 @@ def get_logs_dir() -> Path:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: The logs directory.
|
Path: The logs directory.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
env_logs_dir = os.getenv("SPOOLMAN_DIR_LOGS")
|
env_logs_dir = os.getenv("SPOOLMAN_DIR_LOGS")
|
||||||
if env_logs_dir is not None:
|
if env_logs_dir is not None:
|
||||||
@@ -254,6 +267,7 @@ def get_backups_dir() -> Path:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: The backups directory.
|
Path: The backups directory.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
env_backups_dir = os.getenv("SPOOLMAN_DIR_BACKUPS")
|
env_backups_dir = os.getenv("SPOOLMAN_DIR_BACKUPS")
|
||||||
if env_backups_dir is not None:
|
if env_backups_dir is not None:
|
||||||
@@ -274,6 +288,7 @@ def get_version() -> str:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The version.
|
str: The version.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Read version from pyproject.toml, don't use pkg_resources because it requires the package to be installed
|
# 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:
|
with Path("pyproject.toml").open(encoding="utf-8") as f:
|
||||||
@@ -290,6 +305,7 @@ def get_commit_hash() -> Optional[str]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[str]: The commit hash.
|
Optional[str]: The commit hash.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Read commit has from build.txt
|
# Read commit has from build.txt
|
||||||
# commit is written as GIT_COMMIT=<hash> in build.txt
|
# commit is written as GIT_COMMIT=<hash> in build.txt
|
||||||
@@ -308,6 +324,7 @@ def get_build_date() -> Optional[datetime]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[datetime.datetime]: The build date.
|
Optional[datetime.datetime]: The build date.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Read build date has from build.txt
|
# Read build date has from build.txt
|
||||||
# build date is written as BUILD_DATE=<hash> in build.txt
|
# build date is written as BUILD_DATE=<hash> in build.txt
|
||||||
@@ -394,6 +411,7 @@ def is_metrics_enabled() -> bool:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: Whether collect metrics is enabled.
|
bool: Whether collect metrics is enabled.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
metrics_enabled = os.getenv("SPOOLMAN_METRICS_ENABLED", "FALSE").upper()
|
metrics_enabled = os.getenv("SPOOLMAN_METRICS_ENABLED", "FALSE").upper()
|
||||||
if metrics_enabled in {"FALSE", "0"}:
|
if metrics_enabled in {"FALSE", "0"}:
|
||||||
@@ -412,6 +430,7 @@ def get_base_path() -> str:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The base path.
|
str: The base path.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
path = os.getenv("SPOOLMAN_BASE_PATH", "")
|
path = os.getenv("SPOOLMAN_BASE_PATH", "")
|
||||||
if len(path) == 0:
|
if len(path) == 0:
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import hishel
|
import hishel
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, RootModel
|
||||||
from scheduler.asyncio.scheduler import Scheduler
|
from scheduler.asyncio.scheduler import Scheduler
|
||||||
|
|
||||||
from spoolman import filecache
|
from spoolman import filecache
|
||||||
@@ -55,51 +56,67 @@ class Pattern(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ExternalFilament(BaseModel):
|
class ExternalFilament(BaseModel):
|
||||||
id: str = Field(description="A unique ID for this filament.", example="polymaker_pla_polysonicblack_1000_175")
|
id: str = Field(description="A unique ID for this filament.", examples=["polymaker_pla_polysonicblack_1000_175"])
|
||||||
manufacturer: str = Field(description="Filament manufacturer.", example="Polymaker")
|
manufacturer: str = Field(description="Filament manufacturer.", examples=["Polymaker"])
|
||||||
name: str = Field(description="Filament name.", example="Polysonic\u2122 Black")
|
name: str = Field(description="Filament name.", examples=["Polysonic\u2122 Black"])
|
||||||
material: str = Field(description="Filament material.", example="PLA")
|
material: str = Field(description="Filament material.", examples=["PLA"])
|
||||||
density: float = Field(description="Density in g/cm3.", example=1.23)
|
density: float = Field(description="Density in g/cm3.", examples=[1.23])
|
||||||
weight: float = Field(description="Net weight of a single spool.", example=1000)
|
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.", example=140)
|
spool_weight: Optional[float] = Field(default=None, description="Weight of an empty spool.", examples=[140])
|
||||||
spool_type: Optional[SpoolType] = Field(description="Type of spool.", example=SpoolType.PLASTIC)
|
spool_type: Optional[SpoolType] = Field(None, description="Type of spool.", examples=[SpoolType.PLASTIC])
|
||||||
diameter: float = Field(description="Filament in mm.", example=1.75)
|
diameter: float = Field(description="Filament in mm.", examples=[1.75])
|
||||||
color_hex: Optional[str] = Field(
|
color_hex: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Filament color code in hex format, for single-color filaments.",
|
description="Filament color code in hex format, for single-color filaments.",
|
||||||
example="2c3232",
|
examples=["2c3232"],
|
||||||
)
|
)
|
||||||
color_hexes: Optional[list[str]] = Field(
|
color_hexes: Optional[list[str]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="For multi-color filaments. List of hex color codes in hex format.",
|
description="For multi-color filaments. List of hex color codes in hex format.",
|
||||||
example=["2c3232", "5f5f5f"],
|
examples=[["2c3232", "5f5f5f"]],
|
||||||
)
|
)
|
||||||
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", example=210)
|
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.", example=50)
|
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.", example=Finish.MATTE)
|
finish: Optional[Finish] = Field(default=None, description="Finish of the filament.", examples=[Finish.MATTE])
|
||||||
multi_color_direction: Optional[MultiColorDirection] = Field(
|
multi_color_direction: Optional[MultiColorDirection] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Direction of multi-color filaments.",
|
description="Direction of multi-color filaments.",
|
||||||
example=MultiColorDirection.COAXIAL,
|
examples=[MultiColorDirection.COAXIAL],
|
||||||
)
|
)
|
||||||
pattern: Optional[Pattern] = Field(default=None, description="Pattern of the filament.", example=Pattern.MARBLE)
|
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.")
|
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.")
|
glow: bool = Field(default=False, description="Whether the filament is glow-in-the-dark.")
|
||||||
|
|
||||||
|
|
||||||
class ExternalFilamentsFile(BaseModel):
|
class ExternalFilamentsFile(RootModel):
|
||||||
__root__: list[ExternalFilament]
|
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):
|
class ExternalMaterial(BaseModel):
|
||||||
material: str = Field(example="PLA")
|
material: str = Field(examples=["PLA"])
|
||||||
density: float = Field(example=1.24)
|
density: float = Field(examples=[1.24])
|
||||||
extruder_temp: Optional[int] = Field(default=None, description="Extruder/nozzle temperature in °C.", example=210)
|
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.", example=50)
|
bed_temp: Optional[int] = Field(default=None, description="Bed temperature in °C.", examples=[50])
|
||||||
|
|
||||||
|
|
||||||
class ExternalMaterialsFile(BaseModel):
|
class ExternalMaterialsFile(RootModel):
|
||||||
__root__: list[ExternalMaterial]
|
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:
|
def get_external_db_url() -> str:
|
||||||
@@ -161,8 +178,8 @@ async def _sync() -> None:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"External DB synced. Filaments: %d, Materials: %d",
|
"External DB synced. Filaments: %d, Materials: %d",
|
||||||
len(filaments.__root__),
|
len(filaments.root),
|
||||||
len(materials.__root__),
|
len(materials.root),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,6 +188,7 @@ def schedule_tasks(scheduler: Scheduler) -> None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
scheduler: The scheduler to use for scheduling tasks.
|
scheduler: The scheduler to use for scheduling tasks.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if len(get_external_db_url().strip()) == 0:
|
if len(get_external_db_url().strip()) == 0:
|
||||||
logger.info("External DB URL is empty. Skipping sync.")
|
logger.info("External DB URL is empty. Skipping sync.")
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ class ExtraFieldParameters(BaseModel):
|
|||||||
choices: Optional[list[str]] = Field(
|
choices: Optional[list[str]] = Field(
|
||||||
None,
|
None,
|
||||||
description="Choices for the field, only for field type choice",
|
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")
|
multi_choice: Optional[bool] = Field(None, description="Whether multiple choices can be selected")
|
||||||
|
|
||||||
|
|
||||||
class ExtraField(ExtraFieldParameters):
|
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")
|
entity_type: EntityType = Field(description="Entity type this field is for")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def weight_from_length(*, length: float, diameter: float, density: float) -> flo
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: Weight in g
|
float: Weight in g
|
||||||
|
|
||||||
"""
|
"""
|
||||||
volume_mm3 = length * math.pi * (diameter / 2) ** 2
|
volume_mm3 = length * math.pi * (diameter / 2) ** 2
|
||||||
volume_cm3 = volume_mm3 / 1000
|
volume_cm3 = volume_mm3 / 1000
|
||||||
@@ -31,6 +32,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: Length in mm
|
float: Length in mm
|
||||||
|
|
||||||
"""
|
"""
|
||||||
volume_cm3 = weight / density
|
volume_cm3 = weight / density
|
||||||
volume_mm3 = volume_cm3 * 1000
|
volume_mm3 = volume_cm3 * 1000
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Prometheus metrics collectors."""
|
"""Prometheus metrics collectors."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
@@ -13,16 +14,16 @@ registry = REGISTRY
|
|||||||
|
|
||||||
PREFIX = "spoolman"
|
PREFIX = "spoolman"
|
||||||
|
|
||||||
SPOOL_PRICE = Gauge("%s_spool_price" % PREFIX, "Total Spool price", ["spool_id", "filament_id"])
|
SPOOL_PRICE = Gauge(f"{PREFIX}_spool_price", "Total Spool price", ["spool_id", "filament_id"])
|
||||||
SPOOL_USED_WEIGHT = Gauge("%s_spool_weight_used" % PREFIX, "Spool Used Weight", ["spool_id", "filament_id"])
|
SPOOL_USED_WEIGHT = Gauge(f"{PREFIX}_spool_weight_used", "Spool Used Weight", ["spool_id", "filament_id"])
|
||||||
FILAMENT_INFO = Gauge(
|
FILAMENT_INFO = Gauge(
|
||||||
"%s_filament_info" % PREFIX,
|
f"{PREFIX}_filament_info",
|
||||||
"Filament information",
|
"Filament information",
|
||||||
["filament_id", "vendor", "name", "material", "color"],
|
["filament_id", "vendor", "name", "material", "color"],
|
||||||
)
|
)
|
||||||
FILAMENT_DENSITY = Gauge("%s_filament_density" % PREFIX, "Density of filament", ["filament_id"])
|
FILAMENT_DENSITY = Gauge(f"{PREFIX}_filament_density", "Density of filament", ["filament_id"])
|
||||||
FILAMENT_DIAMETER = Gauge("%s_filament_diameter" % PREFIX, "Diameter of filament", ["filament_id"])
|
FILAMENT_DIAMETER = Gauge(f"{PREFIX}_filament_diameter", "Diameter of filament", ["filament_id"])
|
||||||
FILAMENT_WEIGHT = Gauge("%s_filament_weight" % PREFIX, "Net weight of filament", ["filament_id"])
|
FILAMENT_WEIGHT = Gauge(f"{PREFIX}_filament_weight", "Net weight of filament", ["filament_id"])
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ async def spool_metrics(db: AsyncSession) -> None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: async db session
|
db: async db session
|
||||||
|
|
||||||
"""
|
"""
|
||||||
stmt = sqlalchemy.select(models.Spool).where(
|
stmt = sqlalchemy.select(models.Spool).where(
|
||||||
sqlalchemy.or_(
|
sqlalchemy.or_(
|
||||||
@@ -61,6 +63,7 @@ async def filament_metrics(db: AsyncSession) -> None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: async db session
|
db: async db session
|
||||||
|
|
||||||
"""
|
"""
|
||||||
stmt = (
|
stmt = (
|
||||||
sqlalchemy.select(models.Filament)
|
sqlalchemy.select(models.Filament)
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ def length_from_weight(*, weight: float, diameter: float, density: float) -> flo
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
float: Length in mm
|
float: Length in mm
|
||||||
|
|
||||||
"""
|
"""
|
||||||
volume_cm3 = weight / density
|
volume_cm3 = weight / density
|
||||||
volume_mm3 = volume_cm3 * 1000
|
volume_mm3 = volume_cm3 * 1000
|
||||||
@@ -256,6 +257,7 @@ def assert_dicts_compatible(actual: Any, expected: Any, path: str = "") -> None:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
AssertionError: If dictionaries are not compatible.
|
AssertionError: If dictionaries are not compatible.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Check if both inputs are dictionaries
|
# Check if both inputs are dictionaries
|
||||||
if not (isinstance(actual, dict) and isinstance(expected, dict)):
|
if not (isinstance(actual, dict) and isinstance(expected, dict)):
|
||||||
|
|||||||
@@ -95,6 +95,44 @@ def test_update_filament(random_vendor: dict[str, Any]):
|
|||||||
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
|
httpx.delete(f"{URL}/api/v1/filament/{filament['id']}").raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_filament_cant_set_none(random_vendor: dict[str, Any]):
|
||||||
|
"""Test updating a filament and setting density and diameter fields to None, should result in 422 error."""
|
||||||
|
# Setup
|
||||||
|
result = httpx.post(
|
||||||
|
f"{URL}/api/v1/filament",
|
||||||
|
json={
|
||||||
|
"name": "Filament X",
|
||||||
|
"vendor_id": random_vendor["id"],
|
||||||
|
"material": "PLA",
|
||||||
|
"price": 100,
|
||||||
|
"density": 1.25,
|
||||||
|
"diameter": 1.75,
|
||||||
|
"weight": 1000,
|
||||||
|
"spool_weight": 250,
|
||||||
|
"article_number": "123456789",
|
||||||
|
"comment": "abcdefghåäö",
|
||||||
|
"settings_extruder_temp": 200,
|
||||||
|
"settings_bed_temp": 60,
|
||||||
|
"color_hex": "FF0000",
|
||||||
|
"external_id": "external_id1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result.raise_for_status()
|
||||||
|
added_filament = result.json()
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
result = httpx.patch(
|
||||||
|
f"{URL}/api/v1/filament/{added_filament['id']}",
|
||||||
|
json={"density": None, "diameter": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert result.status_code == 422
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
httpx.delete(f"{URL}/api/v1/filament/{added_filament['id']}").raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
def test_update_filament_not_found():
|
def test_update_filament_not_found():
|
||||||
"""Test updating a filament that does not exist."""
|
"""Test updating a filament that does not exist."""
|
||||||
# Execute
|
# Execute
|
||||||
|
|||||||
Reference in New Issue
Block a user