Files
Donkie 6520ce6d1b Let NFC tags identify filaments as well as spools
A tag can now be linked to a filament type, not just to one spool of it. The
tag table already carried filament_id and target_type for exactly this, so
there is no migration.

API, all additive:

  POST   /filament/{id}/tag        link; 409 if anything else holds the UID
  DELETE /filament/{id}/tag/{uid}  unlink
  GET    /filament?tag=<uid>       exact lookup on the normalized UID
  Filament.tags                    also inside every spool's embedded filament
  TagScan.matched_filament_id      always present, like matched_spool_id
  TagConflictMessage.filament_id   names a filament holder on a 409

One UID still identifies one thing across both kinds: a spool's tag cannot be
linked to a filament or the reverse, and neither ?tag= lookup answers with the
other kind. The scan debounce key now includes the kind, since spool 7 and
filament 7 are different answers.

SpoolTag and SpoolTagParameters are renamed Tag and TagLinkParameters while
they are unreleased, so the schema does not describe filament tags as spool
tags forever.

client_v2: the filament inspector gets the Tags section, the link dialog names
and moves a tag held by either kind, and scanning a filament's tag opens that
filament when auto-navigate is on.

Tag having two delete-orphan parents was checked on SQLAlchemy 2.0.51: new tags
flush, unlinking deletes the row, and deleting either parent cascades.

Proven on sqlite, postgres, mariadb and cockroachdb: 558 integration tests
each. Unit tests, svelte-check and the Playwright tag specs pass.
2026-09-13 10:10:13 +02:00

244 lines
9.0 KiB
Python

"""Helper functions for interacting with tag database objects.
Kept out of `spool.py`, which is already the largest module in the tree, but following
its conventions exactly: `AsyncSession` first, exceptions from `spoolman.exceptions`,
and the websocket event emitted after the commit.
This module is the ONE place a UID gets normalized, on the way in and on every lookup.
Doing it in a Pydantic validator would read better but would only cover callers that
arrive over HTTP -- and the unique constraint is worthless if any other path (an
importer, a migration backfill, a future tag codec) can write a differently-shaped UID.
It is also the one place that decides what a tag points at. A tag identifies a spool or a
filament today, and `models.Tag` can address more than that -- including things that are
not rows at all, such as a location -- so `target_type` is set from the thing being linked
and every read here says which kind it found rather than assuming. The database has no
CHECK enforcing "exactly one target": no migration in this tree uses one and MySQL below
8.0.16 silently ignores them, so a single enforced write path is worth more than a
constraint that is real on three databases out of four.
"""
import logging
from datetime import datetime
import sqlalchemy
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import EventType
from spoolman.database import filament, models, spool
from spoolman.exceptions import ItemNotFoundError, TagConflictError
from spoolman.tags import TARGET_FILAMENT, TARGET_SPOOL, normalize_format, normalize_uid
logger = logging.getLogger(__name__)
# The row kinds a tag can be linked to. Both carry a `tags` collection and an `id`, which is
# all the shared link and unlink paths below need from them.
Target = models.Spool | models.Filament
async def _get_tag_by_uid(db: AsyncSession, uid: str) -> models.Tag | None:
"""Get the tag with this exact normalized UID, if any. Hits the unique index."""
stmt = sqlalchemy.select(models.Tag).where(models.Tag.uid == uid)
return (await db.execute(stmt)).scalar_one_or_none()
def _target_type(target: Target) -> str:
return TARGET_SPOOL if isinstance(target, models.Spool) else TARGET_FILAMENT
def _holds(tag: models.Tag, target: Target) -> bool:
"""Whether `tag` is linked to exactly this target.
Compared on the foreign key for the target's own kind, so spool 7 and filament 7 are never
mistaken for each other: the other kind's column is null on the tag.
"""
held = tag.spool_id if isinstance(target, models.Spool) else tag.filament_id
return held == target.id
async def _changed(target: Target) -> None:
"""Emit the target's ordinary `updated` event; its tags are part of its payload."""
if isinstance(target, models.Spool):
await spool.spool_changed(target, EventType.UPDATED)
else:
await filament.filament_changed(target, EventType.UPDATED)
def _conflict(uid: str, existing: models.Tag) -> TagConflictError:
"""Describe a UID that is already spoken for.
The holder's id is included when there is one, which is what lets a client offer "move it
here". A kind addressed by value (a location) has no id, and the message says what holds
the tag instead.
"""
if existing.spool_id is not None:
return TagConflictError(
f"Tag {uid} is already linked to spool {existing.spool_id}.",
spool_id=existing.spool_id,
)
if existing.filament_id is not None:
return TagConflictError(
f"Tag {uid} is already linked to filament {existing.filament_id}.",
filament_id=existing.filament_id,
)
return TagConflictError(f"Tag {uid} is already linked to {existing.target_type} {existing.target_value}.")
async def _link(db: AsyncSession, target: Target, uid: str, tag_format: str | None) -> models.Tag:
"""Link an already-normalized UID to a loaded spool or filament. See `link_spool`."""
existing = await _get_tag_by_uid(db, uid)
if existing is not None:
if not _holds(existing, target):
raise _conflict(uid, existing)
if tag_format is not None and existing.format != tag_format:
existing.format = tag_format
await db.commit()
await _changed(target)
return existing
tag = models.Tag(
uid=uid,
target_type=_target_type(target),
format=tag_format,
added=datetime.utcnow().replace(microsecond=0),
)
target.tags.append(tag)
try:
await db.commit()
except IntegrityError:
# Two clients linked the same UID at the same time and the unique index caught
# the loser. The database, not the read above, is what makes "one tag, one thing"
# true; report the winner the same way a sequential conflict is reported.
await db.rollback()
winner = await _get_tag_by_uid(db, uid)
if winner is None:
raise
raise _conflict(uid, winner) from None
await _changed(target)
return tag
async def _unlink(db: AsyncSession, target: Target, uid: str) -> None:
"""Unlink an already-normalized UID from a loaded spool or filament. See `unlink_spool`."""
for tag in target.tags:
if tag.uid == uid:
# delete-orphan on the relationship turns this into the DELETE.
target.tags.remove(tag)
break
else:
raise ItemNotFoundError(f"{_target_type(target).capitalize()} {target.id} has no tag with UID {uid}.")
# Commit before notifying so the change is durable and visible to subsequent
# requests; post-commit notification must be the last, infallible step.
await db.commit()
await _changed(target)
async def link_spool(
*,
db: AsyncSession,
spool_id: int,
uid: str,
tag_format: str | None = None,
) -> models.Tag:
"""Link a physical tag to a spool.
Re-linking a UID to the spool that already holds it is idempotent; if the request
carries a format and the stored row has a different one, the stored one is refined,
since a later scan generally knows more about the tag than the first one did.
Args:
db: Database session.
spool_id: The spool to link the tag to.
uid: The tag UID in any shape; normalized here.
tag_format: Optional tag format name, e.g. "ntag".
Returns:
models.Tag: The linked tag.
Raises:
ItemNotFoundError: If no spool with that ID exists.
TagConflictError: If the UID is already linked to anything else, spool or filament.
ValueError: If the UID or format is not valid.
"""
uid = normalize_uid(uid)
tag_format = normalize_format(tag_format)
return await _link(db, await spool.get_by_id(db, spool_id), uid, tag_format)
async def link_filament(
*,
db: AsyncSession,
filament_id: int,
uid: str,
tag_format: str | None = None,
) -> models.Tag:
"""Link a physical tag to a filament. Same rules as `link_spool`, across both kinds.
Raises:
ItemNotFoundError: If no filament with that ID exists.
TagConflictError: If the UID is already linked to anything else, spool or filament.
ValueError: If the UID or format is not valid.
"""
uid = normalize_uid(uid)
tag_format = normalize_format(tag_format)
return await _link(db, await filament.get_by_id(db, filament_id), uid, tag_format)
async def unlink_spool(*, db: AsyncSession, spool_id: int, uid: str) -> None:
"""Unlink a tag from a spool.
Args:
db: Database session.
spool_id: The spool the tag is linked to.
uid: The tag UID in any shape; normalized here.
Raises:
ItemNotFoundError: If the spool does not exist, or does not hold that tag.
ValueError: If the UID is not valid.
"""
uid = normalize_uid(uid)
await _unlink(db, await spool.get_by_id(db, spool_id), uid)
async def unlink_filament(*, db: AsyncSession, filament_id: int, uid: str) -> None:
"""Unlink a tag from a filament. Same rules as `unlink_spool`.
Raises:
ItemNotFoundError: If the filament does not exist, or does not hold that tag.
ValueError: If the UID is not valid.
"""
uid = normalize_uid(uid)
await _unlink(db, await filament.get_by_id(db, filament_id), uid)
async def find_by_uid(db: AsyncSession, uid: str) -> Target | None:
"""Find the spool or filament a tag UID is linked to, or None if the tag is unknown.
Args:
db: Database session.
uid: The tag UID in any shape; normalized here.
Returns:
models.Spool | models.Filament | None: Whatever holds that tag.
Raises:
ValueError: If the UID is not valid.
"""
tag = await _get_tag_by_uid(db, normalize_uid(uid))
if tag is None:
return None
if tag.spool_id is not None:
return await spool.get_by_id(db, tag.spool_id)
if tag.filament_id is not None:
return await filament.get_by_id(db, tag.filament_id)
# A kind addressed by value (a location) resolves to no row, and nothing reads those yet.
return None