Products - Models Documentation

Location: api/nextango/apps/products/models.py Last Updated: 2026-07-06

Overview

The Products app keeps two representations of every variant:

  • Product.variants (JSONField) holds the exact Sanity structure for CMS sync
  • ProductVariant (model) is the relational record used for inventory, transactions, and queries

Sanity is the source of truth for product content. Sync runs Sanity to Django through webhooks; Product, ProductVariant, InventoryLevel, and InventoryMovement all disable outbound Django-to-Sanity sync (_outbound_sync_enabled = False). InventoryLevel changes index into Elasticsearch instead. The taxonomy models (Brand, Category, ProductType, Collection, Attribute) sync bidirectionally.


Models

Brand

Base class: SanityIntegratedModel, AuditableMixin Purpose: Product brand with filter visibility control.

FieldTypeDescription
nameCharField(255)Brand name
is_shownBooleanFieldShow in filters (default False)
slugSlugField(255)URL-friendly identifier, unique, indexed

Exposed by BrandSerializer (Serializers); read through BrandViewSet (Views).


Category

Base class: SanityIntegratedModel, AuditableMixin Purpose: Product categorization with collection references.

FieldTypeDescription
nameCharField(255)Category name
slugSlugField(96)URL-friendly identifier, unique, indexed
is_shownBooleanFieldShow in filters (default False)
descriptionTextFieldCategory description, optional
collectionsJSONFieldArray of Sanity collection references [{"_ref": "...", "_type": "reference"}]

Exposed by CategorySerializer (Serializers).


ProductType

Base class: SanityIntegratedModel, AuditableMixin Purpose: Product type classification within a category.

FieldTypeDescription
nameCharField(255)Product type name
slugSlugField(96)URL-friendly identifier, unique, indexed
categoryForeignKey(Category)Parent category, CASCADE, reverse accessor product_types
is_shownBooleanFieldShow in filters (default False)
descriptionTextFieldProduct type description, optional

Exposed by ProductTypeSerializer (Serializers).


Collection

Base class: SanityIntegratedModel, AuditableMixin Purpose: Product collection for merchandising.

FieldTypeDescription
nameCharField(255)Collection name
is_shownBooleanFieldShow in filters (default False)
slugSlugField(96)URL-friendly identifier, unique, indexed
descriptionTextFieldCollection description, optional
associated_product_typesJSONFieldArray of Sanity productType references

Exposed by CollectionSerializer (Serializers).


Product

Base class: SanityIntegratedModel, AuditableMixin Purpose: Main product record mirroring Sanity's product schema, with a derived variants JSON array.

Outbound sync is disabled: products are authored in Sanity and flow to Django via webhooks only.

FieldTypeDescription
hiddenBooleanFieldHide product from storefront (default False)
nameCharField(255)Product name, indexed
slugSlugField(96)URL-friendly identifier, unique, indexed
short_descriptionTextFieldShort product description, optional
bodyJSONFieldRich text body (Portable Text)
highlightsJSONFieldArray of highlight strings
base_dimensionsJSONFieldBase dimensions {width, height, depth, weight}
product_notesTextFieldInternal team notes
organizationJSONFieldOrganization object {productType, brand, collections, tags} from Sanity
brand_sanity_idCharField(255)Denormalized organization.brand._ref, indexed; maintained on save, source of truth stays in organization
product_type_sanity_idCharField(255)Denormalized organization.productType._ref, indexed; maintained on save
base_promo_codeCharField(255)Sanity _id of the product-level default PromoCode, optional
seoJSONFieldSEO object {title, description}
variantsJSONFieldArray of productVariant objects in exact Sanity structure

A composite index on (hidden, name) backs catalog listing queries. The denormalized taxonomy columns let catalog filters use B-tree indexes instead of scanning nested JSON paths; they refresh on create, on full save, and whenever organization is in update_fields.

Variant structure (JSONField)

Each entry in variants carries Sanity's camelCase field names:

{
  "_key": "f63e4c8b",
  "name": "Small - Red",
  "sku": "PROD-SM-RED",
  "upc": "812345678901",
  "featured": false,
  "onSale": true,
  "price": 29.99,
  "salePrice": 24.99,
  "stockInitial": 15,
  "digitalInventory": false,
  "customInventoryManagement": false,
  "reorderPoint": 5,
  "reorderQuantity": 20,
  "maxStockLevel": 100,
  "basePromoCode": {"_ref": "promo_id", "_type": "reference"},
  "attributes": [
    {"attribute": {"_ref": "attr_id"}, "value": "Red"}
  ],
  "dimensions": {"width": 10, "height": 5, "depth": 3, "weight": 0.5},
  "images": ["image1.jpg", "image2.jpg"],
  "storeLocation": [{"_ref": "store_id", "_type": "reference"}]
}

sync_variants_to_model_records(dispatch_inventory_reconcile_async=False)

Syncs the variants JSONField into ProductVariant records. Variants match by Sanity _key (with a SKU fallback for legacy records that predate _key). The method creates new variants, updates existing ones with camelCase-to-snake_case field mapping (onSale to on_sale, stockInitial to stock_initial, storeLocation to store_location), applies dimension inheritance from base_dimensions when a variant has no dimensions of its own, and handles variants removed from Sanity: those with transaction history are soft-deleted, the rest are hard-deleted.

After the sync, each active variant's inventory levels reconcile against its store assignments. With dispatch_inventory_reconcile_async=True (the webhook path), reconciliation fans out to Celery workers after the transaction commits instead of running inline. Non-webhook callers (admin actions, views, services, serializer writes) keep the default inline behavior.


ProductVariant

Base class: SanityIntegratedModel, AuditableMixin Purpose: Relational variant record for inventory, transactions, UPC lookups, and queries.

Variants are inline objects inside the Sanity product document, so they have no standalone outbound sync. They are created and maintained from Product.variants by sync_variants_to_model_records().

FieldTypeDescription
productForeignKey(Product)Parent product, CASCADE, reverse accessor variant_records
nameCharField(255)Variant display name
skuCharField(100)Stock Keeping Unit, unique, indexed
upcCharField(14)Barcode for POS scanning, optional, indexed; populated values must be unique (partial unique constraint, NULL and empty unconstrained)
sanity_variant_keyCharField(255)Sanity _key within product.variants, indexed
featuredBooleanFieldFeatured variant flag (default False)
on_saleBooleanFieldOn-sale flag (default False)
priceDecimalField(10,2)Regular price
sale_priceDecimalField(10,2)Discounted price when on sale, optional
base_promo_codeCharField(255)Sanity _id of a variant-level PromoCode override; falls back to Product.base_promo_code when NULL
store_locationJSONFieldArray of Sanity store references, GIN-indexed
store_specific_productBooleanFieldWhether inventory levels are created per assigned store only
stock_initialIntegerFieldInitial quantity that seeds InventoryLevel records, immutable after creation (default 0)
digital_inventoryBooleanFieldUnlimited digital inventory sentinel, no stock tracking (default False)
custom_inventory_managementBooleanFieldEnables reorder points and quantities (default False)
reorder_pointIntegerFieldReorder threshold, optional
reorder_quantityIntegerFieldQuantity to order when restocking, optional
max_stock_levelIntegerFieldMaximum stock to keep, optional
attributesJSONFieldVariant attributes [{"attribute": {"_ref": "..."}, "value": "..."}]; attribute filtering goes through the Elasticsearch path, not DB joins
dimensionsJSONFieldVariant dimensions; inherits Product.base_dimensions when empty
imagesJSONFieldArray of variant image URLs
cannabis_complianceJSONFieldFull Sanity cannabisCompliance object (thcPct, cbdPct, strainType, menuType, labResults, unitWeightMg, labeledWeight, vapeTaxApplicable, limitCategory). Industry-swappable slot: another vertical would carry food_compliance instead

Stock seeding behavior

stock_initial (Sanity field stockInitial) is used once, when the variant is created, to seed InventoryLevel.stock_on_hand. After creation, changes to this field do not update InventoryLevel.stock_on_hand; the original value is preserved in InventoryLevel.stock_initial. Adjust live inventory through the InventoryLevel API endpoints.

Properties and methods

effective_price returns sale_price when on_sale and a sale price is set, otherwise price.

is_in_stock is a global, non-store-specific check: digital variants always report in stock; physical variants report stock_initial > 0. It is not the per-store availability signal. "Is this purchasable at store X" goes through the store-availability seam (products/availability.py), which reads live InventoryLevel rows.

get_effective_promo_code_id() resolves the two-tier promo hierarchy: the variant's base_promo_code if set, else the product's, else None. It does not validate against PromoCode scope; the consuming service applies that gate.

reconcile_inventory_levels() reconciles InventoryLevel rows against the variant's current store assignments: it propagates SKU changes, creates or reactivates rows for newly assigned stores (a reactivated row resets stock_on_hand to 0 and requires a physical count), and soft-deletes rows for unassigned stores. Digital variants get no rows; any legacy active row is soft-deleted. When store_specific_product is false the variant targets all active stores.

has_transaction_history() reports whether any SaleLineItem references the variant; can_be_hard_deleted() is its negation. get_custom_deletion_blockers() surfaces that transaction history as an audit blocker for the safe-delete flow. soft_delete_if_needed() is deprecated; use safe_delete(origin='cleanup_worker', hard_delete=True).

Exposed by ProductVariantSerializer (Serializers); operated on by the product services (Services).


Attribute

Base class: SanityIntegratedModel, AuditableMixin Purpose: Product attribute set for filtering and variant differentiation.

FieldTypeDescription
nameCharField(255)Attribute set name (e.g. "Color", "Material")
is_shownBooleanFieldShow in filters (default False)
display_orderIntegerFieldFilter UI ordering, optional
featuredBooleanFieldFeatured filter flag (default False)
attribute_typeJSONFieldArray containing one attribute type object

The attribute_type array holds exactly one object, either:

{"_type": "attributeTextList", "options": ["Small", "Medium", "Large"]}

or:

{
  "_type": "attributeColorSwatch",
  "colors": [{"name": "Red", "color": {"hex": "#FF0000"}}]
}

type_name returns the _type of that object. options returns the option list for a text list, or {name, hex, value} objects for a color swatch.

Exposed by AttributeSerializer (Serializers).


InventoryLevel

Base class: SanityIntegratedModel, AuditableMixin Purpose: Per-variant, per-store inventory tracking with costing and reorder settings.

Outbound Sanity sync is disabled; changes index into Elasticsearch only.

Identification fields

FieldTypeDescription
productForeignKey(Product)Product reference, CASCADE, reverse accessor inventory_levels
variantForeignKey(ProductVariant)Variant reference for indexed joins, CASCADE, nullable
variant_skuCharField(100)Variant SKU, indexed; kept alongside the FK for Sanity sync compatibility
variant_nameCharField(255)Variant display name, optional
storeForeignKey(stores.Store)Store reference, CASCADE
store_specific_productBooleanFieldSet True on save for every row backed by a store
store_locationJSONFieldSanity store reference; _ref is store.sanity_id. Save raises ValueError when the store has no sanity_id
is_activeBooleanFieldActive for inventory tracking (default True)

Uniqueness is (product, variant_sku, store). A partial index on (store, variant_sku) where is_active=True backs store-scoped lookups.

Stock fields

Five quantity-shaped attributes describe per-row stock state. Each tracks a different lifecycle aspect of the same physical stock; select by intent.

FieldTypeDescription
stock_initialIntegerFieldSnapshot of ProductVariant.stock_initial at row creation. Never updated; diagnostic reference only
stock_on_handIntegerFieldLive stock counter, indexed. Decremented by sale commits, incremented by voids, returns, and procurement receives, adjusted by admin actions
stock_reservedIntegerFieldReservations held during the cart-checkout-payment window; released on completion or void
stock_lifetime_receivedIntegerFieldCumulative receipts. Initialized at row creation, incremented only by procurement on stock receive, never decremented by sales
stock_availablepropertyComputed max(0, stock_on_hand - stock_reserved), read-only. The clamp returns 0 when reservations exceed on-hand, a state that signals a write-ordering anomaly worth investigating via the underlying fields

Costing and strategy fields

FieldTypeDescription
on_saleBooleanFieldOn-sale flag
sale_priceDecimalField(10,2)Discounted price when on sale, optional
cost_per_unitDecimalField(10,2)Cost per unit, optional
standard_costDecimalField(10,2)Standard cost, optional
cost_methodCharField(20)standard, average, or fifo (default standard)
current_costDecimalField(10,2)Current cost under the selected method, optional
average_costDecimalField(10,2)Weighted average cost. Recalculated on every purchase order receive by the procurement receiving signal: new_avg = (old_qty × old_avg + recv_qty × recv_cost) / (old_qty + recv_qty), rounded to 2 places; set directly to the received unit cost when stock is empty or no prior cost exists. Used for COGS
last_purchase_costDecimalField(10,2)Cost from the last purchase, optional
last_transaction_costDecimalField(10,2)Cost from the last transaction, optional
last_transaction_dateDateTimeFieldTimestamp of the last transaction, optional

Settings and analytics fields

FieldTypeDescription
reorder_pointIntegerFieldReorder threshold (default 0)
reorder_quantityIntegerFieldQuantity to reorder (default 0)
max_stock_levelIntegerFieldMaximum stock threshold, optional
total_transaction_countIntegerFieldTransactions recorded against this row
total_revenueDecimalField(15,2)Revenue recorded against this row
created_at_sanityDateTimeFieldCreation timestamp from Sanity, optional
last_cost_updateDateTimeFieldLast cost update, optional
django_idCharField(50)Django ID stored in Sanity, optional
last_synced_atDateTimeFieldLast sync timestamp, optional
last_updatedDateTimeFieldAuto-updated modification timestamp

Properties and methods

stock_status returns inactive, out_of_stock (on hand is 0), low_stock (on hand at or below reorder_point), overstocked (above max_stock_level), or in_stock. needs_reorder and is_overstocked apply the same thresholds for active rows. effective_sale_price returns sale_price only when on_sale.

is_quarantined() reports whether the row is under a compliance hold in any registered vertical batch profile. Verticals register into the _VERTICAL_BATCH_PROFILE_REGISTRATIONS registry (a dict keyed by relation name, models.py:1110-1127) from their own AppConfig.ready(); cannabis registers its cannabis_profile relation from compliance/cannabis/apps.py. The check ORs across every registered relation and returns False when none is registered, which is correct both for non-regulated verticals and for installs without a given vertical's compliance app.

get_custom_deletion_blockers() surfaces blockers the safe-delete flow cannot infer from FK semantics: active checkout reservations (operational), analytics ledger rows (audit), and one audit blocker per registered vertical batch profile, resolved through the same registry so an install without a given vertical's compliance app contributes no blocker for it.

Exposed by InventoryLevelSerializer (Serializers).


InventoryMovement

Base class: SanityIntegratedModel, AuditableMixin Purpose: Append-only audit row for inventory movements.

Django-owned: outbound Sanity sync is disabled and admin display is read-only.

FieldTypeDescription
movement_idCharField(255)Movement identifier, unique, indexed
productForeignKey(Product)Product reference, CASCADE
variant_skuCharField(100)SKU of the variant being moved, indexed
quantityIntegerFieldPositive for incoming, negative for outgoing
movement_typeCharField(20)One of receiving, sale, return, adjustment, transfer, commit, void, restore
from_locationForeignKey(stores.Store)Source store, SET_NULL, optional
to_locationForeignKey(stores.Store)Destination store, SET_NULL, optional
employeeForeignKey(users.User)Employee reference, SET_NULL, optional
related_transactionForeignKey(transactions.SaleTransaction)Linked sale transaction, SET_NULL, optional
timestampDateTimeFieldMovement timestamp, auto

commit and void support the transaction inventory-idempotency flow: sale commits emit commit rows and voids emit void rows. is_incoming and is_outgoing report quantity sign. "Incoming" is a movement direction, not a live counter; cumulative receipts live on InventoryLevel.stock_lifetime_received.


DailyInventorySnapshot

Base class: models.Model Purpose: Pre-aggregated daily inventory state per store, written nightly by the rollup_daily_inventory_snapshot Celery task and read by analytics inventory-trend endpoints.

FieldTypeDescription
storeForeignKey(stores.Store)Store, CASCADE, reverse accessor daily_inventory_snapshots
dateDateFieldCalendar date covered; one row per store per day (unique together)
total_sku_countPositiveIntegerFieldActive InventoryLevel rows for the store
total_units_on_handPositiveIntegerFieldSum of stock_on_hand across active rows
total_inventory_valueDecimalField(14,2)Sum of stock_on_hand × current_cost across active rows
low_stock_countPositiveIntegerFieldActive rows where 0 < stock_on_hand <= reorder_point
out_of_stock_countPositiveIntegerFieldActive rows with stock_on_hand = 0
snapshot_atDateTimeFieldWhen the rollup last wrote the row

InventoryAnalyticsLedger

Base class: TimestampedModel Purpose: Audit-trail row per (sale_transaction, inventory_level) analytics counter increment.

FieldTypeDescription
sale_transactionForeignKey(transactions.SaleTransaction)CASCADE, reverse accessor inventory_analytics_ledger
inventory_levelForeignKey(InventoryLevel)CASCADE, reverse accessor analytics_ledger
applied_atDateTimeFieldWhen the increment applied, auto

Row existence is authoritative for whether InventoryLevel analytics counters were incremented for a given pair; a unique constraint on the pair keeps the deferred analytics task safe under Celery retries.



Model Relationships

Product
├── variant_records (ProductVariant) - One-to-Many
├── inventory_levels (InventoryLevel) - One-to-Many
└── inventory_movements (InventoryMovement) - One-to-Many

ProductVariant
├── product (Product) - Many-to-One
└── inventory_levels (InventoryLevel) - One-to-Many

InventoryLevel
├── product (Product) - Many-to-One
├── variant (ProductVariant) - Many-to-One
├── store (Store) - Many-to-One
└── analytics_ledger (InventoryAnalyticsLedger) - One-to-Many

Category
└── product_types (ProductType) - One-to-Many

Key Design Patterns

Dual structure

Product.variants preserves the exact Sanity shape for webhook sync; ProductVariant records enable foreign keys, indexed queries, and business logic. sync_variants_to_model_records() keeps the two in step, and a post-save cascade keeps derived representations consistent when a variant record changes (Signals).

Dimension inheritance

A variant with no dimensions, or an empty dimensions object, inherits Product.base_dimensions. A variant with its own dimensions keeps them.

Stock seeding immutability

ProductVariant.stock_initial seeds InventoryLevel.stock_on_hand once at creation and is preserved in InventoryLevel.stock_initial. Later changes to the variant field do not touch live stock; adjust inventory through the InventoryLevel API. This prevents accidental inventory overwrites from product updates or webhook syncs.

Store availability seam

Per-store purchasability reads live InventoryLevel data through products/availability.py, not ProductVariant.is_in_stock. Zone-level inventory and par levels live in the zones app and coexist with store-scoped InventoryLevel rows.



File References

All model definitions: api/nextango/apps/products/models.py

  • Brand: models.py:9
  • Category: models.py:41
  • ProductType: models.py:83
  • Collection: models.py:125
  • Product: models.py:166
  • ProductVariant: models.py:569
  • Attribute: models.py:1032
  • VerticalBatchProfileRegistration dataclass and _VERTICAL_BATCH_PROFILE_REGISTRATIONS registry: models.py:1110-1127
  • InventoryLevel: models.py:1130
  • InventoryMovement: models.py:1548
  • DailyInventorySnapshot: models.py:1895
  • InventoryAnalyticsLedger: models.py:1948

Was this page helpful?