angee.base.mixins
Reusable abstract model mixins for Angee source models.
ARCHIVE_FLAG_FIELD
The one archive-flag column name — the single archive vocabulary word.
Every model that composes :class:ArchiveMixin carries this exact column, and the resource-metadata field classifier recognises the archive flag by this name (angee.data.field_classification.is_archive_field). Keeping the name identical everywhere is the contract that lets pickers default-filter archived rows and lists expose an archived facet without per-model wiring.
TimestampMixin
class TimestampMixin(models.Model)Add conventional creation and update timestamps to a model.
created_at
The timestamp when the row was first created.
updated_at
The timestamp when the row was most recently saved.
Meta
class Meta()Django model options for timestamp-only abstract inheritance.
update_fields_with_auto_now
def update_fields_with_auto_now(instance: models.Model,
update_fields: Any) -> set[str]Return non-empty update_fields plus this model's auto_now fields.
SqidMixin
class SqidMixin(models.Model)Add an opaque public identifier backed by the model primary key.
A model sets only the varying fact — its prefix — as sqid_prefix (e.g. sqid_prefix = "nte_"); the shared sqid column reads it (see SqidField.contribute_to_class), so no model re-declares the field.
sqid_prefix
Public-id prefix for sqid (e.g. "nte_"); empty means no prefix.
sqid
Opaque public identifier encoded from the integer primary key.
Meta
class Meta()Django model options for sqid-only abstract inheritance.
public_id_value
def public_id_value() -> AnyReturn the raw public identifier value for this instance.
public_id_lookup
@classmethod
def public_id_lookup(cls, value: str) -> dict[str, Any]Return the Django lookup for this model's public identifier.
public_id_from_pk
@classmethod
def public_id_from_pk(cls, value: Any) -> strReturn the public id encoded from this model's primary-key value.
AuditMixin
class AuditMixin(models.Model)Add conventional user-owned audit foreign keys to a model.
created_by
The user that created the row, when known.
updated_by
The user that most recently updated the row, when known.
Meta
class Meta()Django model options for audit-only abstract inheritance.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the row after stamping user audit fields.
ArchiveMixin
class ArchiveMixin(models.Model)Add a soft-archive flag to a model.
One vocabulary, everywhere: the column is is_archived (see :data:ARCHIVE_FLAG_FIELD) and the read scopes are .archived() / .unarchived() (compose :class:ArchiveQuerySet into the model's queryset). Archived rows are soft-hidden from default surfaces but kept for an explicit archived facet — a metadata fact the field classifier carries as archivable, not per-page logic. This is archival, distinct from a soft-delete/trash flag or an enablement flag, which own different contracts.
is_archived
Whether the row is archived — soft-hidden from default pickers and lists.
Meta
class Meta()Django model options for archive-only abstract inheritance.
ArchiveQuerySet
class ArchiveQuerySet(models.QuerySet[_ArchiveModelT])Composable read scopes for the :class:ArchiveMixin archive flag.
Mix into a model's queryset alongside its base queryset (e.g. class DriveQuerySet(ArchiveQuerySet[Drive], AngeeQuerySet[Drive])) so the archive vocabulary — .archived() / .unarchived() — reads as chainable predicates over the one is_archived column rather than repeated inline filters.
archived
def archived() -> SelfReturn rows flagged archived.
unarchived
def unarchived() -> SelfReturn rows not flagged archived — the default picker/list scope.
HistoryMixin
class HistoryMixin(models.Model)Mark a model as tracked by django-simple-history.
angee_model_attributes
@classmethod
def angee_model_attributes(
cls, *, app_label: str, model_class: type[models.Model],
extension_bases: tuple[type[models.Model], ...]
) -> tuple[ModelClassAttribute, ...]Return the simple-history class attribute for a concrete model.
angee_history_excluded_fields
@staticmethod
def angee_history_excluded_fields(
model_bases: tuple[type[models.Model], ...]) -> list[str]Return source fields simple-history cannot mirror.
Meta
class Meta()Django model options for history-only abstract inheritance.
RevisionMixin
class RevisionMixin(models.Model)Mark a model as tracked by django-reversion snapshots.
angee_model_decorators
Composer decorators applied to emitted concrete revision models.
revisioned_fields
Model field names registered with django-reversion.
Meta
class Meta()Django model options for revision-only abstract inheritance.
revisions
@property
def revisions() -> AnyReturn this row's django-reversion versions newest-first.
revert_to
def revert_to(version: Any) -> NoneRestore declared revisioned fields from version and save.
Saves with update_fields so unrelated in-memory columns are not flushed. The method records its own revert revision so integrity does not depend on the caller's transport opening a reversion block.
HierarchyQuerySet
class HierarchyQuerySet(models.QuerySet[_HierarchyModelT])Subtree read scopes for models composing :class:HierarchyMixin.
Compose alongside the model's base queryset (e.g. class LocationQuerySet(HierarchyQuerySet[Location], AngeeQuerySet[Location])) so the subtree vocabulary — :meth:subtree_of / :meth:ancestors_of — reads as chainable predicates over the maintained path column, served by the prefix index rather than a client-side parent walk.
subtree_of
def subtree_of(node: HierarchyMixin) -> SelfReturn node and every descendant (INCLUSIVE), by path prefix.
A node's own path is the prefix of every descendant's path and of itself, so a single LIKE 'path%' covers the whole subtree. An unmaterialized node (empty path) matches nothing rather than the whole table.
ancestors_of
def ancestors_of(node: HierarchyMixin) -> SelfReturn every proper ancestor of node (EXCLUSIVE of node).
HierarchyMixin
class HierarchyMixin(models.Model)Materialized-path tree membership for a self-parented model.
Adds a parent self-FK and a maintained path column of zero-padded, delimiter-terminated primary-key segments (/0000000012/0000000045/), so subtree membership is a prefix test the database serves from an index rather than a fact each addon re-derives by walking parent in the client. The terminal delimiter is the correctness guarantee — a path is a string prefix of another exactly when the first node is an ancestor-or-self of the second — and the zero-padding (see :attr:path_segment_width) keeps segments lexically ordered.
Compose it on a self-parented model, pair it with :class:HierarchyQuerySet for the subtree_of / ancestors_of read scopes, and inherit its Meta so the concrete table carries the prefix index::
class Location(HierarchyMixin, AngeeDataModel):
...
class Meta(HierarchyMixin.Meta):
abstract = False
app_label = "inventory"
rebac_resource_type = "inventory/location"
(Django propagates Meta.indexes only through Meta-class inheritance, not across sibling abstract bases, so a consumer that needs other indexes lists *HierarchyMixin.Meta.indexes alongside its own.)
:meth:save maintains the path: derived from the parent on create, and on reparent it rejects a cycle (a new parent inside the node's own subtree) and a parent in a different scope (any field the model names in :attr:hierarchy_scope_fields), then rewrites the whole subtree's paths in one bulk UPDATE. It owns no REBAC of its own; path maintenance runs unscoped so a reparent reaches descendants the acting user cannot read.
hierarchy_scope_fields
Field names a child must share with its parent (e.g. ("scope",)).
A reparent (and a create under a parent) rejects a parent that differs on any of these fields, so a subtree never straddles a scope boundary. It is a declared contract — generic and iam-free — owned by the consuming model rather than probed by column name: a scoped tree declares hierarchy_scope_fields = ("scope",), an unscoped tree leaves it empty. An FK is compared by its stored id (the field's attname); a parent must agree on every listed field.
parent
The parent node, or NULL for a root; PROTECT keeps a subtree whole.
path
Maintained root-to-self path of padded pk segments; server-owned.
editable=False keeps it out of forms and the auto-CRUD write surface — the mixin is its only writer. The column width bounds tree depth: at :attr:path_segment_width = 12 each segment costs 13 characters, so max_length=255 holds ~19 levels — deeper than any ERP location/category tree, but a consumer expecting deeper nesting must widen the column. Maintenance writes go through queryset update() (the one-UPDATE cascade), so path changes bypass post_save — a HistoryMixin consumer's historical rows do not track path, a derivable server-owned value.
path_segment_width
Zero-pad width for one pk segment.
Governs lexical ordering only; correctness rests on the terminal delimiter, so a primary key wider than this stays correct (it just sorts by raw digits within its level). Twelve digits order rows up to a trillion per table.
PATH_DELIMITER
Segment delimiter; safe because a padded pk segment is digits only.
Meta
class Meta()Abstract options carrying the prefix-serving path index.
from_db
@classmethod
def from_db(cls, db: Any, field_names: Sequence[str],
values: Sequence[Any]) -> SelfRecord the loaded parent so :meth:save can detect a reparent.
Only when parent was actually loaded: seeding the baseline off a deferred field (.only(...)/.defer(...) excluding parent) would trigger one extra query per row. :meth:_hierarchy_needs_repath falls back to the live parent_id when the baseline is absent, so a deferred load simply stays lazy.
refresh_from_db
def refresh_from_db(using: str | None = None,
fields: Sequence[str] | None = None,
from_queryset: models.QuerySet[Any] | None = None) -> NoneReload the row, re-syncing the reparent baseline to the loaded parent.
Without the re-sync a refresh after an external parent change leaves the baseline stale, and the next unrelated save() would be misclassified as a reparent.
ancestor_paths
def ancestor_paths() -> list[str]Return the paths of this node's proper ancestors, root-first.
Decomposes this node's own path into the cumulative prefixes at each delimiter boundary, dropping the last (the node itself) — so a root node yields an empty list.
is_within
def is_within(other: HierarchyMixin) -> boolReturn whether this node is other or a descendant of other.
The test is intentionally inclusive and query-free: the maintained, delimiter-terminated path column is a prefix of exactly its own subtree. Empty/unmaterialized paths match nothing so they cannot become an accidental whole-tree prefix.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the row, maintaining path on create and reparent.