Promotions Domain - REST API Views

Source File: api/nextango/apps/promotions/views.py Last Updated: 2026-07-08

Overview

The Promotions views expose four ModelViewSet/ReadOnlyModelViewSet classes: PromotionalCampaignViewSet, PromoCodeViewSet, ABTestViewSet, and PromotionViewSet (auto-apply promotions). All business logic delegates to PromotionService and PromotionAnalyticsService; no ViewSet performs discount calculation inline.

Root mount

api/nextango/apps/promotions/urls.py registers its own router (campaigns, promo-codes, ab-tests) but that file is not included anywhere in nextango/urls.py, so it is dead code. The live routes come entirely from the root router in nextango/urls.py:83-87:

router.register(r'campaigns', PromotionalCampaignViewSet, basename='campaign')
router.register(r'promo-codes', PromoCodeViewSet, basename='promocode')
router.register(r'ab-tests', ABTestViewSet, basename='abtest')
router.register(r'promotions', PromotionViewSet, basename='promotion')

Every endpoint below is root-level, there is no /promotions/ or /api/ prefix on campaigns, promo-codes, or ab-tests. The PromotionViewSet (auto-apply model) is the one exception, it is mounted at /promotions/ because its basename matches the domain name.


PromotionalCampaignViewSet

Base: ModelViewSet Queryset: PromotionalCampaign.objects.all()

Full CRUD for campaigns. list and retrieve are cached via @cache_response(timeout=1800, key_func=make_cache_key_func('campaign')), a 30-minute cache keyed per request, invalidated on model save/delete through the loop-prevention signal handlers documented in Signals.

MethodURLAction
GET/campaigns/list
GET/campaigns/{id}/retrieve
POST/campaigns/create
PUT/PATCH/campaigns/{id}/update / partial_update
DELETE/campaigns/{id}/destroy
GET/campaigns/analytics/summary/campaign performance summary

The analytics endpoint is also bound directly in the root URLconf (nextango/urls.py:131) as path('campaigns/analytics/summary/', ...), ahead of the router include, so it resolves before the router's {id}/ detail pattern would otherwise intercept it.

Auth: inherits DEFAULT_PERMISSION_CLASSES (IsActiveUser in production, AllowAny in DEBUG, settings/base.py:251-253). No permission_classes is declared on the ViewSet.


PromoCodeViewSet

Base: ModelViewSet Queryset: PromoCode.objects.all() lookup_field = 'code', retrieval and mutation use the code string, not the UUID primary key.

list and retrieve are cached via @cache_response(timeout=3600, key_func=make_cache_key_func('promocode')), a 1-hour cache.

MethodURLAction
GET/promo-codes/list
GET/promo-codes/{code}/retrieve
POST/promo-codes/create
PUT/PATCH/promo-codes/{code}/update / partial_update
DELETE/promo-codes/{code}/destroy
POST/promo-codes/validate/validate a code without applying it
GET/promo-codes/analytics/performance/code performance summary

validate_code

POST /promo-codes/validate/, permission_classes=[AllowAny], authentication_classes=[CustomTokenAuthentication, BasicAuthentication]. Public because storefront checkout calls it before the customer is authenticated.

Request: {"code": "SUMMER20", "subtotal": 100.00, "store_id": "<uuid>"}

Response: {"valid": true, "discount_info": {"code": ..., "discount_type": ..., "discount_amount": ..., "message": ...}} on success, or {"valid": false, "error": "..."} with 400 if code is missing or subtotal is not a valid decimal.

This is a read-only validation, it does not increment current_usage_count. The increment happens only when the transaction reaches COMPLETED status, via PromoUsageLedger.

Auth: all other actions inherit DEFAULT_PERMISSION_CLASSES.


ABTestViewSet

Base: ModelViewSet Queryset: ABTest.objects.all()

Standard CRUD plus two detail actions that call the model's thread-safe counter methods:

MethodURLAction
POST/ab-tests/{pk}/record-impression/body {"variant": "a"} (or "b"), calls ABTest.record_impression(variant)
POST/ab-tests/{pk}/record-conversion/body {"variant": "a"} (or "b"), calls ABTest.record_conversion(variant)

Both return 400 if variant is not 'a' or 'b'. No caching is applied to this ViewSet.

Auth: inherits DEFAULT_PERMISSION_CLASSES.


PromotionViewSet

Base: ReadOnlyModelViewSet (list and retrieve only, no create/update/delete through the API) Queryset: Promotion.objects.filter(is_active=True)

MethodURLAction
GET/promotions/list active promotions
GET/promotions/{id}/retrieve
GET/promotions/active/?store_id=<uuid>active promotions for a store, checkout-preview shape

active

GET /promotions/active/, authentication_classes=[], permission_classes=[AllowAny]. POS and storefront call this unauthenticated.

Delegates to PromotionService.preview_from_cart_items([], store_id) with an empty cart, so eligible_skus on each returned promotion is empty; the POS client resolves per-item eligibility client-side using isItemEligible() in promotionUtils.ts. The image field on each promotion is the raw Sanity image object, the client resolves the URL.

Response: {"promotions": [...]}, each entry carries id, name, description, discount_effect, image, applies_to, eligible_skus, savings_preview, is_applied.


  • Models - PromotionalCampaign, PromoCode, ABTest, Promotion model definitions
  • Services - PromotionService business logic
  • Serializers - Request/response field shapes
  • Signals - Real-time Sanity sync
  • Examples - Worked request/response examples

Was this page helpful?