Products - Services Documentation
Location: api/nextango/apps/products/services.py, api/nextango/apps/products/availability.py
Last Updated: 2026-07-06
Overview
The Products services layer holds business logic for product display, dashboard metrics, SKU lookup, Sanity CMS sync, attribute filtering, and Elasticsearch search. services.py defines three service classes plus shared query expression helpers:
- ProductService - product display, dashboard metrics, SKU lookup, and Sanity sync
- AttributeService - attribute filtering and synchronization
- ProductSearchService - Elasticsearch-backed product search
- Query expression helpers - live-inventory annotations and a NULL-safe valuation expression shared across readers
availability.py is the store-availability seam. Every store-scoped "is this purchasable at store X" question routes through it, anchored on InventoryLevel records. Analytics and smart filter services live in their own modules, documented on the Analytics Service and Smart Filter Service pages.
Query Expression Helpers
Module-level functions shared by dashboard readers and analytics call sites.
variant_live_inventory_annotation()
Location: services.py:19-43
Returns an OuterRef/Subquery annotation that sums InventoryLevel.stock_on_hand across active inventory levels per ProductVariant, coalesced to 0. Dashboard readers use it so reported inventory is live stock, not the stock_initial snapshot. The helper aggregates physical inventory only; callers preserve the unlimited-stock semantics for digital variants through their own digital_inventory filters.
product_live_inventory_annotation()
Location: services.py:46-64
Same shape per Product: sums InventoryLevel.stock_on_hand across active inventory levels of the product's non-digital variants, coalesced to 0.
inventory_valuation_expression()
Location: services.py:67-86
NULL-safe per-row inventory valuation: stock_on_hand multiplied by Coalesce(current_cost, 0.00), typed as DecimalField end to end. Rows with a NULL current_cost contribute zero explicitly instead of producing NULL products that Sum() silently omits. Callers wrap the expression with Sum(...) for aggregates or use it directly in .annotate() for row-level display.
ProductService
Location: services.py:89-492
Service class for product-related business logic. Handles the dual structure: Product.variants JSONField plus ProductVariant model records.
Methods
get_products_for_display(filters=None)
Location: services.py:95-231
Decorators: @log_execution_time, @cache_result(timeout=300) varying on filters
Fetches products with calculated metrics for dashboard and storefront display. Reads from the replica database when USE_REPLICA_DATABASE is configured, prefetches variant records with their active inventory levels to avoid N+1 queries, and orders results by name.
Parameters:
filters(dict, optional): Filter criteria
Filter Options:
{
'show_hidden': False, # Include hidden products
'brand': 'brand_uuid', # Django Brand UUID, resolved to brand_sanity_id
'product_type': 'type_uuid',# Django ProductType UUID, resolved to product_type_sanity_id
'in_stock': True, # Filter by inventory availability
'featured': True # Filter by featured variants
}
The brand and product_type filters resolve the Django UUID to the denormalized indexed brand_sanity_id / product_type_sanity_id columns. An unknown UUID returns an empty result, not an error.
Returns:
list: List of product data dictionaries with calculated fields
Response Structure:
{
'id': 'uuid',
'sanity_id': 'sanity_document_id',
'name': 'Product Name',
'slug': 'product-slug',
'hidden': False,
'shortDescription': 'Description text',
'is_in_stock': True,
'total_inventory': 150,
'variant_count': 4,
'featured_variant_count': 2,
'price_range': {
'min_price': 29.99,
'max_price': 59.99,
'min_sale_price': 24.99,
'max_sale_price': 49.99
},
'organization': {...},
'seo': {...},
'created_at': 'timestamp',
'updated_at': 'timestamp'
}
total_inventory sums live InventoryLevel.stock_on_hand across the active inventory levels of the product's non-digital variants. Digital variants are excluded from the total but any digital variant marks the product in stock regardless of the count. Price ranges come from the variant records, with sale prices considered only where on_sale is set.
Usage:
from apps.products.services import ProductService
# Get all visible products
products = ProductService.get_products_for_display()
# Get in-stock products from a specific brand
products = ProductService.get_products_for_display({
'brand': brand.id,
'in_stock': True
})
get_dashboard_summary()
Location: services.py:233-297
Decorators: @log_execution_time, @cache_result(timeout=300)
Calculates dashboard summary statistics across Product and ProductVariant models. Inventory figures read live InventoryLevel.stock_on_hand through the annotation helpers above, not the stock_initial snapshot. Reads from the replica database when configured.
Returns:
dict: Dashboard summary data
Response Structure:
{
# Product statistics
'total_products': 150,
'visible_products': 145,
'hidden_products': 5,
# Variant statistics
'total_variants': 450,
'featured_variants': 85,
'on_sale_variants': 120,
# Inventory statistics
'total_inventory': 5420,
'in_stock_products': 135,
'low_stock_products': 25,
'out_of_stock_products': 15,
'in_stock_variants': 380,
'low_stock_variants': 45,
'out_of_stock_variants': 70
}
Low stock means live inventory above 0 and at or below 10, at both variant and product level. A digital variant counts as in stock regardless of inventory levels; digital variants are excluded from total_inventory and from the low-stock and out-of-stock counts.
get_product_by_sku(sku)
Location: services.py:299-314
Finds a product and specific variant by SKU using the ProductVariant model. A single query with select_related returns both objects.
Parameters:
sku(str): The SKU to search for
Returns:
tuple:(Product, ProductVariant)if found,(None, None)if not found
get_variants_by_skus(skus)
Location: services.py:316-329
Bulk lookup of ProductVariant records by exact SKU match, with product data joined via select_related.
Parameters:
skus(list): List of SKUs to look up
Returns:
dict: Mapping of SKU to ProductVariant instance. SKUs with no match are absent from the dict.
sync_from_sanity()
Location: services.py:331-388
Manual sync path that polls the Sanity client directly and creates or updates both the Product.variants JSONField and ProductVariant records. This is not the canonical product sync entrypoint; production webhook traffic flows through ProductSyncHandler in integrations.sync.product_sync. This method backs the admin-triggered sync action.
Product upserts use update_or_create keyed on sanity_id, so the operation is idempotent, and each product sync calls sync_variants_to_model_records() to materialize variant records. Errors on individual products are counted and the sync continues.
Returns:
dict: Sync result summary
Response Structure:
{
'success': True,
'synced_count': 150,
'error_count': 2,
'total_attempted': 152,
'variant_records_created': 450,
'variant_records_updated': 125
}
A failure before the per-product loop returns {'success': False, 'error': '<message>'}.
sync_variant_to_jsonfield(variant)
Location: services.py:436-492
Syncs a ProductVariant model change back to the Product.variants JSONField (the reverse cascade in the three-source-of-truth contract). Called when ProductVariant records change outside of Sanity sync.
Parameters:
variant(ProductVariant): The variant that was updated
The method finds the matching entry in the JSONField by SKU and replaces it with a fresh snapshot, or appends the snapshot when the SKU is not present. The save touches only the variants field.
Variant Snapshot Structure:
{
'name': 'Medium - Blue',
'sku': 'SHIRT-MD-BLU',
'featured': False,
'onSale': True,
'price': 29.99,
'salePrice': 24.99,
'stockInitial': 15,
'digitalInventory': False,
'attributes': [...],
'dimensions': {...},
'images': [...],
'basePromoCode': {'_type': 'reference', '_ref': 'promo_id'} # only when set
}
basePromoCode is omitted when the variant has no promo code; absence in the document is the correct shape for an unset Sanity reference.
AttributeService
Location: services.py:495-594
Service class for attribute-related business logic. Handles the Sanity-aligned Attribute model with embedded type definitions.
Methods
get_attributes_for_filters()
Location: services.py:501-531
Decorators: @log_execution_time, @cache_result(timeout=600)
Gets attributes that should be shown in product filters. Queries attributes with is_shown=True, ordered by display_order then name, and skips any attribute without an attribute_type definition.
Returns:
list: List of attribute data for building filter UI
Response Structure:
[
{
'id': 'uuid',
'sanity_id': 'sanity_attribute_id',
'name': 'Size',
'featured': True,
'displayOrder': 1,
'type': 'attributeTextList',
'options': ['Small', 'Medium', 'Large', 'X-Large'],
'option_count': 4
},
{
'id': 'uuid',
'sanity_id': 'sanity_color_id',
'name': 'Color',
'featured': True,
'displayOrder': 2,
'type': 'attributeColorSwatch',
'options': [
{'name': 'Red', 'hex': '#FF0000', 'value': 'red'},
{'name': 'Blue', 'hex': '#0000FF', 'value': 'blue'}
],
'option_count': 2
}
]
sync_from_sanity()
Location: services.py:533-567
Triggers sync of attributes from Sanity CMS. Upserts use update_or_create keyed on sanity_id; errors on individual attributes are counted and the sync continues.
Returns:
dict: Sync result summary
Response Structure:
{
'success': True,
'synced_count': 15,
'error_count': 0,
'total_attempted': 15
}
ProductSearchService
Location: services.py:597-746
Service class for Elasticsearch-based product search against the ProductDocument index, working with the dual structure (JSONField variants plus ProductVariant records).
Methods
search(query=None, filters=None, page=1, page_size=20)
Location: services.py:603-746
Performs product search using Elasticsearch with embedded variants. Without a query the search matches all documents; with a query it runs a multi-match with AUTO fuzziness. Hidden products are always excluded.
Parameters:
query(str, optional): Full-text search queryfilters(dict, optional): Filter criteriapage(int): 1-based page number, mapped to ES from/sizepage_size(int): Results per page
Filter Options:
{
'in_stock': True, # Boolean or string 'true'/'false'
'category': 'category_ref', # organization.productType term
'min_price': 19.99, # Minimum price (float)
'max_price': 99.99, # Maximum price (float)
'featured': True, # Boolean or string 'true'/'false'
'store_id': 'uuid_or_sanity_id' # Store-scoped availability
}
Returns:
elasticsearch_dsl.response.Response: The requested page of hits.hits.total.valueis the full match count regardless of page.
Search Fields:
[
'name^3', # Product name (boosted 3x)
'shortDescription^2', # Product description (boosted 2x)
'variants.name^2', # Variant names (boosted 2x)
'variants.sku^2', # Variant SKUs (boosted 2x)
'organization.tags' # Organization tags
]
Store-scoped behavior:
The store_id parameter accepts a Django UUID or a Sanity ID, resolved through resolve_store_id_param. A resolved store adds a nested filter on variants.inventory_by_store requiring both the store match and is_available=True, so a zero-stock or fully reserved entry never matches. When the search is store-scoped, the doc-level has_inventory term is dropped and in_stock is answered from live per-store inventory instead of the global snapshot. An unresolvable store_id applies no store filter; the search degrades to the unfiltered result rather than returning empty.
Other behavior:
- Non-store-scoped
in_stockfilters on the doc-levelhas_inventorysnapshot - Price filters run as nested range queries on
variants.price; non-numeric values are ignored - Results sort by relevance, then name ascending
- Pagination maps to ES from/size with a
max_result_windowof 10000. A page starting at or beyond the window returns zero hits but still reports an accurate total - Elasticsearch errors are re-raised for the caller to handle
Usage:
from apps.products.services import ProductSearchService
# Store-scoped search with pagination
response = ProductSearchService.search(
query='shirt',
filters={'in_stock': True, 'store_id': store.id},
page=2,
page_size=20,
)
for hit in response:
print(f"{hit.name} ({hit.slug})")
print(f"Total matches: {response.hits.total.value}")
Store Availability Seam
Location: availability.py
The single place that answers "is this variant or product purchasable at store X". Availability is anchored on InventoryLevel, the authoritative store-specific record, never on Product.store_location (metadata) or ProductVariant.stock_initial (a one-time seed). Store-scoped consumers, including the catalog viewsets, search, the storefront detail and cart paths, and reconciliation, route through these helpers. A guard test (products/tests/test_store_availability_enforcement.py) forbids product views from re-deriving their own store-availability filters, so the boundary stays enforced by construction.
Functions
available_inventory_q(store)
Location: availability.py:19-22
Returns the canonical Q predicate: an active InventoryLevel at store with sellable quantity, meaning stock_on_hand strictly above stock_reserved.
Parameters:
store(Store): The store to test against
Returns:
Q: Predicate for use in InventoryLevel filters
inventory_available_at(store)
Location: availability.py:25-28
Returns the InventoryLevel queryset of rows sellable at store.
products_available_at(store, queryset=None)
Location: availability.py:31-42
Filters a Product queryset (all products when omitted) to products with at least one sellable variant at store, using a single Exists subquery on the indexed InventoryLevel.product FK.
Returns:
QuerySet[Product]
variants_available_at(store, queryset=None)
Location: availability.py:45-55
Filters a ProductVariant queryset (all variants when omitted) to variants sellable at store, via an Exists subquery matching InventoryLevel.variant_sku to the variant's sku.
Returns:
QuerySet[ProductVariant]
available_variant_skus_at(store, product=None)
Location: availability.py:58-65
Returns the set of variant SKUs sellable at store, optionally scoped to one product. Runs one query, then membership tests happen in memory, which suits filtering already-serialized variant_records on the detail path without N+1 queries.
Returns:
set[str]: SKUs with sellable inventory at the store
Caching and Monitoring
get_products_for_display and get_dashboard_summary cache results for 5 minutes; get_attributes_for_filters caches for 10 minutes. The display query caches per filter combination. These methods also log execution time through @log_execution_time.
Related Documentation
- Models - Product, variant, and inventory models
- Views - API endpoints that use these services
- Search - Elasticsearch document structure
- Signals - Sync signals and automation
- Analytics Service - Product and inventory analytics
- Smart Filter Service - Smart filter search and manifests
- Examples - Complete usage examples
File Reference
- Query expression helpers:
services.py:19-86 - ProductService:
services.py:89-492 - AttributeService:
services.py:495-594 - ProductSearchService:
services.py:597-746 - Store availability seam:
availability.py:1-65