Products - Serializers Documentation
Location: api/nextango/apps/products/serializers.py
Last Updated: 2026-07-06
Overview
The products serializers connect the products app models to the REST API and handle inbound Sanity webhook payloads. Catalog serializers (Brand, ProductType, Category, Collection, Attribute) mirror their Sanity schemas field for field. ProductSerializer and InventoryLevelSerializer accept Sanity webhook data, converting camelCase keys to snake_case model fields and unwrapping reference objects to Django records. ProductVariantSerializer resolves store-scoped inventory from InventoryLevel and exposes bounded quantity signals for the storefront.
Serializers
BrandSerializer
Exposes: Brand
Purpose: Brand serialization with exact Sanity schema matching for bidirectional sync.
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
name | String | Writable | Brand name |
is_shown | Boolean | Writable | Show in filters |
slug | Slug | Writable | URL-friendly identifier |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
ProductTypeSerializer
Exposes: ProductType
Purpose: Product type serialization with its parent category relationship.
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
name | String | Writable | Product type name |
slug | Slug | Writable | URL-friendly identifier |
category | FK (UUID) | Writable | Parent category reference |
is_shown | Boolean | Writable | Show in filters |
description | Text | Writable | Type description |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
CategorySerializer
Exposes: Category
Purpose: Category serialization with its collections JSONField.
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
name | String | Writable | Category name |
slug | Slug | Writable | URL-friendly identifier |
is_shown | Boolean | Writable | Show in filters |
description | Text | Writable | Category description |
collections | JSON | Writable | Array of collection references |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
CollectionSerializer
Exposes: Collection
Purpose: Collection serialization with associated product types.
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
name | String | Writable | Collection name |
is_shown | Boolean | Writable | Show in filters |
slug | Slug | Writable | URL-friendly identifier |
description | Text | Writable | Collection description |
associated_product_types | JSON | Writable | Array of product type references |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
ProductVariantSerializer
Exposes: ProductVariant
Purpose: Variant serialization with store-scoped inventory resolved from InventoryLevel. Includes parent product fields so the frontend avoids N+1 requests.
Base Fields
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity variant ID |
product | FK (UUID) | Writable | Parent product reference |
product_name | String | Read-only | Product name (from relationship) |
product_slug | String | Read-only | Product slug (from relationship) |
product_organization | JSON | Read-only | Parent product organization refs |
product_short_description | String | Read-only | Parent product short description |
product_hidden | Boolean | Read-only | Parent product hidden flag |
name | String | Writable | Variant name |
sku | String | Writable | Stock Keeping Unit (unique) |
upc | String | Writable | Optional barcode for POS scanning |
featured | Boolean | Writable | Featured variant flag |
on_sale | Boolean | Writable | On sale flag |
price | Decimal | Writable | Regular price |
sale_price | Decimal | Writable | Sale price |
effective_price | Decimal | Read-only | Sale price when on sale, otherwise regular price |
stock_initial | Integer | Writable | Initial stock seed. Changes after creation do not propagate to InventoryLevel; the API logs a warning and accepts the value. Adjust live stock through the InventoryLevel endpoints |
digital_inventory | Boolean | Writable | Unlimited-stock sentinel for digital goods |
is_in_stock | Boolean | Read-only | Global stock check from the stock_initial seed |
attributes | JSON | Writable | Variant attributes array |
dimensions | JSON | Writable | Variant dimensions |
images | JSON | Writable | Variant images array |
store_location | JSON | Writable | Sanity store reference array |
store_specific_product | Boolean | Writable | Store-specific availability |
custom_inventory_management | Boolean | Writable | Custom inventory flag |
reorder_point | Integer | Writable | Reorder threshold |
reorder_quantity | Integer | Writable | Reorder quantity |
max_stock_level | Integer | Writable | Maximum stock level |
cannabis_compliance | JSON | Writable | Vertical-specific compliance slot |
base_promo_code | String | Read-only | Variant-level promo code override |
effective_promo_code_id | String | Read-only | Two-tier resolution: variant override, then product default |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
Store-Scoped Inventory Fields
All read-only, resolved from the InventoryLevel row that matches the request's store context.
| Field | Type | Description |
|---|---|---|
stock_status | String | InventoryLevel stock status. Without a store-level record, falls back to in_stock or out_of_stock from is_in_stock |
store_inventory | Integer | stock_on_hand for the resolved store. Without a store-level record, digital variants report 999999 (the API convention for unlimited) and physical variants fall back to stock_initial |
stock_available | Integer | InventoryLevel.stock_available for the store, computed as max(0, stock_on_hand - stock_reserved). Same fallback behavior as store_inventory |
stock_reserved | Integer | Reserved units for the store. 0 without a store-level record |
store_specific_price | Decimal | Store sale price when the level is on sale, otherwise the level's cost_per_unit, otherwise the variant's effective_price |
Bounded Quantity Signals
The storefront receives bounded signals instead of raw per-store stock levels. The low-stock threshold and the cart-selector cap are both 10.
| Field | Type | Description |
|---|---|---|
low_stock | Boolean | True when available quantity at the store is between 1 and 10 |
units_remaining | Integer or null | The remaining count only when at or below the threshold. Null above it, so the exact per-store level is never published |
max_selectable | Integer | Cart-selector ceiling: the lesser of available quantity and the hard cap of 10 |
Store Context
The serializer resolves the store from the store_id query parameter first, then from the authenticated user's assigned store. Without a store context it uses the first inventory row available for the variant. Inventory rows come from data the viewset prefetches; the serializer queries directly only when no prefetched data exists.
Validation
The API rejects a SKU that already exists on another variant (the database unique constraint on sku backs this check), a sale_price greater than or equal to price, an attributes value that is not an array of objects with attribute and value keys, and a store_location value that is not an array of Sanity reference objects (_ref plus _type: "reference").
ProductSerializer
Exposes: Product
Purpose: Product serialization with the dual variant structure: the variants JSONField for exact Sanity sync compliance, and nested variant_records for Django relational access. Accepts Sanity webhook payloads directly.
Base Fields
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
hidden | Boolean | Writable | Hide from storefront |
name | String | Writable | Product name |
slug | Slug | Writable | URL-friendly identifier |
short_description | Text | Writable | Short description |
body | JSON | Writable | Rich text (Portable Text) |
highlights | JSON | Writable | Array of highlights |
base_dimensions | JSON | Writable | Base dimensions object (width, height, depth, weight only, numeric values) |
product_notes | Text | Writable | Internal notes |
organization | JSON | Writable | Sanity refs for productType, brand, collections, tags |
base_promo_code | String | Writable | Product-level promo code reference |
seo | JSON | Writable | SEO data object |
variants | JSON | Writable | Variants array in the exact Sanity structure (camelCase keys) |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
Computed Fields
All read-only.
| Field | Type | Description |
|---|---|---|
variant_records | Array | Nested ProductVariantSerializer (many), the Django model records for the product's variants |
variant_count | Integer | Count of entries in the variants JSONField |
total_inventory | Integer | Live sum of InventoryLevel.stock_on_hand across the product's active, non-digital variants. This reads live stock, not the stock_initial snapshot |
price_range | Object | Min and max price from the variants JSON, plus min and max salePrice for variants that are on sale |
featured_variants_count | Integer | Count of featured variants in the JSON |
organization_resolved | Object | The organization refs resolved to {id, name, slug} objects for productType, brand, and collections. Refs with no matching Django record pass through unchanged; tags pass through unchanged. The product list endpoint injects lookup maps into the serializer context so resolution adds no per-product queries |
Sanity Webhook Handling
The serializer accepts raw Sanity payloads. CamelCase keys (shortDescription, baseDimensions, productNotes, basePromoCode) map to their snake_case model fields, slug objects ({"_type": "slug", "current": "value"}) unwrap to the string value, and basePromoCode reference objects unwrap to the _ref ID. Nested baseDimensions keys convert recursively, with Sanity metadata keys (leading underscore) dropped.
Variants JSON Validation
Each entry in the variants array must carry name, sku, price, stockInitial, and digitalInventory. SKUs must be unique within the array, salePrice must be less than price, stockInitial must be an integer, and digitalInventory must be a boolean. attributes and storeLocation entries, when present, must follow the same structures the variant serializer validates.
Save Behavior
Saving a product with a variants payload syncs the JSON to ProductVariant model records: always on create, and on update whenever variants is part of the validated data.
AttributeSerializer
Exposes: Attribute
Purpose: Attribute serialization with the Sanity attributeType structure.
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
name | String | Writable | Attribute name |
is_shown | Boolean | Writable | Show in filters |
display_order | Integer | Writable | Filter display order |
featured | Boolean | Writable | Featured filter flag |
attribute_type | JSON | Writable | Attribute type array |
type_name | String | Read-only | Type name from attributeType |
options | JSON | Read-only | Options or values from attributeType |
option_count | Integer | Read-only | Count of options |
created_at | DateTime | Read-only | Creation timestamp |
updated_at | DateTime | Read-only | Last update timestamp |
attribute_type must be an array with exactly one element, and that element's _type must be attributeTextList (with an options array of strings) or attributeColorSwatch (with a colors array of objects, each carrying a name and a color object with a hex value).
ProductSearchSerializer
Purpose: Validates search request parameters for the product search endpoint. Not bound to a model.
| Field | Type | Required | Description |
|---|---|---|---|
q | String | No | Search query |
category | String | No | Category or type filter |
brand | String | No | Brand filter |
min_price | Decimal | No | Minimum price |
max_price | Decimal | No | Maximum price |
in_stock | Boolean | No | Inventory filter |
featured | Boolean | No | Featured products only |
store_id | String | No | Store-specific filter |
page | Integer | No | Page number (default: 1) |
page_size | Integer | No | Results per page (default: 20, max: 100) |
ProductDocumentSerializer
Purpose: Shapes Elasticsearch hits from ProductDocument for search responses. Serializes ES documents, not model instances.
| Field | Type | Description |
|---|---|---|
id | String | Elasticsearch document ID |
name | String | Product name |
slug | String | URL-friendly identifier |
short_description | String | Reads the ES shortDescription field |
hidden | Boolean | Hidden flag |
variants | JSON | Variants array from the index |
price_range | Object | Min, max, and sale price bounds from aggregated index data |
has_inventory | Boolean | Inventory availability |
total_inventory | Integer | Total inventory from the index |
InventoryLevelSerializer
Exposes: InventoryLevel
Purpose: Store-scoped inventory serialization covering stock quantities, costing, reorder settings, and analytics. Accepts Sanity webhook payloads.
Identification Fields
| Field | Type | Access | Description |
|---|---|---|---|
id | UUID | Read-only | Django primary key |
sanity_id | String | Read-only | Sanity CMS document ID |
product | FK | Writable | Product reference |
product_name | String | Read-only | Product name (from relationship) |
variant_sku | String | Writable | Variant SKU |
variant_name | String | Writable | Variant display name |
store | FK | Writable | Store reference |
store_name | String | Read-only | Store name (from relationship) |
store_specific_product | Boolean | Writable | Store-specific flag |
store_location | Array | Read-only | List of _ref IDs unwrapped from the Sanity reference array. Each _ref holds store.sanity_id (the Sanity document ID), written by InventoryLevel.save() |
is_active | Boolean | Writable | Active tracking flag |
Stock Fields
| Field | Type | Access | Description |
|---|---|---|---|
stock_initial | Integer | Read-only | Immutable snapshot of the variant's initial stock |
stock_on_hand | Integer | Writable | Live on-hand quantity |
stock_reserved | Integer | Writable | Units reserved during the cart and checkout window |
stock_lifetime_received | Integer | Read-only | Cumulative received units, Django-populated |
available_quantity | Integer | Read-only | Computed as stock_on_hand - stock_reserved |
Costing Fields
| Field | Type | Access | Description |
|---|---|---|---|
on_sale | Boolean | Writable | On sale flag |
sale_price | Decimal | Writable | Store-level sale price |
effective_sale_price | Decimal | Read-only | sale_price when on sale, otherwise null |
cost_per_unit | Decimal | Writable | Cost per unit |
standard_cost | Decimal | Writable | Standard cost |
cost_method | String | Writable | Cost method (standard, average, fifo) |
current_cost | Decimal | Read-only | Current cost based on method |
average_cost | Decimal | Read-only | Calculated average cost |
last_purchase_cost | Decimal | Read-only | Last purchase cost |
last_transaction_cost | Decimal | Read-only | Last transaction cost |
last_transaction_date | DateTime | Read-only | Last transaction date |
Inventory Settings
| Field | Type | Access | Description |
|---|---|---|---|
reorder_point | Integer | Writable | Reorder threshold |
reorder_quantity | Integer | Writable | Quantity to reorder |
max_stock_level | Integer | Writable | Maximum stock level |
stock_status | String | Read-only | out_of_stock, low_stock, overstocked, or in_stock, derived from stock_on_hand against the reorder and max-stock settings |
needs_reorder | Boolean | Read-only | True when stock_on_hand is at or below reorder_point |
is_overstocked | Boolean | Read-only | True when stock_on_hand exceeds max_stock_level |
Analytics and System Fields
All read-only: total_transaction_count, total_revenue, created_at_sanity, last_cost_update, last_synced_at, last_updated, created_at, updated_at.
Validation
The API rejects negative stock_on_hand, stock_reserved, reorder_point, or reorder_quantity values, a standard_cost or max_stock_level that is not positive, and a malformed store_location reference array. Cross-field rules: stock_reserved cannot exceed stock_on_hand, reorder_point cannot exceed max_stock_level, store_specific_product requires store_location entries, and on_sale requires a sale_price.
Sanity Webhook Handling
Inbound payloads convert camelCase keys to snake_case through the shared bidirectional field mapper, with Sanity metadata keys (_id, _type, _rev, _createdAt, _updatedAt) dropped. The Sanity createdAt value maps to created_at_sanity (a per-serializer override; the generic mapper would target the wrong field). product and store reference objects resolve to Django records by sanity_id.
Usage Examples
Creating a Product
from apps.products.serializers import ProductSerializer
data = {
'name': 'Blue T-Shirt',
'slug': 'blue-t-shirt',
'short_description': 'Comfortable cotton t-shirt',
'variants': [
{
'name': 'Small',
'sku': 'TSHIRT-SM-BLU',
'price': 29.99,
'stockInitial': 50,
'digitalInventory': False
}
]
}
serializer = ProductSerializer(data=data)
if serializer.is_valid():
product = serializer.save()
# Variants JSON syncs to ProductVariant records on save
else:
print(serializer.errors)
Store-Specific Variant Lookup
from apps.products.serializers import ProductVariantSerializer
# Request carries ?store_id=<uuid>
variant = ProductVariant.objects.prefetch_related('inventory_levels').get(sku='TSHIRT-SM-BLU')
serializer = ProductVariantSerializer(variant, context={'request': request})
data = serializer.data
print(f"Store inventory: {data['store_inventory']}")
print(f"Available: {data['stock_available']}")
print(f"Reserved: {data['stock_reserved']}")
print(f"Low stock: {data['low_stock']}, max selectable: {data['max_selectable']}")
Inventory Level Update
from apps.products.serializers import InventoryLevelSerializer
inv_level = InventoryLevel.objects.get(variant_sku='TSHIRT-SM-BLU', store=store)
serializer = InventoryLevelSerializer(inv_level, data={'stock_on_hand': 75}, partial=True)
if serializer.is_valid():
updated = serializer.save()
print(f"Updated inventory: {updated.stock_on_hand}")
else:
print(serializer.errors)
Sanity Webhook Processing
from apps.products.serializers import ProductSerializer
# Webhook payload from Sanity
webhook_data = {
'name': 'Product Name',
'slug': {'current': 'product-name'},
'shortDescription': 'Description text',
'baseDimensions': {'width': 10, 'height': 5},
'variants': [
{
'name': 'Default',
'sku': 'PROD-001',
'price': 19.99,
'stockInitial': 25,
'digitalInventory': False
}
]
}
serializer = ProductSerializer(data=webhook_data)
if serializer.is_valid():
product = serializer.save()
# camelCase keys convert to snake_case, the slug unwraps,
# and variants sync to ProductVariant records
Related Documentation
- Models: product model definitions
- Views: API endpoints using these serializers
- Services: business logic layer
- Signals: automatic syncing
- Search: the Elasticsearch document these search serializers shape
File Reference
All serializers: api/nextango/apps/products/serializers.py
- BrandSerializer: serializers.py:7-19
- ProductTypeSerializer: serializers.py:22-34
- CategorySerializer: serializers.py:37-49
- CollectionSerializer: serializers.py:52-64
- ProductVariantSerializer: serializers.py:73-340
- ProductSerializer: serializers.py:343-806
- AttributeSerializer: serializers.py:809-903
- ProductSearchSerializer: serializers.py:906-917
- ProductDocumentSerializer: serializers.py:920-947
- InventoryLevelSerializer: serializers.py:950-1179