mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-20 09:09:47 -05:00
Generalise the tag table beyond spools before it ships
A tag maps a UID to "the thing to bring up", and only some of those things are
spools -- or rows at all. Locations are the case that settles the shape: a
location in Spoolman is a String(64) on Spool, not a table, so a tag meaning
"show me Shelf A" can never be a foreign key.
Getting this wrong is asymmetrically expensive. Renaming the table, dropping
spool_id's NOT NULL and adding a discriminator all mean altering a populated
table on four databases; adding an endpoint or a target kind later is additive
and free. The migration has shipped to nobody -- it is not on master and
v0.26.1 predates it -- so the schema is settled now and the unshipped revision
is rewritten in place rather than chased with a second one.
spool_tag becomes tag:
uid unchanged, still globally unique -- one physical tag, one thing
target_type which kind of target is in force, rather than leaving readers
to infer it from whichever column is non-null
spool_id nullable now; targets that ARE rows keep real foreign keys, so
filament_id the database still cascades deletes and refuses orphans
target_value targets that are not rows; a new kind of this sort costs no
migration at all, only a new target_type string
Exactly-one-target is enforced in database/tag.py rather than by a CHECK: no
migration in this tree uses one, and MySQL below 8.0.16 parses and silently
ignores them, so a constraint here would be real on three databases out of
four. Single write path beats a constraint that is only sometimes a constraint
-- the same argument, in the same module, as normalize_uid.
filament_id gets a column and a foreign key but no ORM relationship: nothing
writes filament tags, and a selectin collection on Filament would add a query
to every filament listing for rows that cannot exist. ON DELETE CASCADE holds
integrity until the feature that needs the relationship arrives.
No API surface changes. POST /spool/{id}/tag, ?tag=, the spool response's tags
and matched_spool_id are all exactly as they were; the reads are just explicit
now about wanting a spool rather than assuming the tag they found is one.
TagConflictError's spool_id becomes optional for the same reason, which the
client already handled -- it only offers "move it here" when there is an id.
Proven on sqlite, postgres, mariadb and cockroachdb: 526 integration tests each.
This commit is contained in:
1 parent
ff2e3ec31a
commit
d43552bd26
11 files changed
+242
-100
No files matched your search
@@ -66,13 +66,18 @@ two physical tags with two different UIDs.
|
||||
In `spoolman/database/models.py`, alongside `SpoolField` (`models.py:125`):
|
||||
|
||||
```python
|
||||
class SpoolTag(Base):
|
||||
__tablename__ = "spool_tag"
|
||||
class Tag(Base):
|
||||
__tablename__ = "tag"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), index=True)
|
||||
spool: Mapped["Spool"] = relationship(back_populates="tags")
|
||||
uid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(16))
|
||||
spool_id: Mapped[int | None] = mapped_column(ForeignKey("spool.id"), index=True)
|
||||
spool: Mapped["Spool | None"] = relationship(back_populates="tags")
|
||||
filament_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("filament.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
target_value: Mapped[str | None] = mapped_column(String(64))
|
||||
format: Mapped[str | None] = mapped_column(String(32))
|
||||
added: Mapped[datetime] = mapped_column()
|
||||
```
|
||||
@@ -84,12 +89,42 @@ class SpoolTag(Base):
|
||||
- `format` — nullable free-ish string (`openprinttag`, `opentag3d`, `ntag`, `bambu`, `tigertag`,
|
||||
`qidi`). Informational in Phase 1; Phase 2 uses it. Not an enum in the DB — new tag types appear
|
||||
faster than migrations should.
|
||||
- Unique index on `uid` is the point of the whole table: one tag, one spool, enforced.
|
||||
- Unique index on `uid` is the point of the whole table: one physical tag identifies exactly one
|
||||
thing, enforced.
|
||||
|
||||
### Why the table is wider than "spool tags"
|
||||
|
||||
Only spools are tagged in Phase 1, and only `POST /spool/{id}/tag` exists. The *table* is general
|
||||
because the expensive half of this decision is the one that cannot be revisited cheaply: renaming a
|
||||
table, dropping a NOT NULL and adding a discriminator all mean altering a populated table on four
|
||||
databases, whereas adding an endpoint later is additive and free. The shape was settled before
|
||||
release, while the migration had shipped to nobody.
|
||||
|
||||
Two kinds of target, and the difference is the whole reason for the shape:
|
||||
|
||||
- **Targets that are rows** — a spool, later a filament — get real foreign keys, so the database
|
||||
keeps referential integrity and cascades deletes. A tag row orphaned by a deleted spool is exactly
|
||||
the class of silent rot this project cannot afford.
|
||||
- **Targets that are not rows** — a *location* is a `String(64)` on `Spool`, not a table, so a tag
|
||||
meaning "show me Shelf A" can only ever carry the value. `target_value` holds it, and a new kind of
|
||||
this sort (a saved search, say) costs **no migration at all**: only a new `target_type` string.
|
||||
|
||||
`target_type` names which is in force rather than leaving readers to infer it from whichever column
|
||||
is non-null — a kind that populates neither would otherwise be unreadable.
|
||||
|
||||
**No CHECK constraint** enforces "exactly one target". No migration in this tree uses one, and MySQL
|
||||
below 8.0.16 parses and silently ignores them, so a CHECK here would be real on three databases out
|
||||
of four. `spoolman/database/tag.py` is the single write path instead — the same argument, and the
|
||||
same module, as `normalize_uid`.
|
||||
|
||||
**`filament_id` has no ORM relationship yet.** Nothing writes filament tags, and a `selectin`
|
||||
collection on `Filament` would add a query to every filament listing for rows that cannot exist.
|
||||
`ON DELETE CASCADE` holds integrity meanwhile; the relationship arrives with the feature.
|
||||
|
||||
On `Spool`, add the relationship following the `extra` convention at `models.py:94`:
|
||||
|
||||
```python
|
||||
tags: Mapped[list["SpoolTag"]] = relationship(
|
||||
tags: Mapped[list["Tag"]] = relationship(
|
||||
back_populates="spool",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
lazy="selectin",
|
||||
@@ -519,7 +554,7 @@ data model, endpoints and protocol above are the spec; this is the file layout a
|
||||
| `spoolman/database/tag.py` | `link()`, `unlink()`, `find_spool_by_uid()` | Follows `database/spool.py` conventions: `AsyncSession` first, raising `ItemNotFoundError` / `ItemCreateError` from `exceptions.py`. Kept out of `spool.py`, which is already ~800 lines and the largest module in the tree. |
|
||||
| `spoolman/scanrelay.py` | Debounce cache and reader registry, as a class | Relay state is not database state and not HTTP state. Isolating it means the debounce window and registry eviction are testable without a socket or a session. |
|
||||
| `spoolman/api/v1/tag.py` | `/tag/scan` POST, `/tag/reader` GET, the two scan websockets | Mirrors the one-router-per-resource layout of `spool.py` / `vendor.py` / `field.py`. Thin: it validates, delegates, and broadcasts. |
|
||||
| `migrations/versions/<rev>_spool_tags.py` | CREATE TABLE + indexes | — |
|
||||
| `migrations/versions/<rev>_tags.py` | CREATE TABLE + indexes | — |
|
||||
|
||||
`database/tag.py` imports `spool.spool_changed` to emit the update event. One direction only, so no
|
||||
import cycle; the reverse (spool.py knowing about tags) is not needed because the relationship is
|
||||
@@ -529,7 +564,7 @@ declared on the ORM model.
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `spoolman/database/models.py` | `SpoolTag` ORM class next to `SpoolField` (`models.py:125`); `Spool.tags` relationship with `lazy="selectin"`. |
|
||||
| `spoolman/database/models.py` | `Tag` ORM class next to `SpoolField` (`models.py:125`); `Spool.tags` relationship with `lazy="selectin"`. |
|
||||
| `spoolman/api/v1/models.py` | `SpoolTag` response model; `tags` field on `Spool` (`models.py:285`); `SCANNED` on `EventType` (`models.py:604`); `TagScanEvent(Event)` with `resource: Literal["tag_scan"]`. |
|
||||
| `spoolman/database/spool.py` | `find()` gains a `tag: str \| None` kwarg, applied inside `_apply_spool_filters` (`spool.py:259`). |
|
||||
| `spoolman/api/v1/spool.py` | `tag` query param wired into the `find` handler; `POST /spool/{id}/tag` and `DELETE /spool/{id}/tag/{uid}` on the existing router. |
|
||||
@@ -546,7 +581,7 @@ differently-shaped UID.
|
||||
|
||||
**`?tag=` is a join, not a subquery.** Added in `_apply_spool_filters` so the list query and the
|
||||
count query stay in sync automatically — they share that builder. Because `uid` is unique, at most
|
||||
one `spool_tag` row can match, so a plain join cannot multiply result rows and the existing
|
||||
one `tag` row can match, so a plain join cannot multiply result rows and the existing
|
||||
`contains_eager` chain for filament/vendor is unaffected.
|
||||
|
||||
**Link and unlink emit `SpoolEvent`, not a new event type.** They mutate a spool as far as any client
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""spool tags.
|
||||
|
||||
Revision ID: fe4970567bb3
|
||||
Revises: 9c1d5f2a7b31
|
||||
Create Date: 2026-08-13 10:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "fe4970567bb3"
|
||||
down_revision = "9c1d5f2a7b31"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the spool_tag table linking physical NFC/RFID tags to spools."""
|
||||
# Purely additive: one new table, no existing column touched, so lossless by
|
||||
# construction. CREATE TABLE and its indexes ride the same revision, which is the
|
||||
# pattern the extra-fields tables already established (b8881bdb716c) and which the
|
||||
# 4-DB suite covers; the DDL/DML split cockroachdb needs (see 304a32906234) does not
|
||||
# apply here because there is no data migration.
|
||||
op.create_table(
|
||||
"spool_tag",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("spool_id", sa.Integer(), nullable=False),
|
||||
sa.Column("uid", sa.String(length=64), nullable=False),
|
||||
sa.Column("format", sa.String(length=32), nullable=True),
|
||||
sa.Column("added", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["spool_id"],
|
||||
["spool.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_spool_tag_id"), "spool_tag", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_spool_tag_spool_id"), "spool_tag", ["spool_id"], unique=False)
|
||||
# The whole point of the table: one tag, one spool, enforced by the database rather
|
||||
# than by whichever client happened to write last.
|
||||
op.create_index(op.f("ix_spool_tag_uid"), "spool_tag", ["uid"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Perform the downgrade."""
|
||||
op.drop_index(op.f("ix_spool_tag_uid"), table_name="spool_tag")
|
||||
op.drop_index(op.f("ix_spool_tag_spool_id"), table_name="spool_tag")
|
||||
op.drop_index(op.f("ix_spool_tag_id"), table_name="spool_tag")
|
||||
op.drop_table("spool_tag")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""tags.
|
||||
|
||||
Revision ID: fe4970567bb3
|
||||
Revises: 9c1d5f2a7b31
|
||||
Create Date: 2026-08-13 10:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "fe4970567bb3"
|
||||
down_revision = "9c1d5f2a7b31"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the tag table mapping physical NFC/RFID tag UIDs to what they identify."""
|
||||
# Purely additive: one new table, no existing column touched, so lossless by
|
||||
# construction. CREATE TABLE and its indexes ride the same revision, which is the
|
||||
# pattern the extra-fields tables already established (b8881bdb716c) and which the
|
||||
# 4-DB suite covers; the DDL/DML split cockroachdb needs (see 304a32906234) does not
|
||||
# apply here because there is no data migration.
|
||||
#
|
||||
# Only spools are tagged today, and the table is deliberately wider than that. A tag
|
||||
# is a mapping from a UID to "the thing to bring up", and not every such thing is a
|
||||
# row: a location in Spoolman is a string on a spool rather than a table, so a tag
|
||||
# meaning "show me Shelf A" can only ever carry the value. Getting that wrong here
|
||||
# would mean altering a populated table's nullability on four databases later, so
|
||||
# the shape is settled now, while nothing has shipped. See models.Tag for the full
|
||||
# reasoning.
|
||||
op.create_table(
|
||||
"tag",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("uid", sa.String(length=64), nullable=False),
|
||||
sa.Column("target_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("spool_id", sa.Integer(), nullable=True),
|
||||
sa.Column("filament_id", sa.Integer(), nullable=True),
|
||||
sa.Column("target_value", sa.String(length=64), nullable=True),
|
||||
sa.Column("format", sa.String(length=32), nullable=True),
|
||||
sa.Column("added", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["spool_id"],
|
||||
["spool.id"],
|
||||
),
|
||||
# Filament tags have no ORM relationship yet (nothing writes them), so the
|
||||
# database is what stops a deleted filament leaving tag rows behind.
|
||||
sa.ForeignKeyConstraint(
|
||||
["filament_id"],
|
||||
["filament.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_tag_id"), "tag", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_tag_spool_id"), "tag", ["spool_id"], unique=False)
|
||||
op.create_index(op.f("ix_tag_filament_id"), "tag", ["filament_id"], unique=False)
|
||||
# The whole point of the table: one physical tag means exactly one thing, enforced by
|
||||
# the database rather than by whichever client happened to write last.
|
||||
op.create_index(op.f("ix_tag_uid"), "tag", ["uid"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Perform the downgrade."""
|
||||
op.drop_index(op.f("ix_tag_uid"), table_name="tag")
|
||||
op.drop_index(op.f("ix_tag_filament_id"), table_name="tag")
|
||||
op.drop_index(op.f("ix_tag_spool_id"), table_name="tag")
|
||||
op.drop_index(op.f("ix_tag_id"), table_name="tag")
|
||||
op.drop_table("tag")
|
||||
@@ -88,14 +88,23 @@ class Message(BaseModel):
|
||||
|
||||
|
||||
class TagConflictMessage(Message):
|
||||
"""A tag UID is already linked to a different spool.
|
||||
"""A tag UID is already linked to something else.
|
||||
|
||||
Subclasses Message so the `message` key is where it is in every other error body, and
|
||||
adds the conflicting spool's ID so a client can offer to move the tag there instead of
|
||||
making the user go and find it.
|
||||
|
||||
`spool_id` is optional because a tag identifies one thing and that thing is not always
|
||||
a spool -- see `models.Tag`. It is absent when the UID is held by something else, in
|
||||
which case `message` still says what; a client that cannot offer "move it here" without
|
||||
an id should fall back to reporting the message.
|
||||
"""
|
||||
|
||||
spool_id: int = Field(description="The spool the tag is already linked to.", examples=[42])
|
||||
spool_id: int | None = Field(
|
||||
None,
|
||||
description="The spool the tag is already linked to, if it is a spool that holds it.",
|
||||
examples=[42],
|
||||
)
|
||||
|
||||
|
||||
class SettingResponse(BaseModel):
|
||||
|
||||
+47
-10
@@ -97,7 +97,7 @@ class Spool(Base):
|
||||
# selectin for the same reason as `extra` above: a spool listing already joins
|
||||
# filament and vendor, and a joined collection here would multiply against the
|
||||
# extra-field rows.
|
||||
tags: Mapped[list["SpoolTag"]] = relationship(
|
||||
tags: Mapped[list["Tag"]] = relationship(
|
||||
back_populates="spool",
|
||||
cascade="save-update, merge, delete, delete-orphan",
|
||||
lazy="selectin",
|
||||
@@ -139,24 +139,61 @@ class SpoolField(Base):
|
||||
value: Mapped[str] = mapped_column(Text())
|
||||
|
||||
|
||||
class SpoolTag(Base):
|
||||
"""A physical NFC/RFID tag linked to a spool.
|
||||
class Tag(Base):
|
||||
"""A physical NFC/RFID tag, and the thing tapping it should bring up.
|
||||
|
||||
Many tags per spool is a real case rather than a hypothetical: copying a Prusa NFC-V
|
||||
Many tags per target is a real case rather than a hypothetical: copying a Prusa NFC-V
|
||||
tag's payload onto an NTAG215 so a PN532 can read it leaves one spool carrying two
|
||||
physical tags with two different UIDs (#776).
|
||||
|
||||
The unique index on `uid` is the point of the table -- one tag, one spool, enforced.
|
||||
It only means that because every write goes through `spoolman.tags.normalize_uid`;
|
||||
see the module docstring there.
|
||||
The unique index on `uid` is the point of the table -- one physical tag means exactly
|
||||
one thing, enforced. It only means that because every write goes through
|
||||
`spoolman.tags.normalize_uid`; see the module docstring there.
|
||||
|
||||
## Why the target is not just `spool_id`
|
||||
|
||||
Only spools are tagged today. The columns are wider than that because the shapes a
|
||||
tag might point at are not all rows, and finding that out after release would mean
|
||||
altering a populated table's nullability on four databases:
|
||||
|
||||
* `spool_id` / `filament_id` -- targets that ARE rows, so they are real foreign keys
|
||||
and the database keeps them honest.
|
||||
* `target_value` -- targets that are not rows at all. A location in Spoolman is a
|
||||
string on a spool (`Spool.location`), not a table, so a tag that means "show me
|
||||
Shelf A" can only carry the value. Adding such a kind later needs no migration:
|
||||
only a new `target_type`.
|
||||
|
||||
`target_type` says which of those is in force rather than leaving readers to infer it
|
||||
from whichever column is non-null, so a future kind that populates nothing (or
|
||||
something new) stays legible.
|
||||
|
||||
Exactly-one-target is enforced in `spoolman.database.tag`, not by a CHECK constraint:
|
||||
no migration in this tree uses one, and MySQL below 8.0.16 accepts and silently
|
||||
ignores them, so a constraint here would be real on three databases out of four --
|
||||
worse than a single enforced path. Same reasoning as `normalize_uid`.
|
||||
"""
|
||||
|
||||
__tablename__ = "spool_tag"
|
||||
__tablename__ = "tag"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id"), index=True)
|
||||
spool: Mapped["Spool"] = relationship(back_populates="tags")
|
||||
uid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
# Which kind of thing this tag points at; see the class docstring. Values live in
|
||||
# spoolman.tags. A string rather than an enum for the same reason `format` is one:
|
||||
# new kinds should not need a schema migration on four databases.
|
||||
target_type: Mapped[str] = mapped_column(String(16))
|
||||
spool_id: Mapped[int | None] = mapped_column(ForeignKey("spool.id"), index=True)
|
||||
spool: Mapped["Spool | None"] = relationship(back_populates="tags")
|
||||
# No ORM relationship yet, deliberately. Nothing writes filament tags, and a
|
||||
# `selectin` collection on Filament would add a query to every filament listing for
|
||||
# rows that cannot exist. ON DELETE CASCADE keeps the database honest in the
|
||||
# meantime; the relationship arrives with the feature that needs it.
|
||||
filament_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("filament.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
# For target kinds addressed by value rather than by row -- a location name. Sized to
|
||||
# match Spool.location, which is what it would hold.
|
||||
target_value: Mapped[str | None] = mapped_column(String(64))
|
||||
# Free-ish string (openprinttag, ntag, bambu, ...) rather than an enum: informational
|
||||
# in phase 1, and new tag types appear faster than migrations should.
|
||||
format: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
@@ -293,10 +293,14 @@ def _apply_spool_filters(
|
||||
if tag is not None:
|
||||
# An inner join rather than a subquery, so the list query and the count query stay
|
||||
# in sync automatically: they share this builder. `uid` is unique, so at most one
|
||||
# spool_tag row can match and the join cannot multiply result rows -- which is why
|
||||
# the contains_eager chain for filament/vendor is unaffected.
|
||||
stmt = stmt.join(models.SpoolTag, models.SpoolTag.spool_id == models.Spool.id).where(
|
||||
models.SpoolTag.uid == normalize_uid(tag),
|
||||
# tag row can match and the join cannot multiply result rows -- which is why the
|
||||
# contains_eager chain for filament/vendor is unaffected.
|
||||
#
|
||||
# The join condition also does the filtering: a tag that points at something other
|
||||
# than a spool has a null `spool_id`, which matches no spool, so such a UID finds
|
||||
# nothing here rather than finding the wrong thing.
|
||||
stmt = stmt.join(models.Tag, models.Tag.spool_id == models.Spool.id).where(
|
||||
models.Tag.uid == normalize_uid(tag),
|
||||
)
|
||||
stmt = add_where_clause_int(stmt, models.Spool.filament_id, filament_id)
|
||||
stmt = add_where_clause_int_opt(stmt, models.Filament.vendor_id, vendor_id)
|
||||
|
||||
+37
-16
@@ -1,4 +1,4 @@
|
||||
"""Helper functions for interacting with spool tag database objects.
|
||||
"""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`,
|
||||
@@ -8,6 +8,14 @@ This module is the ONE place a UID gets normalized, on the way in and on every l
|
||||
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. `models.Tag` can address
|
||||
things that are not spools -- and things that are not rows at all, such as a location --
|
||||
but only spool tags are written today, so `link` sets `target_type` and every read here
|
||||
is explicit about wanting a spool rather than assuming the tag it found is one. 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
|
||||
@@ -20,24 +28,39 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from spoolman.api.v1.models import EventType
|
||||
from spoolman.database import models, spool
|
||||
from spoolman.exceptions import ItemNotFoundError, TagConflictError
|
||||
from spoolman.tags import normalize_format, normalize_uid
|
||||
from spoolman.tags import TARGET_SPOOL, normalize_format, normalize_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_tag_by_uid(db: AsyncSession, uid: str) -> models.SpoolTag | None:
|
||||
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.SpoolTag).where(models.SpoolTag.uid == uid)
|
||||
stmt = sqlalchemy.select(models.Tag).where(models.Tag.uid == uid)
|
||||
return (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
def _conflict(uid: str, existing: models.Tag) -> TagConflictError:
|
||||
"""Describe a UID that is already spoken for.
|
||||
|
||||
Only spool tags are written today, so the second branch is unreachable in practice --
|
||||
but the table is deliberately able to hold other kinds (see `models.Tag`), and a
|
||||
conflict that reported a spool id of `None` would be worse than one that says plainly
|
||||
what holds the tag. The id is included only when there genuinely is one, which is what
|
||||
lets a client offer "move it here" and fall back to reporting the message otherwise.
|
||||
"""
|
||||
if existing.spool_id is not None:
|
||||
return TagConflictError(f"Tag {uid} is already linked to spool {existing.spool_id}.", existing.spool_id)
|
||||
target = existing.target_value if existing.target_value is not None else existing.filament_id
|
||||
return TagConflictError(f"Tag {uid} is already linked to {existing.target_type} {target}.")
|
||||
|
||||
|
||||
async def link(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
spool_id: int,
|
||||
uid: str,
|
||||
tag_format: str | None = None,
|
||||
) -> models.SpoolTag:
|
||||
) -> 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
|
||||
@@ -51,7 +74,7 @@ async def link(
|
||||
tag_format: Optional tag format name, e.g. "ntag".
|
||||
|
||||
Returns:
|
||||
models.SpoolTag: The linked tag.
|
||||
models.Tag: The linked tag.
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError: If no spool with that ID exists.
|
||||
@@ -67,18 +90,16 @@ async def link(
|
||||
existing = await _get_tag_by_uid(db, uid)
|
||||
if existing is not None:
|
||||
if existing.spool_id != spool_id:
|
||||
raise TagConflictError(
|
||||
f"Tag {uid} is already linked to spool {existing.spool_id}.",
|
||||
existing.spool_id,
|
||||
)
|
||||
raise _conflict(uid, existing)
|
||||
if tag_format is not None and existing.format != tag_format:
|
||||
existing.format = tag_format
|
||||
await db.commit()
|
||||
await spool.spool_changed(db_spool, EventType.UPDATED)
|
||||
return existing
|
||||
|
||||
tag = models.SpoolTag(
|
||||
tag = models.Tag(
|
||||
uid=uid,
|
||||
target_type=TARGET_SPOOL,
|
||||
format=tag_format,
|
||||
added=datetime.utcnow().replace(microsecond=0),
|
||||
)
|
||||
@@ -93,10 +114,7 @@ async def link(
|
||||
winner = await _get_tag_by_uid(db, uid)
|
||||
if winner is None:
|
||||
raise
|
||||
raise TagConflictError(
|
||||
f"Tag {uid} is already linked to spool {winner.spool_id}.",
|
||||
winner.spool_id,
|
||||
) from None
|
||||
raise _conflict(uid, winner) from None
|
||||
|
||||
await spool.spool_changed(db_spool, EventType.UPDATED)
|
||||
return tag
|
||||
@@ -147,6 +165,9 @@ async def find_spool_by_uid(db: AsyncSession, uid: str) -> models.Spool | None:
|
||||
|
||||
"""
|
||||
tag = await _get_tag_by_uid(db, normalize_uid(uid))
|
||||
if tag is None:
|
||||
# A known tag that points at something other than a spool is not a spool match, and
|
||||
# answering with one would be a lie. `spool_id` carries that on its own: it is null
|
||||
# for every other kind of target.
|
||||
if tag is None or tag.spool_id is None:
|
||||
return None
|
||||
return await spool.get_by_id(db, tag.spool_id)
|
||||
@@ -18,14 +18,19 @@ class SpoolMeasureError(Exception):
|
||||
|
||||
|
||||
class TagConflictError(Exception):
|
||||
"""A tag UID is already linked to a different spool.
|
||||
"""A tag UID is already linked to something else.
|
||||
|
||||
Carries the conflicting spool's ID rather than only a message, so the API can hand it
|
||||
to the client and the client can offer "move the tag to this spool instead" without a
|
||||
second lookup.
|
||||
|
||||
`spool_id` is None when the UID is held by something that is not a spool -- a tag
|
||||
identifies exactly one thing, and not every such thing is a spool (see
|
||||
`database.models.Tag`). The message says what in that case, and a client with nothing
|
||||
to offer beyond it simply reports it.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, spool_id: int) -> None:
|
||||
def __init__(self, message: str, spool_id: int | None = None) -> None:
|
||||
"""Initialize with the message and the ID of the spool already holding the tag."""
|
||||
super().__init__(message)
|
||||
self.spool_id = spool_id
|
||||
+16
-5
@@ -8,21 +8,32 @@ colon-separated lowercase, ESPHome emits ``04-a2-b3``, nfcpy hands you raw bytes
|
||||
most scripts ``.hex()`` into unseparated lowercase.
|
||||
|
||||
``normalize_uid`` collapses all of those to one canonical spelling so that the unique
|
||||
index on ``spool_tag.uid`` actually means "one tag, one spool". Without it the same
|
||||
physical tag could be linked to three spools under three spellings and no constraint
|
||||
would notice.
|
||||
index on ``tag.uid`` actually means "one physical tag identifies one thing". Without it
|
||||
the same physical tag could be linked to three spools under three spellings and no
|
||||
constraint would notice.
|
||||
|
||||
Pure module: no database, no FastAPI. Same shape as ``colors.py`` and ``math.py``.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Mirrors the SpoolTag.uid column width, so an over-long UID is a 400 and not a 500.
|
||||
# Mirrors the Tag.uid column width, so an over-long UID is a 400 and not a 500.
|
||||
UID_MAX_LENGTH = 64
|
||||
|
||||
# Mirrors the SpoolTag.format column width.
|
||||
# Mirrors the Tag.format column width.
|
||||
FORMAT_MAX_LENGTH = 32
|
||||
|
||||
# What a tag points at, stored in `Tag.target_type`. Only TARGET_SPOOL is ever written
|
||||
# today; the other two are named here because the column exists for them, and a reserved
|
||||
# name is cheaper to honour than one invented later by two people differently.
|
||||
#
|
||||
# TARGET_SPOOL and TARGET_FILAMENT address a row, and carry a foreign key. TARGET_LOCATION
|
||||
# addresses a value: a location is a string on a spool rather than a table, so such a tag
|
||||
# means "the spools whose location is this" and lives in `Tag.target_value`.
|
||||
TARGET_SPOOL = "spool"
|
||||
TARGET_FILAMENT = "filament"
|
||||
TARGET_LOCATION = "location"
|
||||
|
||||
# Tag formats Spoolman knows the name of. Deliberately NOT an enum and NOT enforced:
|
||||
# `format` is informational in phase 1, new tag types appear faster than migrations
|
||||
# should, and rejecting an unknown one would make Spoolman the thing standing between a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for tag UID normalization.
|
||||
|
||||
The unique index on `spool_tag.uid` only means "one tag, one spool" because every write goes
|
||||
The unique index on `tag.uid` only means "one tag, one thing" because every write goes
|
||||
through `normalize_uid`. These pin the shapes real readers produce, so a change that stopped
|
||||
collapsing one of them would show up here rather than as two spools quietly claiming the same
|
||||
physical tag.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Integration tests: linking physical NFC/RFID tags to spools, and looking a spool up by one.
|
||||
|
||||
The point of the `spool_tag` table is that one tag identifies exactly one spool, enforced by the
|
||||
The point of the `tag` table is that one tag identifies exactly one thing, enforced by the
|
||||
database rather than by whichever client wrote last. These tests pin that from both ends: the
|
||||
409 that stops a second spool claiming a tag, and the normalization that stops the same physical
|
||||
tag being linked twice under two spellings.
|
||||
|
||||
Reference in new issue
Block a user