mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
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.
244 lines
9.0 KiB
Python
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
|