Products - Usage Examples & Patterns
Location: api/nextango/apps/products/
Last Updated: 2026-07-06
Overview
Working examples for the common developer paths through the products domain: catalog reads, the two public search endpoints, multi-store inventory queries, the inventory mutation workflow, and ORM query patterns.
All catalog and inventory endpoints require authentication. /search/ and /search/smart-filter/ are public. Raw per-store stock numbers (stock_on_hand, stock_reserved, stock_available, store_inventory, total_inventory) reach only privileged callers: users with the employee or manager role, and POS-session principals. Everyone else, including the storefront service principal and anonymous search callers, receives binary availability (is_in_stock, is_available) plus prices. See the Access Model in the views documentation.
Table of Contents
- Catalog Reads
- Public Search
- Multi-Store Inventory Queries
- Inventory Mutation Workflow
- Dashboard Summaries
- ORM Query Patterns
Catalog Reads
Store-Scoped Product List
Use Case: Storefront category page showing products sellable at one store.
GET /products/?store_id=550e8400-e29b-41d4-a716-446655440000&category=7f9c2f4e-1b2a-4c3d-9e8f-001122334455
Authorization: Bearer <token>
{
"count": 42,
"next": "http://api/products/?page=2&store_id=550e8400-e29b-41d4-a716-446655440000",
"previous": null,
"results": [
{
"id": "a1b2c3d4-0000-0000-0000-000000000001",
"name": "Classic T-Shirt",
"slug": "classic-t-shirt",
"short_description": "Heavyweight cotton tee",
"variant_records": [
{
"sku": "SKU-TSHIRT-RED-L",
"name": "Red / Large",
"price": "29.99",
"effective_price": "29.99",
"is_in_stock": true
}
]
}
]
}
With store_id, the list excludes products with no sellable variant at that store, and variant_records contains only variants sellable there. Non-privileged callers do not receive total_inventory, store_inventory, stock_available, or stock_reserved; privileged callers get the full ProductVariantSerializer shape. An unresolvable store_id returns an empty result set, not an error.
Retrieve Product with Store Availability Signal
Use Case: Product detail page that signals unavailability without hiding the product.
GET /products/classic-t-shirt/?store_id=550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>
{
"id": "a1b2c3d4-0000-0000-0000-000000000001",
"name": "Classic T-Shirt",
"slug": "classic-t-shirt",
"variant_records": [
{
"sku": "SKU-TSHIRT-RED-L",
"name": "Red / Large",
"price": "29.99",
"is_in_stock": true
}
],
"available_at_store": true
}
The product is always returned even when nothing is sellable at the store. available_at_store is the signal; when it is false, variant_records is empty.
POS Barcode Lookup
Use Case: A scanner at the register reads a UPC; the terminal resolves it to a variant.
GET /product-variants/?barcode=012345678905
Authorization: Bearer <token>
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": "b2c3d4e5-0000-0000-0000-000000000002",
"sku": "SKU-TSHIRT-RED-L",
"upc": "012345678905",
"name": "Red / Large",
"product_name": "Classic T-Shirt",
"price": "29.99",
"sale_price": "24.99",
"effective_price": "24.99",
"on_sale": true,
"is_in_stock": true
}
]
}
barcode matches exactly on either sku or upc. POS sessions are privileged callers, so they also receive store_inventory, stock_available, and stock_reserved.
Variant Inventory Status
Use Case: Quick stock check from variant-level fields, no per-store quantities.
GET /product-variants/SKU-TSHIRT-RED-L/inventory_status/
Authorization: Bearer <token>
{
"sku": "SKU-TSHIRT-RED-L",
"name": "Red / Large",
"product": "Classic T-Shirt",
"stock_initial": 100,
"digital_inventory": false,
"unlimited_stock": false,
"is_in_stock": true,
"price": "29.99",
"sale_price": "24.99",
"effective_price": "24.99",
"on_sale": true,
"featured": false
}
stock_initial is the immutable creation-time seed, not a live counter. Live quantities live on InventoryLevel and come from the /inventory-levels/ endpoints below.
Public Search
Store-Scoped Full-Text Search
Use Case: Storefront search box. No authentication required.
GET /search/?q=t-shirt&store_id=550e8400-e29b-41d4-a716-446655440000&in_stock=true&page=1&page_size=20
{
"results": [
{
"id": "a1b2c3d4-0000-0000-0000-000000000001",
"name": "Classic T-Shirt",
"slug": "classic-t-shirt",
"short_description": "Heavyweight cotton tee",
"hidden": false,
"variants": [
{
"sku": "SKU-TSHIRT-RED-L",
"name": "Red / Large",
"price": 29.99,
"salePrice": 24.99,
"onSale": true,
"stockInitial": 100,
"inventory_by_store": [
{
"store_id": "550e8400-e29b-41d4-a716-446655440000",
"store_sanity_id": "store-downtown",
"store_name": "Downtown Store",
"is_available": true
}
]
}
],
"price_range": {"min_price": 24.99, "max_price": 29.99},
"has_inventory": true,
"total_inventory": 100
}
],
"total": 8,
"page": 1,
"page_size": 20,
"total_pages": 1,
"took": 12,
"query": "t-shirt",
"filters": {"store_id": "550e8400-e29b-41d4-a716-446655440000", "in_stock": true}
}
The variant shape is the Elasticsearch document (camelCase keys such as stockInitial, the immutable seed). Non-privileged callers get the inventory_by_store shape shown here; staff and POS-session callers additionally receive stock_on_hand and stock_reserved in each entry. Invalid parameters return 400 with field errors.
Smart Filter with Facets and Collapsed Cards
Use Case: Faceted product grid with one card per product. No authentication required.
GET /search/smart-filter/?store_id=550e8400-e29b-41d4-a716-446655440000&collapse_variants=true&attributes[Color][]=Red&sort=price-asc&page=1
{
"products": [
{
"card_kind": "variant",
"id": "a1b2c3d4-0000-0000-0000-000000000001_SKU-TSHIRT-RED-L",
"product_name": "Classic T-Shirt",
"product_slug": "classic-t-shirt",
"product_short_description": "Heavyweight cotton tee",
"name": "Red / Large",
"sku": "SKU-TSHIRT-RED-L",
"price": 29.99,
"sale_price": 24.99,
"effective_price": 24.99,
"on_sale": true,
"is_in_stock": true,
"featured": false,
"store_name": "Downtown Store",
"image_urls": []
},
{
"card_kind": "product",
"id": "a1b2c3d4-0000-0000-0000-000000000007",
"product_name": "Hoodie",
"product_slug": "hoodie",
"product_short_description": "Fleece-lined hoodie",
"matched_variant_count": 3,
"price_min": 49.99,
"price_max": 59.99,
"on_sale": false,
"featured": true,
"is_in_stock": true,
"store_name": "Downtown Store",
"image_urls": []
}
],
"total": 2,
"total_variants": 2,
"aggregations": {
"brands": [{"_id": "brand-uuid", "name": "Acme", "count": 2}],
"categories": [{"_id": "category-uuid", "name": "Apparel", "count": 2}],
"productTypes": [{"_id": "type-uuid", "name": "Tops", "count": 2}],
"collections": [],
"attributes": {
"featured": [{"name": "Color", "type": "attributeColorSwatch", "colors": [{"name": "Red", "hex": "#FF0000", "count": 2, "available": true}]}],
"regular": [{"name": "Size", "type": "attributeTextList", "options": [{"name": "Large", "count": 2, "available": true}]}]
}
},
"page": 1,
"page_size": 20,
"filters": {"attributes": {"Color": ["Red"]}, "sort": "price-asc", "store_id": "550e8400-e29b-41d4-a716-446655440000", "collapse": true}
}
collapse_variants=true collapses each product to a single card: card_kind: "variant" when exactly one variant matched the filters, card_kind: "product" with matched_variant_count and a price_min/price_max range when several did. Without collapse_variants (the carousel path), every matched variant is its own row and rows carry no card_kind.
Browsing requests (no search text, store_id present, and no brand, category, productType, collection, attributes, on_sale, or low_stock filter active) serve aggregations from a pre-generated per-store manifest when one exists; availability flags are recalculated against current store inventory either way. Any of those filters falls back to real-time aggregations. Staff and POS-session callers additionally receive stock_available on each row; everyone else keeps is_in_stock and store_name only. See Smart Filter Service.
Multi-Store Inventory Queries
Get Inventory for a Variant Across All Stores
Use Case: Dashboard product detail dialog showing inventory by store.
GET /inventory-levels/by_product/?variant_sku=SKU-TSHIRT-RED-L
Authorization: Bearer <token>
{
"product_id": null,
"variant_sku": "SKU-TSHIRT-RED-L",
"stores_count": 2,
"total_stock_on_hand": 250,
"total_stock_reserved": 25,
"total_available": 225,
"inventory_levels": [
{
"id": "c3d4e5f6-0000-0000-0000-000000000003",
"variant_sku": "SKU-TSHIRT-RED-L",
"variant_name": "Red / Large",
"store_name": "Downtown Store",
"stock_initial": 100,
"stock_on_hand": 100,
"stock_reserved": 10,
"stock_lifetime_received": 150,
"available_quantity": 90,
"reorder_point": 10,
"stock_status": "in_stock"
},
{
"id": "d4e5f6a7-0000-0000-0000-000000000004",
"variant_sku": "SKU-TSHIRT-RED-L",
"variant_name": "Red / Large",
"store_name": "Mall Store",
"stock_initial": 100,
"stock_on_hand": 150,
"stock_reserved": 15,
"stock_lifetime_received": 200,
"available_quantity": 135,
"reorder_point": 10,
"stock_status": "in_stock"
}
]
}
Each entry is the full InventoryLevelSerializer shape (costing, reorder settings, and analytics fields omitted here for brevity, but present in real responses). At least one of product_id or variant_sku is required; otherwise the endpoint returns 400.
Get All Inventory for a Single Store
Use Case: Store manager dashboard showing every tracked item at their location.
GET /inventory-levels/by_store/?store_id=550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>
{
"store_id": "550e8400-e29b-41d4-a716-446655440000",
"total_items": 1234,
"total_stock_on_hand": 5678,
"total_stock_reserved": 123,
"total_available": 5555,
"inventory_levels": [
{
"id": "c3d4e5f6-0000-0000-0000-000000000003",
"variant_sku": "SKU-TSHIRT-RED-L",
"variant_name": "Red / Large",
"stock_on_hand": 100,
"stock_reserved": 10,
"available_quantity": 90,
"reorder_point": 10,
"stock_status": "in_stock"
}
]
}
store_id is required (400 without it) and must be the store's Django UUID: this action re-filters on the raw parameter, so a Sanity ID fails with a server error here. The Sanity-ID dual shape works on the list endpoint (GET /inventory-levels/?store_id=) only. The role-aware store gate applies: staff callers with non-empty store assignments must be assigned to the target store.
Low Stock Alerts
Use Case: Reorder report for the purchasing team.
GET /inventory-levels/low_stock_alerts/?threshold=10
Authorization: Bearer <token>
{
"threshold": 10,
"low_stock_count": 23,
"low_stock_items": [
{
"id": "e5f6a7b8-0000-0000-0000-000000000005",
"product_name": "Classic T-Shirt",
"variant_sku": "SKU-TSHIRT-RED-L",
"variant_name": "Red / Large",
"store_name": "Downtown Store",
"stock_on_hand": 5,
"stock_reserved": 0,
"available_quantity": 5,
"reorder_point": 10,
"reorder_quantity": 50,
"stock_status": "low_stock"
}
]
}
Matches 0 < stock_on_hand <= threshold, ordered by stock_on_hand ascending. threshold defaults to 10.
Inventory Summary
Use Case: Headline numbers for an inventory dashboard, filterable by any list parameter.
GET /inventory-levels/summary/?store_id=550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>
{
"total_items": 1234,
"total_stock_on_hand": 45678,
"total_stock_reserved": 1234,
"total_available_quantity": 44444,
"low_stock_count": 23,
"out_of_stock_count": 5,
"total_inventory_value": 456789.00,
"low_stock_threshold": 10
}
total_inventory_value sums stock_on_hand * cost_per_unit over rows with a cost set. For richer analytics (health, turnover, dead stock, movement summaries) use the /inventory-levels/analytics/... endpoints; see Analytics Service.
Inventory Mutation Workflow
The three mutation actions require IsEmployeeOrManager and run inside a database transaction with a row lock on the target record, so concurrent writes serialize instead of clobbering each other. Adjust live stock through these endpoints rather than direct ORM writes; the endpoints are the locked write path.
Reserve Stock During Checkout
POST /inventory-levels/c3d4e5f6-0000-0000-0000-000000000003/reserve_quantity/
Authorization: Bearer <token>
Content-Type: application/json
{"quantity": 3}
{
"message": "Inventory reserved successfully",
"stock_reserved": 3,
"total_reserved": 13,
"available_quantity": 87
}
Returns 400 for a non-positive or non-integer quantity, or when the request exceeds available stock (stock_on_hand - stock_reserved); 404 when the record does not exist.
Release a Reservation on Cart Abandonment
POST /inventory-levels/c3d4e5f6-0000-0000-0000-000000000003/release_reservation/
Authorization: Bearer <token>
Content-Type: application/json
{"quantity": 3}
{
"message": "Inventory reservation released successfully",
"quantity_released": 3,
"total_reserved": 10,
"available_quantity": 90
}
Returns 400 when releasing more than is currently reserved.
Adjust On-Hand Quantity
Use Case: Shrinkage, damage, or a physical count correction.
POST /inventory-levels/c3d4e5f6-0000-0000-0000-000000000003/adjust_quantity/
Authorization: Bearer <token>
Content-Type: application/json
{"adjustment": -5, "reason": "Damaged stock"}
{
"message": "Inventory adjusted successfully",
"old_quantity": 100,
"new_quantity": 95,
"adjustment": -5,
"reason": "Damaged stock"
}
Returns 400 for a non-integer adjustment or one that would drive stock_on_hand negative.
Dashboard Summaries
Catalog Dashboard Summary
Use Case: The headline tiles on the products dashboard. Requires IsEmployeeOrManager.
GET /products/dashboard_summary/
Authorization: Bearer <token>
{
"total_products": 150,
"visible_products": 142,
"hidden_products": 8,
"total_variants": 487,
"featured_variants": 32,
"on_sale_variants": 54,
"total_inventory": 45678,
"in_stock_products": 130,
"low_stock_products": 9,
"out_of_stock_products": 11,
"in_stock_variants": 440,
"low_stock_variants": 28,
"out_of_stock_variants": 19
}
total_inventory and the stock buckets read live InventoryLevel.stock_on_hand sums across non-digital variants, not the stock_initial snapshots. The low-stock threshold is 10. For sales-based product analytics (/products/analytics/top-products/ and friends) see Analytics Service.
ORM Query Patterns
Aggregate Inventory Across Stores for a SKU
from django.db.models import Sum
from nextango.apps.products.models import InventoryLevel
inventory_levels = InventoryLevel.objects.filter(
variant_sku__iexact='SKU-TSHIRT-RED-L',
is_active=True,
).select_related('store', 'variant')
totals = inventory_levels.aggregate(
total_on_hand=Sum('stock_on_hand'),
total_reserved=Sum('stock_reserved'),
)
available = (totals['total_on_hand'] or 0) - (totals['total_reserved'] or 0)
Prefetch Inventory Levels Through the Variant FK
InventoryLevel.variant is a real foreign key with related_name='inventory_levels', so variant listings prefetch their store inventory in one extra query instead of one query per row.
from nextango.apps.products.models import ProductVariant
variants = ProductVariant.objects.select_related('product').prefetch_related(
'inventory_levels__store'
).filter(product__hidden=False)
for variant in variants:
total_available = sum(
inv.stock_available
for inv in variant.inventory_levels.all()
if inv.is_active
)
print(f"{variant.sku}: {total_available} units available")
stock_available is a model property computed as max(0, stock_on_hand - stock_reserved). Note the related names: a product's variant records hang off product.variant_records (the variants attribute on Product is the Sanity JSONField, not a manager), and inventory rows hang off variant.inventory_levels.
Find Variants Available in Multiple Stores
from django.db.models import Count, Q
from nextango.apps.products.models import ProductVariant
multi_store_variants = ProductVariant.objects.annotate(
store_count=Count(
'inventory_levels',
filter=Q(
inventory_levels__stock_on_hand__gt=0,
inventory_levels__is_active=True,
),
)
).filter(store_count__gte=3).select_related('product')
For "is this variant purchasable at store X" checks, use the availability seam in products/availability.py rather than hand-rolled filters: a variant is available at a store when it has an active InventoryLevel there with stock_on_hand strictly above stock_reserved.
Related Documentation
- README - Products domain overview
- Models - Product, ProductVariant, InventoryLevel definitions
- Views - Endpoint reference, access model, and redaction rules
- Serializers - Full response shapes
- Analytics Service - Product and inventory analytics endpoints
- Smart Filter Service - Faceted search and manifests