angee.tags.models
Tags: a polymorphic shared labelling vocabulary.
A :class:Tag is one label in a vocabulary; a :class:TagAssignment is the polymorphic edge attaching a tag to any row. The edge follows the storage.FileAttachment canon exactly — a content_type/object_id pair with a :class:~django.contrib.contenttypes.fields.GenericForeignKey target — so tags depend on nothing but angee.iam and reach every model without a FK back to it. Consumers attach explicitly through :meth:TagAssignmentManager.attach (create the edge against the concrete target) exactly as storage consumers attach a file.
Scope. Base tags are shared vocabulary, readable by every authenticated actor through a wildcard shared@auth/user:* reader tuple maintained by :meth:Tag.save. Downstream addons may extend the row with their own scope field and override :attr:is_shared_scope; the base model declares which stored fields back that fact through :attr:shared_scope_source_fields so deferred loads do not snapshot an unloaded value.
Pitfalls. Shared-tag visibility rides :meth:Tag.save: any write path that skips save() — bulk_create, queryset.update(...), raw loaddata — leaves the wildcard reader stale (an invisible shared tag or a lingering everyone-grant); route scope changes through instance saves. And the tuple write validates against the loaded REBAC schema, so creating a tag requires rebac sync to have run first — the standard loop order (migrate → rebac sync → resources load) already guarantees it.
Party tags compose this addon without any parties change: a party is tagged by attaching to its Party row (the canon's explicit-attach path). The ergonomic reverse accessor (GenericRelation("tags.TagAssignment") on Party) is a parties-owned decision — adding it makes parties depend on tags for every composing project, so it lands in parties (model + addon.toml dependency together) only when that dependency is wanted. Declare that reverse relation on Party itself — the topmost REBAC-typed MTI ancestor the canonical edge keys on (:func:angee.base.refs.canonical_record_target), never on a Person/Organization child — so the delete collector filters at the same content type the write used (the placement invariant in :mod:angee.base.refs).
SHARED_READER_RELATION
The wildcard-subject relation that opens a shared tag to everyone.
TagQuerySet
class TagQuerySet(ArchiveQuerySet[Any], AngeeQuerySet[Any])Archive read scopes layered over the REBAC-scoped tag queryset.
Tag
class Tag(ArchiveMixin, AngeeDataModel)One label in a shared vocabulary.
:meth:save keeps the wildcard reader tuple in step with :attr:is_shared_scope so the REBAC read scope stays truthful without a queryset override.
shared_scope_source_fields
Stored fields from which :attr:is_shared_scope is computed.
Meta
class Meta()Django model options for a tag.
__str__
def __str__() -> strReturn the tag name for Django displays.
is_shared_scope
@property
def is_shared_scope() -> boolReturn whether this row should carry the shared wildcard reader.
from_db
@classmethod
def from_db(cls, db: Any, field_names: Any, values: Any) -> "Tag"Load a row, snapshotting shared scope when its source fields are loaded.
Tag owns the original-value snapshot the reconcile compares against (the canonical Django "track the loaded value" shape). If any declared source field is deferred, the snapshot is skipped so :meth:save fail-safes into the idempotent re-sync rather than evaluating a missing row fact.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the row and reconcile its shared-reader wildcard tuple atomically.
A shared tag carries a shared@auth/user:* tuple that opens it to every actor. Row and tuple commit or roll back together — a shared tag must never land without its reader, nor a scoped transition leave a stale grant. The reconcile runs only when the row is new, no complete snapshot exists, or the shared/scoped fact changes; an instance with no snapshot fail-safes into the idempotent re-sync.
TagAssignmentManager
class TagAssignmentManager(AngeeManager)Owns the polymorphic tag edge: target resolution, attach, and detach.
The write protocol (the storage.FileManager.draft shape): the target and every tag resolve under the ambient actor — the REBAC-scoped lookups fail fast on a row the actor cannot read, so nobody tags or untags what they cannot see — and only the edge insert/delete itself runs under system_context, because tags/tag_assignment declares no create permission (rows enter through gated call sites, the FileAttachment precedent) and the pre-insert check has no row id to gate on.
resolve_target
def resolve_target(target_type: str,
target_id: str) -> CanonicalRecordTarget | NoneResolve the canonical edge target for a public target address.
target_type is a REBAC resource type (e.g. parties/party) and target_id the row's public id. Returns None when the type or row is unknown or unreadable — the lookup runs on the actor-scoped default manager. The returned :class:~angee.base.refs.CanonicalRecordTarget carries the content_type and object_id canonicalized to the target's topmost REBAC MTI ancestor (:func:angee.base.refs.canonical_record_target): a parties/person address and a parties/party address resolve to one parties/party edge, so mixed-level addressing never splits the edge set.
for_target
def for_target(target_type: str, target_id: str) -> models.QuerySet[Any]Return the assignments on one target row, empty when it does not resolve.
attach
def attach(target_type: str, target_id: str, tag_ids: list[str]) -> list[Any]Attach each tag to the target row, idempotently per edge.
Fails fast with :class:ValueError on an unresolvable target or tag (an unreadable row is indistinguishable from a missing one, by design). Only the get_or_create runs elevated; created_by still stamps from the ambient actor, which elevation preserves.
detach
def detach(target_type: str, target_id: str, tag_ids: list[str]) -> intDetach each tag from the target row; return the number of edges removed.
Same protocol as :meth:attach: target and tags resolve under the actor, only the delete elevates.
TagAssignment
class TagAssignment(SqidMixin, AuditMixin, RecordRefMixin, AngeeModel)Polymorphic edge attaching one :class:Tag to any model row.
The exact storage.FileAttachment canon: a content_type/object_id pair with a :class:GenericForeignKey target. Consumers attach explicitly through :meth:TagAssignmentManager.attach — the party-tag path targets a parties.Party row. Access rides entirely on the tag parent (see permissions.zed), the same way a file attachment rides its file: the polymorphic target is not a single REBAC type, so no arrow can cover it.
Meta
class Meta()Django model options for a tag assignment.
__str__
def __str__() -> strReturn a readable label for Django displays.
TagRole
The tags/role anchor: its const admin arm resolves a platform admin as an effective tags manager. See :func:angee.base.models.role_anchor.