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.

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
nameStringWritableBrand name
is_shownBooleanWritableShow in filters
slugSlugWritableURL-friendly identifier
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast update timestamp

ProductTypeSerializer

Exposes: ProductType Purpose: Product type serialization with its parent category relationship.

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
nameStringWritableProduct type name
slugSlugWritableURL-friendly identifier
categoryFK (UUID)WritableParent category reference
is_shownBooleanWritableShow in filters
descriptionTextWritableType description
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast update timestamp

CategorySerializer

Exposes: Category Purpose: Category serialization with its collections JSONField.

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
nameStringWritableCategory name
slugSlugWritableURL-friendly identifier
is_shownBooleanWritableShow in filters
descriptionTextWritableCategory description
collectionsJSONWritableArray of collection references
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast update timestamp

CollectionSerializer

Exposes: Collection Purpose: Collection serialization with associated product types.

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
nameStringWritableCollection name
is_shownBooleanWritableShow in filters
slugSlugWritableURL-friendly identifier
descriptionTextWritableCollection description
associated_product_typesJSONWritableArray of product type references
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast 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

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity variant ID
productFK (UUID)WritableParent product reference
product_nameStringRead-onlyProduct name (from relationship)
product_slugStringRead-onlyProduct slug (from relationship)
product_organizationJSONRead-onlyParent product organization refs
product_short_descriptionStringRead-onlyParent product short description
product_hiddenBooleanRead-onlyParent product hidden flag
nameStringWritableVariant name
skuStringWritableStock Keeping Unit (unique)
upcStringWritableOptional barcode for POS scanning
featuredBooleanWritableFeatured variant flag
on_saleBooleanWritableOn sale flag
priceDecimalWritableRegular price
sale_priceDecimalWritableSale price
effective_priceDecimalRead-onlySale price when on sale, otherwise regular price
stock_initialIntegerWritableInitial 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_inventoryBooleanWritableUnlimited-stock sentinel for digital goods
is_in_stockBooleanRead-onlyGlobal stock check from the stock_initial seed
attributesJSONWritableVariant attributes array
dimensionsJSONWritableVariant dimensions
imagesJSONWritableVariant images array
store_locationJSONWritableSanity store reference array
store_specific_productBooleanWritableStore-specific availability
custom_inventory_managementBooleanWritableCustom inventory flag
reorder_pointIntegerWritableReorder threshold
reorder_quantityIntegerWritableReorder quantity
max_stock_levelIntegerWritableMaximum stock level
cannabis_complianceJSONWritableVertical-specific compliance slot
base_promo_codeStringRead-onlyVariant-level promo code override
effective_promo_code_idStringRead-onlyTwo-tier resolution: variant override, then product default
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast update timestamp

Store-Scoped Inventory Fields

All read-only, resolved from the InventoryLevel row that matches the request's store context.

FieldTypeDescription
stock_statusStringInventoryLevel stock status. Without a store-level record, falls back to in_stock or out_of_stock from is_in_stock
store_inventoryIntegerstock_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_availableIntegerInventoryLevel.stock_available for the store, computed as max(0, stock_on_hand - stock_reserved). Same fallback behavior as store_inventory
stock_reservedIntegerReserved units for the store. 0 without a store-level record
store_specific_priceDecimalStore 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.

FieldTypeDescription
low_stockBooleanTrue when available quantity at the store is between 1 and 10
units_remainingInteger or nullThe remaining count only when at or below the threshold. Null above it, so the exact per-store level is never published
max_selectableIntegerCart-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

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
hiddenBooleanWritableHide from storefront
nameStringWritableProduct name
slugSlugWritableURL-friendly identifier
short_descriptionTextWritableShort description
bodyJSONWritableRich text (Portable Text)
highlightsJSONWritableArray of highlights
base_dimensionsJSONWritableBase dimensions object (width, height, depth, weight only, numeric values)
product_notesTextWritableInternal notes
organizationJSONWritableSanity refs for productType, brand, collections, tags
base_promo_codeStringWritableProduct-level promo code reference
seoJSONWritableSEO data object
variantsJSONWritableVariants array in the exact Sanity structure (camelCase keys)
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast update timestamp

Computed Fields

All read-only.

FieldTypeDescription
variant_recordsArrayNested ProductVariantSerializer (many), the Django model records for the product's variants
variant_countIntegerCount of entries in the variants JSONField
total_inventoryIntegerLive sum of InventoryLevel.stock_on_hand across the product's active, non-digital variants. This reads live stock, not the stock_initial snapshot
price_rangeObjectMin and max price from the variants JSON, plus min and max salePrice for variants that are on sale
featured_variants_countIntegerCount of featured variants in the JSON
organization_resolvedObjectThe 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.

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
nameStringWritableAttribute name
is_shownBooleanWritableShow in filters
display_orderIntegerWritableFilter display order
featuredBooleanWritableFeatured filter flag
attribute_typeJSONWritableAttribute type array
type_nameStringRead-onlyType name from attributeType
optionsJSONRead-onlyOptions or values from attributeType
option_countIntegerRead-onlyCount of options
created_atDateTimeRead-onlyCreation timestamp
updated_atDateTimeRead-onlyLast 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.

FieldTypeRequiredDescription
qStringNoSearch query
categoryStringNoCategory or type filter
brandStringNoBrand filter
min_priceDecimalNoMinimum price
max_priceDecimalNoMaximum price
in_stockBooleanNoInventory filter
featuredBooleanNoFeatured products only
store_idStringNoStore-specific filter
pageIntegerNoPage number (default: 1)
page_sizeIntegerNoResults per page (default: 20, max: 100)

ProductDocumentSerializer

Purpose: Shapes Elasticsearch hits from ProductDocument for search responses. Serializes ES documents, not model instances.

FieldTypeDescription
idStringElasticsearch document ID
nameStringProduct name
slugStringURL-friendly identifier
short_descriptionStringReads the ES shortDescription field
hiddenBooleanHidden flag
variantsJSONVariants array from the index
price_rangeObjectMin, max, and sale price bounds from aggregated index data
has_inventoryBooleanInventory availability
total_inventoryIntegerTotal 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

FieldTypeAccessDescription
idUUIDRead-onlyDjango primary key
sanity_idStringRead-onlySanity CMS document ID
productFKWritableProduct reference
product_nameStringRead-onlyProduct name (from relationship)
variant_skuStringWritableVariant SKU
variant_nameStringWritableVariant display name
storeFKWritableStore reference
store_nameStringRead-onlyStore name (from relationship)
store_specific_productBooleanWritableStore-specific flag
store_locationArrayRead-onlyList of _ref IDs unwrapped from the Sanity reference array. Each _ref holds store.sanity_id (the Sanity document ID), written by InventoryLevel.save()
is_activeBooleanWritableActive tracking flag

Stock Fields

FieldTypeAccessDescription
stock_initialIntegerRead-onlyImmutable snapshot of the variant's initial stock
stock_on_handIntegerWritableLive on-hand quantity
stock_reservedIntegerWritableUnits reserved during the cart and checkout window
stock_lifetime_receivedIntegerRead-onlyCumulative received units, Django-populated
available_quantityIntegerRead-onlyComputed as stock_on_hand - stock_reserved

Costing Fields

FieldTypeAccessDescription
on_saleBooleanWritableOn sale flag
sale_priceDecimalWritableStore-level sale price
effective_sale_priceDecimalRead-onlysale_price when on sale, otherwise null
cost_per_unitDecimalWritableCost per unit
standard_costDecimalWritableStandard cost
cost_methodStringWritableCost method (standard, average, fifo)
current_costDecimalRead-onlyCurrent cost based on method
average_costDecimalRead-onlyCalculated average cost
last_purchase_costDecimalRead-onlyLast purchase cost
last_transaction_costDecimalRead-onlyLast transaction cost
last_transaction_dateDateTimeRead-onlyLast transaction date

Inventory Settings

FieldTypeAccessDescription
reorder_pointIntegerWritableReorder threshold
reorder_quantityIntegerWritableQuantity to reorder
max_stock_levelIntegerWritableMaximum stock level
stock_statusStringRead-onlyout_of_stock, low_stock, overstocked, or in_stock, derived from stock_on_hand against the reorder and max-stock settings
needs_reorderBooleanRead-onlyTrue when stock_on_hand is at or below reorder_point
is_overstockedBooleanRead-onlyTrue 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

  • 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

Was this page helpful?