Core - Models Documentation

Location: api/nextango/apps/core/models/base.py (base classes), api/nextango/apps/core/models.py (mixins) Last Updated: 2026-07-09

Overview

Core defines the model inheritance spine every domain app builds on: a UUID primary key, automatic timestamps, and bidirectional Sanity CMS sync with deterministic, revision-based sync decisions. Two mixins add audit tracking and soft deletion to any model that needs them.

BaseModel

Source: models/base.py:63-70

Abstract base providing a UUID primary key instead of an auto-incrementing integer.

FieldTypeDescription
idUUIDFieldPrimary key, auto-generated (uuid.uuid4), not editable
from nextango.apps.core.models import BaseModel

class MyModel(BaseModel):
    name = models.CharField(max_length=255)

TimestampedModel

Source: models/base.py:73-81

Extends BaseModel. Adds automatic creation and update timestamps.

FieldTypeDescription
created_atDateTimeFieldSet once, on creation (auto_now_add)
updated_atDateTimeFieldRefreshed on every save (auto_now)

SanityIntegratedModel

Source: models/base.py:84-577

Extends TimestampedModel. Base class for every model that mirrors a Sanity CMS document. Provides sync bookkeeping fields, a deterministic revision-based sync decision, and a P13 deletion-semantics contract that governs how instances may be deleted depending on who is authoring the record.

Fields

FieldTypeDescription
sanity_idCharField(255)Sanity document ID, unique, indexed, nullable
sanity_revCharField(255)Sanity document revision, used for conflict resolution
last_synced_atDateTimeFieldTimestamp of the last successful sync
sync_enabledBooleanFieldWhether this record participates in sync (default True)
sync_statusCharField(20)synced, out_of_sync, or deleted_from_sanity
is_draftBooleanFieldWhether this record mirrors a Sanity draft document
django_revisionPositiveIntegerFieldAuto-incremented on meaningful field changes
last_synced_django_revisionPositiveIntegerFieldRevision value at the last successful sync

A composite index (sync_enabled, django_revision, last_synced_django_revision) supports efficient out-of-sync queries.

Class attributes subclasses override

  • _outbound_sync_enabled (default True): set to False on models with no Django-to-Sanity outbound sync. Excludes the model from the revision health check.
  • _is_sanity_sot (default True): the P13 deletion-semantics classifier. True means Sanity is the source of truth for this model's lifecycle (deletion must originate from a Sanity webhook; Django admin hard-delete is denied). False means the model is Django-owned and admin deletion is permitted, subject to dependency checks.

Revision-based sync decision

Sync is needed when django_revision > last_synced_django_revision. save() bumps django_revision automatically whenever a meaningful (non-sync-metadata) field changes, except during inbound webhook processing (SyncContext.is_webhook_processing()) or when settings.SKIP_REVISION_TRACKING is set. New records always bump to revision 1. Existing records use an atomic F('django_revision') + 1 expression, then refresh the field from the database.

product.name = 'Updated Name'
product.save()  # django_revision increments; sync_status becomes 'out_of_sync'

if product.needs_sync_to_sanity():
    sync_to_sanity(product)
    product.mark_synced_to_sanity(sanity_rev='abc123')

# Sync metadata updates alone never bump the revision
product.last_synced_at = timezone.now()
product.save(update_fields=['last_synced_at'])

Sync lifecycle methods

  • needs_sync_to_sanity(): True if sync_enabled, not deleted_from_sanity, and django_revision > last_synced_django_revision.
  • get_sync_lag(): django_revision - last_synced_django_revision.
  • mark_synced_to_sanity(sanity_rev=None): writes sync bookkeeping via a queryset .update() (bypasses post_save, so it never re-triggers outbound sync signals) and mirrors the change onto the in-memory instance.
  • mark_out_of_sync(): sets sync_status='out_of_sync' and saves.
  • mark_deleted_from_sanity(): soft-delete, setting sync_status='deleted_from_sanity' and sync_enabled=False.
  • is_deleted_from_sanity(): True if sync_status == 'deleted_from_sanity'.
  • published() (classmethod): queryset excluding draft documents (is_draft=False).

should_sync() and mark_synced() remain for backward compatibility; new code uses needs_sync_to_sanity() and mark_synced_to_sanity().

Deletion semantics

SanityIntegratedModel implements the P13 deletion-semantics standard: hard-deleting a record that other rows depend on, or that Sanity still considers live, is a data-integrity hazard. Three pieces cooperate.

has_dependencies() walks the model's reverse relations and returns a DeletionDependencyCheckResult. It reports a blocker for every reverse FK or OneToOne relation using PROTECT, RESTRICT, or DO_NOTHING that has related rows, and for every M2M reverse relation with related rows. CASCADE / SET_NULL / SET_DEFAULT relations are not blockers (Django's delete collector handles those) unless a subclass elevates them via get_custom_deletion_blockers(). Exceptions during discovery propagate rather than being swallowed; an unknown dependency state must never silently allow a hard delete.

get_custom_deletion_blockers() is a subclass override hook (default: empty) for blockers beyond auto-discovered FK/M2M relations, such as active reservations or audit-history rows whose on_delete doesn't naturally block.

safe_delete(*, origin, hard_delete=False) is the origin-aware dispatcher. origin is one of sanity_webhook, django_admin, django_internal, cleanup_worker.

  • Sanity-SoT model (_is_sanity_sot=True) receiving origin='sanity_webhook': soft-deletes unconditionally (mark_deleted_from_sanity()), skipping the dependency check (the hot webhook path).
  • Sanity-SoT model receiving origin='django_admin' or 'django_internal': blocked (reason='sanity_gate_required'). Deletion must come from Sanity.
  • Sanity-SoT model receiving origin='cleanup_worker' with hard_delete=True: hard-deletes only if there are no dependency blockers and the record is already soft-deleted (is_deleted_from_sanity() is True).
  • Django-owned model (_is_sanity_sot=False): admin, internal, or cleanup-worker origins may hard-delete when hard_delete=True and there are no dependency blockers. origin='sanity_webhook' is a no-op; a Django-owned model never takes deletion orders from Sanity.

safe_delete() returns a SafeDeleteResult (action: blocked | soft_deleted | hard_deleted | noop, plus reason and the dependency_check). The admin-layer enforcement of this contract is documented in Admin Infrastructure.

AuditableMixin

Source: core/models/__init__.py:13-30

Mixin adding audit-trail actor fields. The live class carries the two FKs only; it does not provide timestamps, so models composing it pair it with TimestampedModel for created_at/updated_at. (A fuller definition exists in the module file core/models.py, but that file is shadowed by the core/models/ package and never imports; the shipped class is the package's fallback definition.)

FieldTypeDescription
created_byForeignKey(users.Employee)Nullable, SET_NULL on delete
updated_byForeignKey(users.Employee)Nullable, SET_NULL on delete

SoftDeleteMixin

Source: core/models/__init__.py:31-43

Adds soft-deletion fields. SoftDeleteMixin declares the fields only; it does not override delete(), restore(), or provide manager overrides (objects/all_objects/deleted); a model composing this mixin implements that behavior itself.

FieldTypeDescription
is_deletedBooleanFieldDefault False
deleted_atDateTimeFieldNullable
deleted_byForeignKey(users.Employee)Nullable, SET_NULL on delete

Model Inheritance Pattern

from nextango.apps.core.models import BaseModel, TimestampedModel, SanityIntegratedModel

class Product(SanityIntegratedModel):
    """Inherits UUID PK, timestamps, and Sanity sync/deletion semantics."""
    name = models.CharField(max_length=255)

Most Sanity-synced domain models inherit SanityIntegratedModel directly, since it already includes TimestampedModel and BaseModel in its MRO. Pure Django-owned models (no Sanity mirror) inherit TimestampedModel or BaseModel alone and add AuditableMixin / SoftDeleteMixin as needed.

Was this page helpful?