Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled
- Backend proxy endpoints: GET /filamentcolors/manufacturers, GET /filamentcolors/search - Frontend: FilamentColorLookup modal with manufacturer filter + color search - Color grid shows swatches with hex codes, click to apply - Added to both filament create and edit forms next to ColorPicker Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
476 lines
14 KiB
Python
476 lines
14 KiB
Python
"""Filament related endpoints."""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Annotated, Optional
|
|
|
|
import httpx
|
|
import sqlalchemy
|
|
from fastapi import APIRouter, Depends, Query
|
|
from pydantic import BaseModel, Field, RootModel
|
|
from sqlalchemy import func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.database import filament, models, spool
|
|
from spoolman.database.database import get_db_session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(
|
|
prefix="",
|
|
tags=["other"],
|
|
)
|
|
|
|
# ruff: noqa: D103, B008
|
|
|
|
|
|
@router.get(
|
|
"/material",
|
|
name="Find materials",
|
|
description="Get a list of all filament materials.",
|
|
response_model_exclude_none=True,
|
|
responses={
|
|
200: {
|
|
"description": "A list of all filament materials.",
|
|
"content": {
|
|
"application/json": {
|
|
"example": [
|
|
"PLA",
|
|
"ABS",
|
|
"PETG",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def find_materials(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[str]:
|
|
return await filament.find_materials(db=db)
|
|
|
|
|
|
@router.get(
|
|
"/color",
|
|
name="Find colors",
|
|
description="Get a list of all filament colors (hex codes).",
|
|
response_model_exclude_none=True,
|
|
responses={
|
|
200: {
|
|
"description": "A list of all filament color hex codes.",
|
|
"content": {
|
|
"application/json": {
|
|
"example": [
|
|
"FF0000",
|
|
"00FF00",
|
|
"0000FF",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def find_colors(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[str]:
|
|
return await filament.find_colors(db=db)
|
|
|
|
|
|
@router.get(
|
|
"/article-number",
|
|
name="Find article numbers",
|
|
description="Get a list of all article numbers.",
|
|
response_model_exclude_none=True,
|
|
responses={
|
|
200: {
|
|
"description": "A list of all article numbers.",
|
|
"content": {
|
|
"application/json": {
|
|
"example": [
|
|
"123456",
|
|
"987654",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def find_article_numbers(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[str]:
|
|
return await filament.find_article_numbers(db=db)
|
|
|
|
|
|
@router.get(
|
|
"/lot-number",
|
|
name="Find lot numbers",
|
|
description="Get a list of all lot numbers.",
|
|
response_model_exclude_none=True,
|
|
responses={
|
|
200: {
|
|
"description": "A list of all lot numbers.",
|
|
"content": {
|
|
"application/json": {
|
|
"example": [
|
|
"123456",
|
|
"987654",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def find_lot_numbers(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[str]:
|
|
return await spool.find_lot_numbers(db=db)
|
|
|
|
|
|
@router.get(
|
|
"/location",
|
|
name="Find locations",
|
|
description="Get a list of all spool locations.",
|
|
response_model_exclude_none=True,
|
|
responses={
|
|
200: {
|
|
"description": "A list of all spool locations.",
|
|
"content": {
|
|
"application/json": {
|
|
"example": [
|
|
"Printer 1",
|
|
"Printer 2",
|
|
"Storage Shelf A",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
async def find_locations(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> list[str]:
|
|
return await spool.find_locations(db=db)
|
|
|
|
|
|
class RenameLocationBody(BaseModel):
|
|
name: str = Field(description="The new name of the location.", min_length=1)
|
|
|
|
|
|
@router.patch(
|
|
"/location/{location}",
|
|
name="Rename location",
|
|
description="Rename a spool location. All spools in this location will be moved to the new location.",
|
|
response_model_exclude_none=True,
|
|
response_model=RootModel[str],
|
|
)
|
|
async def rename_location(
|
|
location: str,
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
body: RenameLocationBody,
|
|
) -> str:
|
|
logger.info("Renaming location %s to %s", location, body.name)
|
|
await spool.rename_location(db=db, current_name=location, new_name=body.name)
|
|
return body.name
|
|
|
|
|
|
class UsageDataPoint(BaseModel):
|
|
"""A single data point for usage analytics."""
|
|
|
|
date: str = Field(description="Date in YYYY-MM-DD format.")
|
|
weight_used: float = Field(description="Total weight used in grams (positive value).")
|
|
|
|
|
|
@router.get(
|
|
"/analytics/usage",
|
|
name="Get usage analytics",
|
|
description="Get filament usage data aggregated by day for the specified period.",
|
|
response_model=list[UsageDataPoint],
|
|
)
|
|
async def get_usage_analytics(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
days: Annotated[
|
|
int,
|
|
Query(description="Number of days to look back.", ge=1, le=365),
|
|
] = 30,
|
|
) -> list[UsageDataPoint]:
|
|
"""Get usage analytics aggregated by day."""
|
|
# Calculate the start date
|
|
start_date = datetime.utcnow() - timedelta(days=days)
|
|
|
|
# Query adjustments grouped by date
|
|
# Only include negative values (filament used, not added)
|
|
stmt = (
|
|
sqlalchemy.select(
|
|
func.date(models.SpoolAdjustment.timestamp).label("date"),
|
|
func.sum(
|
|
sqlalchemy.case(
|
|
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
|
|
else_=0,
|
|
)
|
|
).label("weight_used"),
|
|
)
|
|
.where(models.SpoolAdjustment.timestamp >= start_date)
|
|
.where(models.SpoolAdjustment.adjustment_type == "weight")
|
|
.group_by(func.date(models.SpoolAdjustment.timestamp))
|
|
.order_by(func.date(models.SpoolAdjustment.timestamp))
|
|
)
|
|
|
|
result = await db.execute(stmt)
|
|
rows = result.all()
|
|
|
|
# Fill in missing dates with 0
|
|
data_by_date = {str(row.date): row.weight_used for row in rows}
|
|
all_dates = []
|
|
current_date = start_date.date()
|
|
end_date = datetime.utcnow().date()
|
|
|
|
while current_date <= end_date:
|
|
date_str = current_date.strftime("%Y-%m-%d")
|
|
all_dates.append(
|
|
UsageDataPoint(
|
|
date=date_str,
|
|
weight_used=data_by_date.get(date_str, 0),
|
|
)
|
|
)
|
|
current_date += timedelta(days=1)
|
|
|
|
return all_dates
|
|
|
|
|
|
class MaterialUsage(BaseModel):
|
|
"""Usage breakdown by material."""
|
|
|
|
material: str = Field(description="Material name.")
|
|
weight_used: float = Field(description="Total weight used in grams.")
|
|
|
|
|
|
@router.get(
|
|
"/analytics/by-material",
|
|
name="Get usage by material",
|
|
description="Get filament usage breakdown by material type.",
|
|
response_model=list[MaterialUsage],
|
|
)
|
|
async def get_usage_by_material(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
days: Annotated[
|
|
Optional[int],
|
|
Query(description="Number of days to look back. If not set, returns all-time usage.", ge=1, le=365),
|
|
] = None,
|
|
) -> list[MaterialUsage]:
|
|
"""Get usage breakdown by material."""
|
|
# Join adjustments with spools and filaments to get material
|
|
stmt = (
|
|
sqlalchemy.select(
|
|
func.coalesce(models.Filament.material, "Unknown").label("material"),
|
|
func.sum(
|
|
sqlalchemy.case(
|
|
(models.SpoolAdjustment.value < 0, -models.SpoolAdjustment.value),
|
|
else_=0,
|
|
)
|
|
).label("weight_used"),
|
|
)
|
|
.join(models.Spool, models.SpoolAdjustment.spool_id == models.Spool.id)
|
|
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
|
.where(models.SpoolAdjustment.adjustment_type == "weight")
|
|
)
|
|
|
|
if days is not None:
|
|
start_date = datetime.utcnow() - timedelta(days=days)
|
|
stmt = stmt.where(models.SpoolAdjustment.timestamp >= start_date)
|
|
|
|
stmt = stmt.group_by(models.Filament.material).order_by(func.sum(-models.SpoolAdjustment.value).desc())
|
|
|
|
result = await db.execute(stmt)
|
|
rows = result.all()
|
|
|
|
return [MaterialUsage(material=row.material or "Unknown", weight_used=row.weight_used) for row in rows]
|
|
|
|
|
|
class CostByMaterial(BaseModel):
|
|
"""Cost breakdown by material."""
|
|
|
|
material: str = Field(description="Material name.")
|
|
total_cost: float = Field(description="Total cost spent on this material.")
|
|
total_weight: float = Field(description="Total initial weight purchased in grams.")
|
|
|
|
|
|
class CostStats(BaseModel):
|
|
"""Overall cost statistics."""
|
|
|
|
total_spent: float = Field(description="Sum of all spool purchase prices.")
|
|
total_weight_purchased: float = Field(description="Total initial weight of all spools in grams.")
|
|
avg_cost_per_kg: Optional[float] = Field(description="Average cost per kilogram of filament.")
|
|
cost_by_material: list[CostByMaterial] = Field(description="Cost breakdown by material type.")
|
|
|
|
|
|
@router.get(
|
|
"/analytics/cost",
|
|
name="Get cost statistics",
|
|
description="Get overall cost tracking statistics across all spools.",
|
|
response_model=CostStats,
|
|
)
|
|
async def get_cost_stats(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
) -> CostStats:
|
|
"""Get cost statistics including total spent and cost per kg."""
|
|
# Use COALESCE(spool.price, filament.price) for effective price
|
|
effective_price = func.coalesce(models.Spool.price, models.Filament.price)
|
|
effective_weight = func.coalesce(models.Spool.initial_weight, models.Filament.weight)
|
|
|
|
# Total spent and total weight across all spools (including archived)
|
|
totals_stmt = (
|
|
sqlalchemy.select(
|
|
func.sum(effective_price).label("total_spent"),
|
|
func.sum(effective_weight).label("total_weight"),
|
|
)
|
|
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
|
.where(effective_price.isnot(None))
|
|
)
|
|
totals_result = await db.execute(totals_stmt)
|
|
totals_row = totals_result.one()
|
|
|
|
total_spent = totals_row.total_spent or 0.0
|
|
total_weight_purchased = totals_row.total_weight or 0.0
|
|
avg_cost_per_kg = (total_spent / total_weight_purchased * 1000) if total_weight_purchased > 0 else None
|
|
|
|
# Cost by material
|
|
material_stmt = (
|
|
sqlalchemy.select(
|
|
func.coalesce(models.Filament.material, "Unknown").label("material"),
|
|
func.sum(effective_price).label("total_cost"),
|
|
func.sum(effective_weight).label("total_weight"),
|
|
)
|
|
.join(models.Filament, models.Spool.filament_id == models.Filament.id)
|
|
.where(effective_price.isnot(None))
|
|
.group_by(models.Filament.material)
|
|
.order_by(func.sum(effective_price).desc())
|
|
)
|
|
material_result = await db.execute(material_stmt)
|
|
material_rows = material_result.all()
|
|
|
|
cost_by_material = [
|
|
CostByMaterial(
|
|
material=row.material or "Unknown",
|
|
total_cost=row.total_cost or 0.0,
|
|
total_weight=row.total_weight or 0.0,
|
|
)
|
|
for row in material_rows
|
|
]
|
|
|
|
return CostStats(
|
|
total_spent=total_spent,
|
|
total_weight_purchased=total_weight_purchased,
|
|
avg_cost_per_kg=avg_cost_per_kg,
|
|
cost_by_material=cost_by_material,
|
|
)
|
|
|
|
|
|
# --- filamentcolors.xyz integration ---
|
|
|
|
FILAMENTCOLORS_API = "https://filamentcolors.xyz/api"
|
|
|
|
|
|
class FilamentColorSwatch(BaseModel):
|
|
"""A color swatch from filamentcolors.xyz."""
|
|
|
|
id: int
|
|
color_name: str
|
|
hex_color: Optional[str] = None
|
|
manufacturer_name: str
|
|
filament_type: str
|
|
image_front: Optional[str] = None
|
|
|
|
|
|
class FilamentColorManufacturer(BaseModel):
|
|
"""A manufacturer from filamentcolors.xyz."""
|
|
|
|
id: int
|
|
name: str
|
|
|
|
|
|
class FilamentColorSearchResult(BaseModel):
|
|
"""Search results from filamentcolors.xyz."""
|
|
|
|
count: int
|
|
swatches: list[FilamentColorSwatch]
|
|
|
|
|
|
@router.get(
|
|
"/filamentcolors/manufacturers",
|
|
name="Get filamentcolors.xyz manufacturers",
|
|
description="List all manufacturers from filamentcolors.xyz.",
|
|
response_model=list[FilamentColorManufacturer],
|
|
)
|
|
async def get_filamentcolors_manufacturers() -> list[FilamentColorManufacturer]:
|
|
"""Proxy manufacturer list from filamentcolors.xyz."""
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(f"{FILAMENTCOLORS_API}/manufacturer/", params={"format": "json", "limit": 500})
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
return [
|
|
FilamentColorManufacturer(id=m["id"], name=m["name"])
|
|
for m in data.get("results", [])
|
|
]
|
|
|
|
|
|
@router.get(
|
|
"/filamentcolors/search",
|
|
name="Search filamentcolors.xyz swatches",
|
|
description="Search color swatches from filamentcolors.xyz by manufacturer and/or color name.",
|
|
response_model=FilamentColorSearchResult,
|
|
)
|
|
async def search_filamentcolors(
|
|
*,
|
|
manufacturer: Annotated[
|
|
Optional[str],
|
|
Query(description="Manufacturer name to filter by."),
|
|
] = None,
|
|
color: Annotated[
|
|
Optional[str],
|
|
Query(description="Color name to search for."),
|
|
] = None,
|
|
filament_type: Annotated[
|
|
Optional[str],
|
|
Query(description="Filament type to filter by (e.g., PLA, PETG)."),
|
|
] = None,
|
|
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
|
page: Annotated[int, Query(ge=1)] = 1,
|
|
) -> FilamentColorSearchResult:
|
|
"""Proxy swatch search from filamentcolors.xyz."""
|
|
params: dict = {"format": "json", "limit": limit, "page": page}
|
|
if manufacturer:
|
|
params["manufacturer__name"] = manufacturer
|
|
if color:
|
|
params["color_name__icontains"] = color
|
|
if filament_type:
|
|
params["filament_type__name__icontains"] = filament_type
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(f"{FILAMENTCOLORS_API}/swatch/", params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
swatches = [
|
|
FilamentColorSwatch(
|
|
id=s["id"],
|
|
color_name=s.get("color_name", ""),
|
|
hex_color=s.get("hex_color"),
|
|
manufacturer_name=s.get("manufacturer", {}).get("name", ""),
|
|
filament_type=s.get("filament_type", {}).get("name", ""),
|
|
image_front=s.get("image_front"),
|
|
)
|
|
for s in data.get("results", [])
|
|
]
|
|
|
|
return FilamentColorSearchResult(count=data.get("count", 0), swatches=swatches)
|