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.
| Method | URL | Action |
|---|---|---|
| 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.
| Method | URL | Action |
|---|---|---|
| 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:
| Method | URL | Action |
|---|---|---|
| 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)
| Method | URL | Action |
|---|---|---|
| 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.
Related Documentation
- 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