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

  1. Access Model
  2. Endpoint Overview
  3. ProductViewSet
  4. ProductVariantViewSet
  5. AttributeViewSet
  6. BrandViewSet
  7. ProductTypeViewSet
  8. CategoryViewSet
  9. CollectionViewSet
  10. InventoryLevelViewSet
  11. ProductSearchAPIView
  12. ProductSmartFilterAPIView
  13. 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_inventory and each variant's store_inventory, stock_available, and stock_reserved.
  • Variant list and retrieve drop the same per-variant fields.
  • /search/ drops stock_on_hand and stock_reserved from each inventory_by_store entry, keeping store_id, store_sanity_id, store_name, and is_available.
  • /search/smart-filter/ drops stock_available from each product, keeping is_in_stock and store_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

EndpointPrimary Use CaseMethodsLookupAuth
/products/Dashboard, multi-store viewsGET onlyslugAuthenticated
/product-variants/POS, single-store operationsGET onlyskuAuthenticated
/inventory-levels/Store inventory tracking and mutationFull CRUDpkAuthenticated; writes employee/manager
/attributes/Variant attribute taxonomyGET onlypkAuthenticated
/brands/Brand catalogGET onlyslugAuthenticated
/product-types/Product type catalogGET onlyslugAuthenticated
/categories/Category catalogGET onlyslugAuthenticated
/collections/Collection catalogGET onlyslugAuthenticated
/search/Elasticsearch full-text searchGETN/AAllowAny
/search/smart-filter/Faceted filtering with aggregationsGETN/AAllowAny

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

ParameterTypeDescription
show_hiddenbooleanTakes effect only with enhanced=true; the standard list pre-filters hidden=False, so hidden products never appear there
brandUUIDBrand Django UUID; resolved to its Sanity ID internally
productTypeUUIDProductType Django UUID
categoryUUIDCategory Django UUID; matches any of its product types
collectionUUIDCollection Django UUID
store_idstringStore Django UUID or Sanity ID; filters to products sellable at that store
enhancedbooleanRoute through ProductService.get_products_for_display(); honored only when store_id is absent
searchstringMatches name, slug, short description, variant names, variant SKUs
orderingstringname, 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

ParameterTypeDescription
barcodestringScanner lookup; exact match on either sku or upc
productUUIDAll variants of a product
product_slugstringAll variants by product slug
categorystringCategory Django UUID or Sanity ID (via ProductType relationship)
collectionstringCollection Django UUID or Sanity ID
productTypestringProductType Django UUID or Sanity ID
featuredbooleanFeatured variants only
on_salebooleanSale variants only
in_stockbooleanWithout store_id: variants with digital_inventory or stock_initial > 0. With store_id: redundant, store filtering already requires sellable stock
low_stockbooleanNon-digital variants with 0 < stock_initial <= 10
min_price / max_pricedecimalPrice bounds
store_idstringStore Django UUID or Sanity ID
searchstringMatches name, SKU, UPC, product name
orderingstringname, 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}/
ParameterTypeDescription
shown_onlybooleanVisible attributes only
featured_onlybooleanFeatured attributes only
typestringattributeTextList or attributeColorSwatch
searchstringMatches name
orderingstringdisplay_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}/
ParameterTypeDescription
shown_onlybooleanVisible brands only
searchstringMatches name
orderingstringname, 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}/
ParameterTypeDescription
shown_onlybooleanVisible types only
categoryUUIDFilter by category ID
category_slugstringFilter by category slug
searchstringMatches name, description
orderingstringname, 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}/
ParameterTypeDescription
shown_onlybooleanVisible categories only
searchstringMatches name, description
orderingstringname, 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}/
ParameterTypeDescription
shown_onlybooleanVisible collections only
searchstringMatches name, description
orderingstringname, 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
ParameterTypeDescription
product_idUUIDAll levels for a product
product_namestringCase-insensitive contains
skustringExact variant SKU, case-insensitive
store_idstringStore Django UUID or Sanity ID; fail-closed resolution and the role-aware store gate apply
store_namestringCase-insensitive contains
low_stockboolean0 < stock_on_hand <= threshold
low_stock_thresholdintegerThreshold for low_stock (default 10)
out_of_stockbooleanstock_on_hand = 0
has_reservedbooleanstock_reserved > 0
needs_reorderbooleanstock_on_hand <= reorder_point (requires reorder_point set)
overstockedbooleanstock_on_hand > max_stock_level (requires max_stock_level set)
min_cost / max_costdecimalBounds on cost_per_unit
min_standard_cost / max_standard_costdecimalBounds on standard_cost
is_activebooleanTri-state: true or false applies the filter, anything else is ignored. The base queryset includes only active rows, so false always returns empty
on_salebooleanTri-state: true or false applies the filter, anything else is ignored
cost_methodstringstandard, average, fifo
searchstringMatches variant_sku, variant_name, product name, store name
orderingstringstock_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

ParameterTypeDefaultDescription
qstring""Full-text query across names, SKUs, descriptions
categorystringNoneFilter by category or product type
brandstringNoneAccepted and echoed in filters; the service applies no brand filter to the ES query
min_pricedecimalNoneMinimum price
max_pricedecimalNoneMaximum price
in_stockbooleanNoneIn-stock products only
featuredbooleanNoneFeatured products only
store_idstringNoneStore-specific availability
pageinteger1Page number
page_sizeinteger20Results 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

ParameterTypeDescription
searchstringText search query
brand[]UUID arrayBrand filter
category[]UUID arrayCategory filter
productType[]UUID arrayProduct type filter
collection[]UUID arrayCollection filter
attributes[Name][]string arrayAttribute values grouped by attribute name, e.g. attributes[Color][]=Red
sortstringfeatured (default), price-asc, price-desc, newest, name-asc, name-desc
on_salebooleanProducts with at least one on-sale variant
low_stockbooleanProducts with a store-available variant whose on-hand count is low (store-scoped when store_id is present)
store_idstringStore UUID for store-scoped filtering and manifest routing
collapse_variantsbooleanCollapse each product to a single card (grid opt-in); omitted by carousels, which keep flat variant rows
pageintegerPage number (default 1)
page_sizeintegerResults 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).


  • 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

Was this page helpful?