Skip to content

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 (migraterebac syncresources 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

python
class TagQuerySet(ArchiveQuerySet[Any], AngeeQuerySet[Any])

Archive read scopes layered over the REBAC-scoped tag queryset.

Tag

python
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

python
class Meta()

Django model options for a tag.

__str__

python
def __str__() -> str

Return the tag name for Django displays.

is_shared_scope

python
@property
def is_shared_scope() -> bool

Return whether this row should carry the shared wildcard reader.

from_db

python
@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

python
def save(*args: Any, **kwargs: Any) -> None

Persist 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

python
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

python
def resolve_target(target_type: str,
                   target_id: str) -> CanonicalRecordTarget | None

Resolve 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

python
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

python
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

python
def detach(target_type: str, target_id: str, tag_ids: list[str]) -> int

Detach 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

python
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

python
class Meta()

Django model options for a tag assignment.

__str__

python
def __str__() -> str

Return 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.

Released under the AGPL-3.0 License.