mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-23 02:22:20 -05:00
Applies a permission level to all 49 routes and turns on the coverage check,
which from here refuses to import the API if a route is added without one.
Levels follow the design doc, with two deliberate departures:
- GET /export/{spools,filaments,vendors} require manage, not read. The doc's
rule is "read covers every GET", but these three return the entire
database in one request, and the doc's own opening section names exactly
that as a reason this project exists. Left at read they would also be
reachable anonymously once auth_anonymous_read is on.
- GET /setting/ stays at read. The client reads currency, price rounding and
base URL at boot, so raising it would break read-only browsing for the
users the level exists for. Keeping secrets out of the settings table is
handled where it belongs, at the storage rule in settings.py.
PUT /spool/{id}/use sits at edit rather than manage on purpose: it is
Moonraker's and OctoPrint's hot path, so a printer can hold a credential
that records filament usage but cannot delete anything.
Websocket routes take the ws_authenticated decorator instead of a
dependency, and their own accept() calls are removed since the decorator now
owns the handshake. One side effect worth noting: the pre-existing
close(4040) for an unknown setting key in setting.py now runs after the
accept, so that code finally reaches clients instead of being flattened into
an HTTP 403. That is an accidental fix, not the goal.
Also fixes a leak found while testing this: resolve_principal drove
get_db_session with "async for" and then returned out of the loop, leaving
the generator suspended at its yield so the session was closed later by the
garbage collector, off the event loop, where SQLAlchemy cannot await. It
produced a wall of greenlet errors on every authenticated request. Sessions
are now closed deterministically through a context manager.
Verified:
- The full existing integration suite passes against a running server with
auth off: 372 tests, no test file edited. That is the proof that this is
additive and that the disabled path is unchanged.
- Removing a single gate makes the API refuse to import and names the route.
- On a live server with auth on, /health and the SPA stay reachable, every
API route answers 401 with the {"message": ...} shape the rest of the API
uses, and secret.key is created at mode 0600.
- 48 gated routes: 29 read, 6 edit, 13 manage, plus /health public.
94 lines
2.6 KiB
Python
94 lines
2.6 KiB
Python
"""Functions for exporting data."""
|
|
|
|
import io
|
|
from collections.abc import Iterable
|
|
from enum import Enum
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Query, Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from spoolman.auth.dependencies import require_level
|
|
from spoolman.auth.levels import Level
|
|
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
|
|
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.",
|
|
dependencies=[Depends(require_level(Level.MANAGE))],
|
|
)
|
|
async def export_spools(
|
|
*,
|
|
db: Annotated[AsyncSession, Depends(get_db_session)],
|
|
fmt: ExportFormat,
|
|
allow_archived: Annotated[
|
|
bool,
|
|
Query(description="Whether to include archived spools in the export."),
|
|
] = False,
|
|
) -> Response:
|
|
all_spools, _ = await spool.find(db=db, allow_archived=allow_archived)
|
|
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.",
|
|
dependencies=[Depends(require_level(Level.MANAGE))],
|
|
)
|
|
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.",
|
|
dependencies=[Depends(require_level(Level.MANAGE))],
|
|
)
|
|
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)
|