Products Views & API Endpoints
API endpoints for the products domain: catalog reads, variant lookups, store inventory, taxonomy, and the two public search views.
Source: api/nextango/apps/products/views.py, api/nextango/apps/products/viewsets/inventory.py
Routing: registered in api/nextango/urls.py (the products app has no urls.py of its own)
Last Updated: 2026-07-06
Table of Contents
- Access Model
- Endpoint Overview
- ProductViewSet
- ProductVariantViewSet
- AttributeViewSet
- BrandViewSet
- ProductTypeViewSet
- CategoryViewSet
- CollectionViewSet
- InventoryLevelViewSet
- ProductSearchAPIView
- ProductSmartFilterAPIView
- Common Patterns
Access Model
The catalog read surface is private and read-only. There is no REST CRUD on products, variants, attributes, or taxonomy.
Read-only viewsets. ProductViewSet, ProductVariantViewSet, AttributeViewSet, BrandViewSet, ProductTypeViewSet, CategoryViewSet, and CollectionViewSet are all ReadOnlyModelViewSet with IsAuthenticated. Sanity Studio is the authoring surface for these records; Django mirrors them. POST, PUT, PATCH, and DELETE on these routes return 405 Method Not Allowed (the four taxonomy viewsets additionally pin http_method_names to get, head, options), with one exception: a stray update() override on ProductVariantViewSet binds PUT /product-variants/{sku}/ in routing, where the call fails with a server error, performs no mutation, and is still gated by IsAuthenticated. Mutation happens only through @action endpoints (sync_variants, sync_from_sanity) gated to IsEmployeeOrManager.
One writable viewset. InventoryLevelViewSet stays a full ModelViewSet because InventoryLevel is Django-owned and operationally mutable. Reads require authentication; create, update, partial_update, and destroy require IsEmployeeOrManager on top.
Public search. ProductSearchAPIView (/search/) and ProductSmartFilterAPIView (/search/smart-filter/) are AllowAny.
Capability-keyed inventory redaction. The storefront authenticates as a trusted non-staff service principal, so authentication alone does not separate public-facing callers from staff. Raw per-store quantities go only to callers that pass the capability check: users holding the employee or manager role, or POS-session principals (request.auth carries type: 'pos_session'). Everyone else, including customers, the storefront service principal, and anonymous callers on the search endpoints, receives binary availability plus prices:
- Product list and retrieve drop
total_inventoryand each variant'sstore_inventory,stock_available, andstock_reserved. - Variant list and retrieve drop the same per-variant fields.
/search/dropsstock_on_handandstock_reservedfrom eachinventory_by_storeentry, keepingstore_id,store_sanity_id,store_name, andis_available./search/smart-filter/dropsstock_availablefrom each product, keepingis_in_stockandstore_name.
Two auxiliary actions serialize the full variant shape with no response-boundary strip, so they are gated outright by the SeesRawInventory permission (staff or POS session only): GET /products/{slug}/variant_records/ and GET /product-variants/by_sku_list/. Non-privileged callers get 403.
Role-aware store gate. Every ?store_id= filter resolves the parameter (Django UUID or Sanity ID) and fails closed to an empty result when it does not resolve. Staff callers with non-empty store assignments must be assigned to the target store; customers, the storefront service token, and managers with empty assignments bypass the gate.
Store availability seam. "Is this purchasable at store X" is answered by products/availability.py and nowhere else: a product or variant is available at a store when it has an active InventoryLevel there with stock_on_hand strictly above stock_reserved. ProductVariant.stock_initial is a one-time seed, not a live signal, and store_location is metadata.
Endpoint Overview
| Endpoint | Primary Use Case | Methods | Lookup | Auth |
|---|---|---|---|---|
/products/ | Dashboard, multi-store views | GET only | slug | Authenticated |
/product-variants/ | POS, single-store operations | GET only | sku | Authenticated |
/inventory-levels/ | Store inventory tracking and mutation | Full CRUD | pk | Authenticated; writes employee/manager |
/attributes/ | Variant attribute taxonomy | GET only | pk | Authenticated |
/brands/ | Brand catalog | GET only | slug | Authenticated |
/product-types/ | Product type catalog | GET only | slug | Authenticated |
/categories/ | Category catalog | GET only | slug | Authenticated |
/collections/ | Collection catalog | GET only | slug | Authenticated |
/search/ | Elasticsearch full-text search | GET | N/A | AllowAny |
/search/smart-filter/ | Faceted filtering with aggregations | GET | N/A | AllowAny |
Two product endpoints exist on purpose. /products/ serves dashboard and multi-store contexts with aggregated variant data. /product-variants/ serves POS and single-store contexts, keyed by SKU for barcode scans.
ProductViewSet
Read-only access to the product catalog for dashboard and multi-store views.
Base URL: /products/
Auth: IsAuthenticated for list and retrieve. Per-action gates elevate from there (see each action).
Lookup: slug
Not for POS transactions (use /product-variants/) or single-variant barcode lookups (use /product-variants/{sku}/).
List Products
GET /products/
GET /products/?search=widget&ordering=name
GET /products/?category=<uuid>&store_id=<store>
Query Parameters
| Parameter | Type | Description |
|---|---|---|
show_hidden | boolean | Takes effect only with enhanced=true; the standard list pre-filters hidden=False, so hidden products never appear there |
brand | UUID | Brand Django UUID; resolved to its Sanity ID internally |
productType | UUID | ProductType Django UUID |
category | UUID | Category Django UUID; matches any of its product types |
collection | UUID | Collection Django UUID |
store_id | string | Store Django UUID or Sanity ID; filters to products sellable at that store |
enhanced | boolean | Route through ProductService.get_products_for_display(); honored only when store_id is absent |
search | string | Matches name, slug, short description, variant names, variant SKUs |
ordering | string | name, created_at, updated_at (prefix - for descending; default name) |
Unknown brand, productType, category, or collection values return an empty result set rather than an error. An unresolvable store_id, or a staff caller not assigned to the resolved store, also returns an empty result set (fail closed).
When store_id is present, the list excludes products with no sellable variant at that store, and variant_records is limited to variants sellable there.
Response (non-privileged callers do not receive total_inventory, store_inventory, stock_available, or stock_reserved):
{
"count": 150,
"next": "http://api/products/?page=2",
"previous": null,
"results": [
{
"id": "uuid",
"name": "Product Name",
"slug": "product-name",
"variant_records": [
{
"sku": "SKU-001",
"name": "Variant Name",
"price": "29.99",
"is_in_stock": true
}
]
}
]
}
Retrieve Product
GET /products/{slug}/
GET /products/{slug}/?store_id=<store>
Returns one product with variant_records. With store_id, the product is always returned even when nothing is sellable at that store; availability is a signal, not a hide. The response adds available_at_store (boolean), and variant_records contains only variants sellable at that store. The same capability-keyed redaction applies as on list.
Write Methods
POST /products/, PUT, PATCH, and DELETE /products/{slug}/ are not implemented. The viewset is read-only; these return 405 Method Not Allowed.
Custom Actions
Get Product Variants (JSONField)
GET /products/{slug}/variants/
GET /products/{slug}/variants/?sku=SKU-001
GET /products/{slug}/variants/?featured=true
GET /products/{slug}/variants/?on_sale=true
Auth: authenticated.
Returns the raw Product.variants JSON array (camelCase, Sanity shape), optionally filtered by sku, featured, or on_sale.
Response:
{
"product": "Product Name",
"slug": "product-slug",
"count": 5,
"variants": [
{
"sku": "SKU-001",
"name": "Variant Name",
"price": 29.99,
"featured": true,
"onSale": false
}
]
}
Get Variant Records (Django Models)
GET /products/{slug}/variant_records/
GET /products/{slug}/variant_records/?sku=SKU-001
GET /products/{slug}/variant_records/?featured=true
GET /products/{slug}/variant_records/?on_sale=true
Auth: SeesRawInventory (staff or POS session). This action serializes the full ProductVariantSerializer shape with raw per-store quantities and no strip, so non-privileged callers get 403. The storefront reads variant records as a nested field of the stripped product retrieve instead.
Response:
{
"product": "Product Name",
"slug": "product-slug",
"count": 5,
"variant_records": [ { "...": "full ProductVariantSerializer shape" } ]
}
The difference from /variants/: that action returns JSONField data (camelCase, Sanity shape); this one returns serialized model records (snake_case, Django shape).
Sync Variants to Model Records
POST /products/{slug}/sync_variants/
Auth: IsAuthenticated + IsEmployeeOrManager.
Triggers sync of the Product.variants JSONField into ProductVariant model records.
Response:
{
"message": "Variants synced successfully",
"variant_count": 5,
"variant_record_count": 5
}
Non-standard status: 500 with {"error": "Sync failed: ..."} when the sync raises.
Dashboard Summary
GET /products/dashboard_summary/
Auth: IsAuthenticated + IsEmployeeOrManager.
Returns aggregate catalog statistics from ProductService.get_dashboard_summary(). See Services for the computation.
Product Analytics
GET /products/analytics/top-products/
GET /products/analytics/by-category/
GET /products/analytics/by-brand/
Auth: IsAuthenticated + IsEmployeeOrManager.
These paths are bound directly in api/nextango/urls.py ahead of the router. All three accept a date range (parsed by parse_date_range) and an optional store_id. top-products also accepts limit (default 10) and sort_by (default revenue). Backed by ProductAnalyticsService; see Analytics Service.
Sync from Sanity CMS
POST /products/sync_from_sanity/
Auth: IsAuthenticated + IsEmployeeOrManager.
Triggers a product sync from Sanity via ProductService.sync_from_sanity() and returns its result.
Non-standard status: 500 with {"error": "..."} when the sync raises.
ProductVariantViewSet
Read-only access to product variants for POS transactions and single-store operations.
Base URL: /product-variants/
Auth: IsAuthenticated. The by_sku_list action requires SeesRawInventory.
Lookup: sku (so GET /product-variants/SKU-12345/)
Not for dashboard multi-store inventory views (use /products/).
List Variants
GET /product-variants/
GET /product-variants/?barcode=012345678905
GET /product-variants/?featured=true&in_stock=true
GET /product-variants/?store_id=<store>&category=<uuid>
Query Parameters
| Parameter | Type | Description |
|---|---|---|
barcode | string | Scanner lookup; exact match on either sku or upc |
product | UUID | All variants of a product |
product_slug | string | All variants by product slug |
category | string | Category Django UUID or Sanity ID (via ProductType relationship) |
collection | string | Collection Django UUID or Sanity ID |
productType | string | ProductType Django UUID or Sanity ID |
featured | boolean | Featured variants only |
on_sale | boolean | Sale variants only |
in_stock | boolean | Without store_id: variants with digital_inventory or stock_initial > 0. With store_id: redundant, store filtering already requires sellable stock |
low_stock | boolean | Non-digital variants with 0 < stock_initial <= 10 |
min_price / max_price | decimal | Price bounds |
store_id | string | Store Django UUID or Sanity ID |
search | string | Matches name, SKU, UPC, product name |
ordering | string | name, sku, upc, price, stock_initial, created_at (default product__name, name) |
With store_id, a variant appears only when it has an active InventoryLevel at that store with stock_on_hand above stock_reserved, and its store_location either is empty (available everywhere) or includes the store. The role-aware store gate and fail-closed resolution described in Access Model apply.
Response: paginated ProductVariantSerializer records. Non-privileged callers do not receive store_inventory, stock_available, or stock_reserved. The store_inventory field is populated only when store_id is present, and only for privileged callers.
Retrieve Variant by SKU
GET /product-variants/{sku}/
Single variant with parent product data. Same redaction as list.
Write Methods
The viewset is read-only by intent. POST, PATCH, and DELETE return 405 Method Not Allowed, while a stray update() override binds PUT in routing: the call fails with a server error, performs no mutation, and is still gated by IsAuthenticated. Variant authoring flows through Sanity and the product sync actions.
Custom Actions
Inventory Status
GET /product-variants/{sku}/inventory_status/
Auth: authenticated.
Quick status check from variant-level fields (no per-store quantities).
Response:
{
"sku": "SKU-001",
"name": "Variant Name",
"product": "Product Name",
"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
}
unlimited_stock is an alias of digital_inventory. stock_initial is the immutable creation-time seed; live quantities live on InventoryLevel.
By SKU List
GET /product-variants/by_sku_list/?skus=SKU-001,SKU-002,SKU-003
Auth: SeesRawInventory (staff or POS session). Serializes the full variant shape with no strip; non-privileged callers get 403.
Response:
{
"requested_skus": ["SKU-001", "SKU-002", "SKU-003"],
"found_count": 3,
"variants": [ { "...": "full ProductVariantSerializer shape" } ]
}
Non-standard status: 400 with {"error": "No SKUs provided"} when skus is empty.
AttributeViewSet
Read-only access to product attributes (colors, sizes, materials). Sanity Studio is the authoring surface.
Base URL: /attributes/
Auth: IsAuthenticated. sync_from_sanity requires IsEmployeeOrManager.
Lookup: pk
List and Retrieve
GET /attributes/
GET /attributes/?shown_only=true
GET /attributes/?type=attributeColorSwatch
GET /attributes/{id}/
| Parameter | Type | Description |
|---|---|---|
shown_only | boolean | Visible attributes only |
featured_only | boolean | Featured attributes only |
type | string | attributeTextList or attributeColorSwatch |
search | string | Matches name |
ordering | string | display_order, name, created_at (default display_order, name) |
Write methods return 405 Method Not Allowed.
Custom Actions
Get Attribute Options
GET /attributes/{id}/options/
Auth: authenticated.
Response:
{
"attribute": "Size",
"type": "attributeTextList",
"options": ["Small", "Medium", "Large", "XL"],
"option_count": 4
}
Non-standard status: 400 with {"error": "Attribute has no type defined"} when the attribute carries no type.
Attributes for Filters
GET /attributes/for_filters/
Auth: authenticated.
Returns attributes intended for product filter UI, shaped by AttributeService.get_attributes_for_filters().
Response:
{
"count": 5,
"attributes": [
{
"name": "Color",
"type": "attributeColorSwatch",
"options": [
{"name": "Red", "color": {"hex": "#FF0000"}}
]
}
]
}
Sync from Sanity
POST /attributes/sync_from_sanity/
Auth: IsAuthenticated + IsEmployeeOrManager.
Triggers attribute sync via AttributeService.sync_from_sanity() and returns its result.
Non-standard status: 500 with {"error": "..."} when the sync raises.
BrandViewSet
Read-only brand catalog mirroring Sanity's brand schema.
Base URL: /brands/
Auth: IsAuthenticated
Lookup: slug
Methods: GET, HEAD, OPTIONS only (pinned via http_method_names). There is no brand CRUD; Sanity Studio authors brand records.
GET /brands/
GET /brands/?shown_only=true
GET /brands/{slug}/
| Parameter | Type | Description |
|---|---|---|
shown_only | boolean | Visible brands only |
search | string | Matches name |
ordering | string | name, created_at, updated_at (default name) |
Caching: list and retrieve responses cache for 3600 seconds (key prefix brand), invalidated on Brand save or delete via signals. See Signals.
Response:
{
"id": "uuid",
"sanity_id": "brand-123",
"name": "Brand Name",
"slug": "brand-name",
"is_shown": true
}
ProductTypeViewSet
Read-only product-type catalog mirroring Sanity's productType schema.
Base URL: /product-types/
Auth: IsAuthenticated
Lookup: slug
Methods: GET, HEAD, OPTIONS only.
GET /product-types/
GET /product-types/?category=<uuid>
GET /product-types/?category_slug=electronics
GET /product-types/{slug}/
| Parameter | Type | Description |
|---|---|---|
shown_only | boolean | Visible types only |
category | UUID | Filter by category ID |
category_slug | string | Filter by category slug |
search | string | Matches name, description |
ordering | string | name, created_at, updated_at (default name) |
Caching: list and retrieve responses cache for 3600 seconds (key prefix producttype), invalidated on save or delete.
CategoryViewSet
Read-only category catalog mirroring Sanity's category schema.
Base URL: /categories/
Auth: IsAuthenticated
Lookup: slug
Methods: GET, HEAD, OPTIONS only.
GET /categories/
GET /categories/?shown_only=true
GET /categories/{slug}/
| Parameter | Type | Description |
|---|---|---|
shown_only | boolean | Visible categories only |
search | string | Matches name, description |
ordering | string | name, created_at, updated_at (default name) |
Caching: list and retrieve responses cache for 3600 seconds (key prefix category), invalidated on save or delete.
Custom Action: Get Product Types
GET /categories/{slug}/product_types/
GET /categories/{slug}/product_types/?shown_only=true
Auth: authenticated.
Response:
{
"category": "Electronics",
"slug": "electronics",
"product_types_count": 5,
"product_types": [
{
"id": "uuid",
"name": "Laptop",
"slug": "laptop",
"is_shown": true
}
]
}
CollectionViewSet
Read-only collection catalog mirroring Sanity's collection schema.
Base URL: /collections/
Auth: IsAuthenticated
Lookup: slug
Methods: GET, HEAD, OPTIONS only.
GET /collections/
GET /collections/?shown_only=true
GET /collections/{slug}/
| Parameter | Type | Description |
|---|---|---|
shown_only | boolean | Visible collections only |
search | string | Matches name, description |
ordering | string | name, created_at, updated_at (default name) |
Caching: list and retrieve responses cache for 3600 seconds (key prefix collection), invalidated on save or delete.
InventoryLevelViewSet
The source of truth for store-specific inventory. Each record represents one variant at one store (unique on product, variant_sku, store).
Source: viewsets/inventory.py
Base URL: /inventory-levels/
Base class: ModelViewSet (the one writable viewset in the domain; InventoryLevel is Django-owned and operationally mutable)
Auth: IsAuthenticated for reads. create, update, partial_update, and destroy require IsEmployeeOrManager. The mutation and analytics actions carry their own employee/manager gates as noted below.
The base queryset includes only is_active=True rows.
List and Filtering
GET /inventory-levels/
GET /inventory-levels/?store_id=<store>&low_stock=true
GET /inventory-levels/?sku=SKU-001
| Parameter | Type | Description |
|---|---|---|
product_id | UUID | All levels for a product |
product_name | string | Case-insensitive contains |
sku | string | Exact variant SKU, case-insensitive |
store_id | string | Store Django UUID or Sanity ID; fail-closed resolution and the role-aware store gate apply |
store_name | string | Case-insensitive contains |
low_stock | boolean | 0 < stock_on_hand <= threshold |
low_stock_threshold | integer | Threshold for low_stock (default 10) |
out_of_stock | boolean | stock_on_hand = 0 |
has_reserved | boolean | stock_reserved > 0 |
needs_reorder | boolean | stock_on_hand <= reorder_point (requires reorder_point set) |
overstocked | boolean | stock_on_hand > max_stock_level (requires max_stock_level set) |
min_cost / max_cost | decimal | Bounds on cost_per_unit |
min_standard_cost / max_standard_cost | decimal | Bounds on standard_cost |
is_active | boolean | Tri-state: true or false applies the filter, anything else is ignored. The base queryset includes only active rows, so false always returns empty |
on_sale | boolean | Tri-state: true or false applies the filter, anything else is ignored |
cost_method | string | standard, average, fifo |
search | string | Matches variant_sku, variant_name, product name, store name |
ordering | string | stock_on_hand, stock_reserved, cost_per_unit, standard_cost, current_cost, reorder_point, last_updated, last_transaction_date (default -last_updated) |
Read Actions
Inventory Summary
GET /inventory-levels/summary/
Auth: authenticated.
Response:
{
"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
}
Accepts low_stock_threshold (default 10) and the standard list filters.
Get Inventory by Store
GET /inventory-levels/by_store/?store_id=<store>
Auth: authenticated. store_id is required.
Response:
{
"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": "inventory-level-uuid",
"variant_sku": "SKU-001",
"variant_name": "Red / Large",
"stock_on_hand": 45,
"stock_reserved": 5,
"reorder_point": 10,
"stock_status": "in_stock"
}
]
}
Non-standard status: 400 with {"error": "store_id parameter is required"}.
Get Inventory by Product
GET /inventory-levels/by_product/?product_id=<uuid>
GET /inventory-levels/by_product/?variant_sku=SKU-001
Auth: authenticated. At least one of product_id or variant_sku is required; variant_sku matches case-insensitively.
Response:
{
"product_id": "product-uuid",
"variant_sku": "SKU-001",
"stores_count": 5,
"total_stock_on_hand": 250,
"total_stock_reserved": 25,
"total_available": 225,
"inventory_levels": [ { "...": "InventoryLevelSerializer shape" } ]
}
Non-standard status: 400 with {"error": "Either product_id or variant_sku parameter is required"}.
Low Stock Alerts
GET /inventory-levels/low_stock_alerts/?threshold=10
Auth: authenticated. threshold defaults to 10; results order by stock_on_hand ascending.
Response:
{
"threshold": 10,
"low_stock_count": 23,
"low_stock_items": [ { "...": "InventoryLevelSerializer shape" } ]
}
Out of Stock
GET /inventory-levels/out_of_stock/
Auth: authenticated.
Response:
{
"out_of_stock_count": 5,
"out_of_stock_items": [ { "...": "InventoryLevelSerializer shape" } ]
}
Mutation Actions
All three run inside a database transaction with a row lock on the target record, so concurrent adjustments serialize rather than clobber each other.
Adjust Quantity
POST /inventory-levels/{id}/adjust_quantity/
Content-Type: application/json
{"adjustment": -5, "reason": "Damaged stock"}
Auth: IsAuthenticated + IsEmployeeOrManager.
Response:
{
"message": "Inventory adjusted successfully",
"old_quantity": 50,
"new_quantity": 45,
"adjustment": -5,
"reason": "Damaged stock"
}
Non-standard status: 400 for a non-integer adjustment or an adjustment that would drive stock_on_hand negative; 404 when the record does not exist.
Reserve Quantity
POST /inventory-levels/{id}/reserve_quantity/
Content-Type: application/json
{"quantity": 3}
Auth: IsAuthenticated + IsEmployeeOrManager.
Increments stock_reserved when enough unreserved stock exists (stock_on_hand - stock_reserved).
Response:
{
"message": "Inventory reserved successfully",
"stock_reserved": 3,
"total_reserved": 8,
"available_quantity": 37
}
Non-standard status: 400 for a non-positive or non-integer quantity, or when the request exceeds available stock; 404 when the record does not exist.
Release Reservation
POST /inventory-levels/{id}/release_reservation/
Content-Type: application/json
{"quantity": 3}
Auth: IsAuthenticated + IsEmployeeOrManager.
Decrements stock_reserved, capped at the currently reserved amount.
Response:
{
"message": "Inventory reservation released successfully",
"quantity_released": 3,
"total_reserved": 5,
"available_quantity": 40
}
Non-standard status: 400 for a non-positive or non-integer quantity, or when releasing more than is reserved; 404 when the record does not exist.
Analytics Actions
GET /inventory-levels/analytics/health/
GET /inventory-levels/analytics/turnover/
GET /inventory-levels/analytics/dead-stock/
GET /inventory-levels/analytics/movements/
Auth: IsAuthenticated + IsEmployeeOrManager. Bound directly in api/nextango/urls.py ahead of the router.
All four accept an optional store_id. turnover accepts limit (default 20); dead-stock accepts days_threshold (default 90); movements accepts a date range via parse_date_range. Backed by InventoryAnalyticsService; see Analytics Service.
ProductSearchAPIView
Elasticsearch full-text product search.
GET /search/
Auth: AllowAny. Anonymous callers are served; the capability-keyed redaction below shapes what they see.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | "" | Full-text query across names, SKUs, descriptions |
category | string | None | Filter by category or product type |
brand | string | None | Accepted and echoed in filters; the service applies no brand filter to the ES query |
min_price | decimal | None | Minimum price |
max_price | decimal | None | Maximum price |
in_stock | boolean | None | In-stock products only |
featured | boolean | None | Featured products only |
store_id | string | None | Store-specific availability |
page | integer | 1 | Page number |
page_size | integer | 20 | Results per page (max 100) |
Parameters validate through ProductSearchSerializer; invalid input returns 400 with field errors. Elasticsearch applies from/size pagination directly.
Response
{
"results": [
{
"id": "uuid",
"name": "Product Name",
"slug": "product-slug",
"short_description": "...",
"hidden": false,
"variants": [
{
"sku": "SKU-001",
"price": 29.99,
"inventory_by_store": [
{
"store_id": "uuid",
"store_sanity_id": "store-abc",
"store_name": "Downtown Store",
"is_available": true
}
]
}
],
"price_range": {"min_price": 29.99, "max_price": 49.99},
"has_inventory": true,
"total_inventory": 100
}
],
"total": 45,
"page": 1,
"page_size": 20,
"total_pages": 3,
"took": 15,
"query": "laptop",
"filters": {"brand": "..."}
}
Staff and POS-session callers additionally receive stock_on_hand and stock_reserved inside each inventory_by_store entry. All other callers, anonymous included, get the stripped shape shown above.
Non-standard status: 400 for invalid parameters; 500 with {"error": "Search service temporarily unavailable", "detail": "..."} when Elasticsearch fails.
See Search for the index document shape and Services for ProductSearchService.
ProductSmartFilterAPIView
Faceted product filtering with Elasticsearch aggregations.
GET /search/smart-filter/
Auth: AllowAny.
Hybrid routing: browsing mode (no search text, store_id present, and none of brand, category, productType, collection, attributes, on_sale, or low_stock active) serves aggregations from a pre-generated per-store manifest when one exists, with availability flags recalculated against current store inventory so stale manifests do not show dead filter options. A search query, any of those filters, or a missing manifest falls through to real-time Elasticsearch aggregations through SmartFilterService.execute_smart_search(). The bypass set is _MANIFEST_BYPASS_FILTERS (views.py:1312-1313): a filtered manifest would otherwise serve unfiltered counts. See Smart Filter Service and Tasks for manifest generation.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
search | string | Text search query |
brand[] | UUID array | Brand filter |
category[] | UUID array | Category filter |
productType[] | UUID array | Product type filter |
collection[] | UUID array | Collection filter |
attributes[Name][] | string array | Attribute values grouped by attribute name, e.g. attributes[Color][]=Red |
sort | string | featured (default), price-asc, price-desc, newest, name-asc, name-desc |
on_sale | boolean | Products with at least one on-sale variant |
low_stock | boolean | Products with a store-available variant whose on-hand count is low (store-scoped when store_id is present) |
store_id | string | Store UUID for store-scoped filtering and manifest routing |
collapse_variants | boolean | Collapse each product to a single card (grid opt-in); omitted by carousels, which keep flat variant rows |
page | integer | Page number (default 1) |
page_size | integer | Results per page (default 20, max 100) |
Response
{
"products": [ { "...": "product documents" } ],
"total": 156,
"aggregations": {
"brands": [{"_id": "...", "name": "...", "count": 12}],
"categories": [{"_id": "...", "name": "...", "count": 8}],
"productTypes": [{"_id": "...", "name": "...", "count": 5}],
"collections": [{"_id": "...", "name": "...", "count": 3}],
"attributes": {
"featured": [{"name": "...", "options": ["..."]}],
"regular": [{"name": "...", "options": ["..."]}]
}
},
"page": 1,
"page_size": 20,
"filters": { "...": "echo of applied filters" }
}
Staff and POS-session callers additionally receive stock_available on each product. All other callers, anonymous included, get the stripped shape; is_in_stock and store_name remain.
Non-standard status: 400 with {"error": "Invalid parameter value", "detail": "..."} for malformed parameters; 500 with {"error": "Search service error", "detail": "..."} on service failure.
Common Patterns
UUID to Sanity ID Translation
Catalog filters accept Django UUIDs from the frontend. Taxonomy references inside Product.organization are Sanity references, so the viewsets resolve each UUID to the record's sanity_id before filtering. An unknown UUID yields an empty result set, not an error. Used by the brand, productType, category, and collection filters on both product viewsets.
Dual-Shape Store Parameter
Every ?store_id= accepts either a store's Django UUID or its Sanity ID. Resolution happens once per request through a shared helper; an unresolvable value returns an empty result (fail closed), and the role-aware store gate from Access Model applies after resolution.
Boolean Query Parameters
Most boolean flags parse as the literal string true (case-insensitive); anything else is false. Used by show_hidden, featured, on_sale, in_stock, enhanced, and shown_only. The inventory is_active and on_sale filters are tri-state instead: true filters to true, false filters to false, and any other value leaves the filter unapplied. Because the base inventory queryset includes only is_active=True rows, is_active=false always returns an empty result.
Lookup Fields
Products and the taxonomy viewsets look up by slug for human-readable URLs. ProductVariantViewSet looks up by sku for barcode compatibility. InventoryLevelViewSet looks up by pk.
Performance Monitoring
List endpoints and the dashboard and analytics actions log execution time through @log_execution_time, named per view (for example ProductViewSet.list, InventoryLevelViewSet.analytics_health).
Related Documentation
- Models - Product, ProductVariant, Attribute, InventoryLevel models
- Serializers - Request/response serialization
- Services - Business logic and Sanity sync operations
- Analytics Service - Product and inventory analytics
- Smart Filter Service - Faceted search and manifests
- Search - Elasticsearch ProductDocument configuration
- Signals - Sync cascades and cache invalidation
- Tasks - Manifest generation and inventory rollups