mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
Implements the backend half of design/nfc-phase-1-tag-identity.md. Format
agnostic: it keys only on a tag's hardware UID, so it works with blank NTAG
stickers, Prusa NFC-V tags, and the Bambu/Creality/Qidi MIFARE tags whose
contents Spoolman can never decrypt.
Answers the question the whole external ecosystem has been working around:
which spool is this physical tag? Every project so far invented its own way to
smuggle a Spoolman id into the tag contents, which fails the moment the tag is
a vendor tag you cannot write to.
Data model: new spool_tag table, additive only, with a unique index on uid.
Many tags per spool, since copying a vendor tag onto a blank sticker leaves one
spool with two UIDs. UIDs are normalized to uppercase hex with separators
stripped, in the database layer rather than a Pydantic validator, so the unique
constraint holds for callers that never arrive over HTTP.
API, all additive:
GET /spool?tag=<uid> exact match on the normalized UID
POST /spool/{id}/tag 201, 409 with the conflicting spool id
DELETE /spool/{id}/tag/{uid} 204
POST /tag/scan broadcast and return the match
GET /tag/reader readers seen recently
WS /tag/scan[/{reader_id}] the relay
Link and unlink emit the existing spool event, so clients need no new wiring.
Spool responses gain a tags array.
The relay decouples where the reader is from where the user is looking: an
agent POSTs a scan, a paired browser reacts. Scans are ephemeral, never
persisted, and go through a SECOND WebsocketManager instance rather than the
shared one. The shared tree propagates to subscribers at every level, and the
root /api/v1/ pool means "any change", so putting scans there would inject a
novel resource into every existing consumer's stream. An integration test pins
that they never reach the entity websockets.
Repeated identical (uid, reader_id) scans are debounced before the broadcast
only, since readers re-read a tag that is sitting still. The response always
carries the match, so a de-duplicated scan never looks to a device like a
failed lookup.
Migration creates the table and its indexes in one revision, following the
extra-fields tables rather than the doc's suggested split. The DDL/DML split
cockroachdb needs does not apply when there is no data migration, and the 4-DB
suite confirms it.
Verified: 506 integration tests on sqlite, postgres, mariadb and cockroachdb;
263 unit tests; ruff clean.
Frontend, docs, and the #783 action links are not included.
99 lines
4.1 KiB
Python
99 lines
4.1 KiB
Python
"""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"}
|
|
|
|
# A spreadsheet treats a cell starting with any of these as a formula, so a vendor named
|
|
# `=cmd|' /C calc'!A0` executes when the export is opened (CWE-1236). Prefixing with a single
|
|
# quote makes the spreadsheet read it as literal text; the quote is not part of the value.
|
|
FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
|
|
|
|
def escape_csv_value(value: Any) -> Any: # noqa: ANN401
|
|
"""Neutralize a value that a spreadsheet would otherwise interpret as a formula.
|
|
|
|
Only strings are escaped. Numbers reach the writer as numeric types, so a negative number
|
|
cannot be mistaken for a formula and must not grow a stray quote.
|
|
|
|
Args:
|
|
value: The cell value.
|
|
|
|
Returns:
|
|
Any: The value, prefixed with a single quote if it would be read as a formula.
|
|
|
|
"""
|
|
if isinstance(value, str) and value.startswith(FORMULA_PREFIXES):
|
|
return "'" + value
|
|
return value
|
|
|
|
|
|
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
|
|
|
|
if attr == "tags":
|
|
# A collection, like `extra`, so it needs collapsing into a cell rather
|
|
# than being left as a list of ORM objects: CSV would write their repr and
|
|
# JSON would stringify them. A spool's tags have no keys to spread across
|
|
# columns, so they become one comma-separated list of UIDs.
|
|
fields[f"{parent_key}tags"] = ",".join(v.uid for v in 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. Header names need no escaping: they are either fixed attribute names or
|
|
# extra-field keys, which are constrained to ^[a-z0-9_]+$.
|
|
csv_writer = csv.DictWriter(writer, fieldnames=headers)
|
|
csv_writer.writeheader()
|
|
for flattened_obj in all_flattened:
|
|
csv_writer.writerow({key: escape_csv_value(value) for key, value in flattened_obj.items()})
|
|
|
|
|
|
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)
|