Merge branch 'Donkie:master' into master

This commit is contained in:
Forest-Troll
2024-12-12 22:33:19 +03:00
committed by GitHub
53 changed files with 4583 additions and 1903 deletions

85
spoolman/api/v1/export.py Normal file
View File

@@ -0,0 +1,85 @@
"""Functions for exporting data."""
import io
from collections.abc import Iterable
from enum import Enum
from typing import Annotated
from fastapi import APIRouter, Depends, Response
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool, vendor
from spoolman.database.database import get_db_session
from spoolman.database.models import Base
from spoolman.export import dump_as_csv, dump_as_json
# ruff: noqa: D103,B008
router = APIRouter(
prefix="/export",
tags=["export"],
)
class ExportFormat(Enum):
CSV = "csv"
JSON = "json"
@router.get(
"/spools",
name="Export spools",
description="Export the list of spools in various formats. Filament and vendor data is included.",
)
async def export_spools(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
fmt: ExportFormat,
) -> Response:
all_spools, _ = await spool.find(db=db)
return await _export(all_spools, fmt)
@router.get(
"/filaments",
name="Export filaments",
description="Export the list of filaments in various formats. Vendor data is included.",
)
async def export_filaments(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
fmt: ExportFormat,
) -> Response:
all_filaments, _ = await filament.find(db=db)
return await _export(all_filaments, fmt)
@router.get(
"/vendors",
name="Export vendors",
description="Export the list of vendors in various formats.",
)
async def export_vendors(
*,
db: Annotated[AsyncSession, Depends(get_db_session)],
fmt: ExportFormat,
) -> Response:
all_vendors, _ = await vendor.find(db=db)
return await _export(all_vendors, fmt)
async def _export(objects: Iterable[Base], fmt: ExportFormat) -> Response:
"""Export the objects in various formats."""
buffer = io.StringIO()
media_type = ""
if fmt == ExportFormat.CSV:
media_type = "text/csv"
await dump_as_csv(objects, buffer)
elif fmt == ExportFormat.JSON:
media_type = "application/json"
await dump_as_json(objects, buffer)
else:
raise ValueError(f"Unknown export format: {fmt}")
return Response(content=buffer.getvalue(), media_type=media_type)

View File

@@ -60,7 +60,7 @@ class Vendor(BaseModel):
)
empty_spool_weight: Optional[float] = Field(
None,
gt=0,
ge=0,
description="The empty spool weight, in grams.",
examples=[140],
)

View File

@@ -4,6 +4,7 @@ import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, RootModel
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.database import filament, spool
@@ -123,3 +124,25 @@ 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

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 externaldb, field, filament, models, other, setting, spool, vendor
from . import export, externaldb, field, filament, models, other, setting, spool, vendor
logger = logging.getLogger(__name__)
@@ -111,3 +111,4 @@ app.include_router(setting.router)
app.include_router(field.router)
app.include_router(other.router)
app.include_router(externaldb.router)
app.include_router(export.router)

View File

@@ -144,7 +144,10 @@ async def find(
elif order == SortOrder.DESC:
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt)
rows = await db.execute(
stmt,
execution_options={"populate_existing": True},
)
result = list(rows.unique().scalars().all())
if total_count is None:
total_count = len(result)

View File

@@ -198,7 +198,10 @@ async def find( # noqa: C901, PLR0912
elif order == SortOrder.DESC:
stmt = stmt.order_by(*(f.desc() for f in sorts))
rows = await db.execute(stmt)
rows = await db.execute(
stmt,
execution_options={"populate_existing": True},
)
result = list(rows.unique().scalars().all())
if total_count is None:
total_count = len(result)
@@ -459,3 +462,15 @@ async def reset_initial_weight(db: AsyncSession, spool_id: int, weight: float) -
await db.commit()
await spool_changed(spool, EventType.UPDATED)
return spool
async def rename_location(
*,
db: AsyncSession,
current_name: str,
new_name: str,
) -> None:
"""Rename all spools with the current location name to the new name."""
await db.execute(
sqlalchemy.update(models.Spool).where(models.Spool.location == current_name).values(location=new_name),
)

View File

@@ -83,7 +83,10 @@ async def find(
elif order == SortOrder.DESC:
stmt = stmt.order_by(field.desc())
rows = await db.execute(stmt)
rows = await db.execute(
stmt,
execution_options={"populate_existing": True},
)
result = list(rows.unique().scalars().all())
if total_count is None:
total_count = len(result)

66
spoolman/export.py Normal file
View File

@@ -0,0 +1,66 @@
"""Functionality for exporting data in various format."""
import asyncio
import csv
import json
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
from spoolman.database import models
if TYPE_CHECKING:
from _typeshed import SupportsWrite
banned_attrs = {"awaitable_attrs", "metadata", "registry", "spools", "filaments"}
async def flatten_sqlalchemy_object(obj: models.Base, parent_key: str = "", sep: str = ".") -> dict[str, Any]:
"""Recursively flattens a SQLAlchemy object into a dictionary with dot-separated keys."""
fields = {}
for attr in dir(obj):
# Check if the attribute is a column or a relationship
if not attr.startswith("_") and attr not in banned_attrs:
value = await getattr(obj.awaitable_attrs, attr)
if attr == "extra":
# Handle extra fields
for v in value:
fields[f"{parent_key}extra.{v.key}"] = v.value
continue
# Handle nested SQLAlchemy objects
if isinstance(value, models.Base):
nested_fields = await flatten_sqlalchemy_object(value, f"{parent_key}{attr}{sep}", sep=sep)
fields.update(nested_fields)
else:
# Use only columns and simple data types
fields[f"{parent_key}{attr}"] = value
return fields
async def dump_as_csv(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
"""Export a list of objects as CSV to a writer. Nested objects are flattened with dot-separated keys."""
# Flatten each object and get all column names
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
# Collect all unique headers across flattened objects
headers = set()
for flattened_obj in all_flattened:
headers.update(flattened_obj.keys())
headers = sorted(headers) # Sort headers for consistent column ordering
# Write to CSV
csv_writer = csv.DictWriter(writer, fieldnames=headers)
csv_writer.writeheader()
for flattened_obj in all_flattened:
csv_writer.writerow(flattened_obj)
async def dump_as_json(sqlalchemy_objects: Iterable[models.Base], writer: "SupportsWrite[str]") -> None:
"""Export a list of objects as JSON to a writer. Nested objects are flattened with dot-separated keys."""
# Flatten each object and get all column names
all_flattened = await asyncio.gather(*[flatten_sqlalchemy_object(obj) for obj in sqlalchemy_objects])
# Write to JSON
json.dump(all_flattened, writer, default=str)

View File

@@ -24,10 +24,23 @@ console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter("%(name)-26s %(levelname)-8s %(message)s"))
# Setup the spoolman logger, which all spoolman modules will use
log_level = env.get_logging_level()
root_logger = logging.getLogger()
root_logger.setLevel(env.get_logging_level())
root_logger.setLevel(log_level)
root_logger.addHandler(console_handler)
# Fix uvicorn logging
logging.getLogger("uvicorn").setLevel(log_level)
logging.getLogger("uvicorn").removeHandler(logging.getLogger("uvicorn").handlers[0])
logging.getLogger("uvicorn").addHandler(console_handler)
logging.getLogger("uvicorn.error").setLevel(log_level)
logging.getLogger("uvicorn.error").addHandler(console_handler)
logging.getLogger("uvicorn.access").setLevel(log_level)
logging.getLogger("uvicorn.access").removeHandler(logging.getLogger("uvicorn.access").handlers[0])
logging.getLogger("uvicorn.access").addHandler(console_handler)
# Get logger instance for this module
logger = logging.getLogger(__name__)

View File

@@ -68,3 +68,6 @@ register_setting("extra_fields_vendor", SettingType.ARRAY, json.dumps([]))
register_setting("extra_fields_filament", SettingType.ARRAY, json.dumps([]))
register_setting("extra_fields_spool", SettingType.ARRAY, json.dumps([]))
register_setting("base_url", SettingType.STRING, json.dumps(""))
register_setting("locations", SettingType.ARRAY, json.dumps([]))
register_setting("locations_spoolorders", SettingType.OBJECT, json.dumps({}))