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.
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Brand name |
is_shown | BooleanField | Show in filters (default False) |
slug | SlugField(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.
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Category name |
slug | SlugField(96) | URL-friendly identifier, unique, indexed |
is_shown | BooleanField | Show in filters (default False) |
description | TextField | Category description, optional |
collections | JSONField | Array of Sanity collection references [{"_ref": "...", "_type": "reference"}] |
Exposed by CategorySerializer (Serializers).
ProductType
Base class: SanityIntegratedModel, AuditableMixin
Purpose: Product type classification within a category.
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Product type name |
slug | SlugField(96) | URL-friendly identifier, unique, indexed |
category | ForeignKey(Category) | Parent category, CASCADE, reverse accessor product_types |
is_shown | BooleanField | Show in filters (default False) |
description | TextField | Product type description, optional |
Exposed by ProductTypeSerializer (Serializers).
Collection
Base class: SanityIntegratedModel, AuditableMixin
Purpose: Product collection for merchandising.
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Collection name |
is_shown | BooleanField | Show in filters (default False) |
slug | SlugField(96) | URL-friendly identifier, unique, indexed |
description | TextField | Collection description, optional |
associated_product_types | JSONField | Array 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.
| Field | Type | Description |
|---|---|---|
hidden | BooleanField | Hide product from storefront (default False) |
name | CharField(255) | Product name, indexed |
slug | SlugField(96) | URL-friendly identifier, unique, indexed |
short_description | TextField | Short product description, optional |
body | JSONField | Rich text body (Portable Text) |
highlights | JSONField | Array of highlight strings |
base_dimensions | JSONField | Base dimensions {width, height, depth, weight} |
product_notes | TextField | Internal team notes |
organization | JSONField | Organization object {productType, brand, collections, tags} from Sanity |
brand_sanity_id | CharField(255) | Denormalized organization.brand._ref, indexed; maintained on save, source of truth stays in organization |
product_type_sanity_id | CharField(255) | Denormalized organization.productType._ref, indexed; maintained on save |
base_promo_code | CharField(255) | Sanity _id of the product-level default PromoCode, optional |
seo | JSONField | SEO object {title, description} |
variants | JSONField | Array 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().
| Field | Type | Description |
|---|---|---|
product | ForeignKey(Product) | Parent product, CASCADE, reverse accessor variant_records |
name | CharField(255) | Variant display name |
sku | CharField(100) | Stock Keeping Unit, unique, indexed |
upc | CharField(14) | Barcode for POS scanning, optional, indexed; populated values must be unique (partial unique constraint, NULL and empty unconstrained) |
sanity_variant_key | CharField(255) | Sanity _key within product.variants, indexed |
featured | BooleanField | Featured variant flag (default False) |
on_sale | BooleanField | On-sale flag (default False) |
price | DecimalField(10,2) | Regular price |
sale_price | DecimalField(10,2) | Discounted price when on sale, optional |
base_promo_code | CharField(255) | Sanity _id of a variant-level PromoCode override; falls back to Product.base_promo_code when NULL |
store_location | JSONField | Array of Sanity store references, GIN-indexed |
store_specific_product | BooleanField | Whether inventory levels are created per assigned store only |
stock_initial | IntegerField | Initial quantity that seeds InventoryLevel records, immutable after creation (default 0) |
digital_inventory | BooleanField | Unlimited digital inventory sentinel, no stock tracking (default False) |
custom_inventory_management | BooleanField | Enables reorder points and quantities (default False) |
reorder_point | IntegerField | Reorder threshold, optional |
reorder_quantity | IntegerField | Quantity to order when restocking, optional |
max_stock_level | IntegerField | Maximum stock to keep, optional |
attributes | JSONField | Variant attributes [{"attribute": {"_ref": "..."}, "value": "..."}]; attribute filtering goes through the Elasticsearch path, not DB joins |
dimensions | JSONField | Variant dimensions; inherits Product.base_dimensions when empty |
images | JSONField | Array of variant image URLs |
cannabis_compliance | JSONField | Full 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.
| Field | Type | Description |
|---|---|---|
name | CharField(255) | Attribute set name (e.g. "Color", "Material") |
is_shown | BooleanField | Show in filters (default False) |
display_order | IntegerField | Filter UI ordering, optional |
featured | BooleanField | Featured filter flag (default False) |
attribute_type | JSONField | Array 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
| Field | Type | Description |
|---|---|---|
product | ForeignKey(Product) | Product reference, CASCADE, reverse accessor inventory_levels |
variant | ForeignKey(ProductVariant) | Variant reference for indexed joins, CASCADE, nullable |
variant_sku | CharField(100) | Variant SKU, indexed; kept alongside the FK for Sanity sync compatibility |
variant_name | CharField(255) | Variant display name, optional |
store | ForeignKey(stores.Store) | Store reference, CASCADE |
store_specific_product | BooleanField | Set True on save for every row backed by a store |
store_location | JSONField | Sanity store reference; _ref is store.sanity_id. Save raises ValueError when the store has no sanity_id |
is_active | BooleanField | Active 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.
| Field | Type | Description |
|---|---|---|
stock_initial | IntegerField | Snapshot of ProductVariant.stock_initial at row creation. Never updated; diagnostic reference only |
stock_on_hand | IntegerField | Live stock counter, indexed. Decremented by sale commits, incremented by voids, returns, and procurement receives, adjusted by admin actions |
stock_reserved | IntegerField | Reservations held during the cart-checkout-payment window; released on completion or void |
stock_lifetime_received | IntegerField | Cumulative receipts. Initialized at row creation, incremented only by procurement on stock receive, never decremented by sales |
stock_available | property | Computed 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
| Field | Type | Description |
|---|---|---|
on_sale | BooleanField | On-sale flag |
sale_price | DecimalField(10,2) | Discounted price when on sale, optional |
cost_per_unit | DecimalField(10,2) | Cost per unit, optional |
standard_cost | DecimalField(10,2) | Standard cost, optional |
cost_method | CharField(20) | standard, average, or fifo (default standard) |
current_cost | DecimalField(10,2) | Current cost under the selected method, optional |
average_cost | DecimalField(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_cost | DecimalField(10,2) | Cost from the last purchase, optional |
last_transaction_cost | DecimalField(10,2) | Cost from the last transaction, optional |
last_transaction_date | DateTimeField | Timestamp of the last transaction, optional |
Settings and analytics fields
| Field | Type | Description |
|---|---|---|
reorder_point | IntegerField | Reorder threshold (default 0) |
reorder_quantity | IntegerField | Quantity to reorder (default 0) |
max_stock_level | IntegerField | Maximum stock threshold, optional |
total_transaction_count | IntegerField | Transactions recorded against this row |
total_revenue | DecimalField(15,2) | Revenue recorded against this row |
created_at_sanity | DateTimeField | Creation timestamp from Sanity, optional |
last_cost_update | DateTimeField | Last cost update, optional |
django_id | CharField(50) | Django ID stored in Sanity, optional |
last_synced_at | DateTimeField | Last sync timestamp, optional |
last_updated | DateTimeField | Auto-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.
| Field | Type | Description |
|---|---|---|
movement_id | CharField(255) | Movement identifier, unique, indexed |
product | ForeignKey(Product) | Product reference, CASCADE |
variant_sku | CharField(100) | SKU of the variant being moved, indexed |
quantity | IntegerField | Positive for incoming, negative for outgoing |
movement_type | CharField(20) | One of receiving, sale, return, adjustment, transfer, commit, void, restore |
from_location | ForeignKey(stores.Store) | Source store, SET_NULL, optional |
to_location | ForeignKey(stores.Store) | Destination store, SET_NULL, optional |
employee | ForeignKey(users.User) | Employee reference, SET_NULL, optional |
related_transaction | ForeignKey(transactions.SaleTransaction) | Linked sale transaction, SET_NULL, optional |
timestamp | DateTimeField | Movement 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.
| Field | Type | Description |
|---|---|---|
store | ForeignKey(stores.Store) | Store, CASCADE, reverse accessor daily_inventory_snapshots |
date | DateField | Calendar date covered; one row per store per day (unique together) |
total_sku_count | PositiveIntegerField | Active InventoryLevel rows for the store |
total_units_on_hand | PositiveIntegerField | Sum of stock_on_hand across active rows |
total_inventory_value | DecimalField(14,2) | Sum of stock_on_hand × current_cost across active rows |
low_stock_count | PositiveIntegerField | Active rows where 0 < stock_on_hand <= reorder_point |
out_of_stock_count | PositiveIntegerField | Active rows with stock_on_hand = 0 |
snapshot_at | DateTimeField | When the rollup last wrote the row |
InventoryAnalyticsLedger
Base class: TimestampedModel
Purpose: Audit-trail row per (sale_transaction, inventory_level) analytics counter increment.
| Field | Type | Description |
|---|---|---|
sale_transaction | ForeignKey(transactions.SaleTransaction) | CASCADE, reverse accessor inventory_analytics_ledger |
inventory_level | ForeignKey(InventoryLevel) | CASCADE, reverse accessor analytics_ledger |
applied_at | DateTimeField | When 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.
ProductInventoryItem exists in the source only as commented-out code preserved for reference. It is not a live model and has no API surface.
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.
Related Documentation
- Serializers - Product serializers and validation
- Views - Product endpoints and filtering
- Services - Product business logic
- Signals - Product sync signals
- Search - Elasticsearch integration
- Examples - Usage examples
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
VerticalBatchProfileRegistrationdataclass and_VERTICAL_BATCH_PROFILE_REGISTRATIONSregistry: models.py:1110-1127- InventoryLevel: models.py:1130
- InventoryMovement: models.py:1548
- DailyInventorySnapshot: models.py:1895
- InventoryAnalyticsLedger: models.py:1948