Adds GET /api/v2/promotions/admin/promotions/ and .../referrals/, listing every Promotion (type derived from Plan.processor) and referral activity (invited_by = resolved payee, invited_user = raw event.data['user'] -- there's no dedicated referral model or referral_code field in this codebase). Follows the existing _user/_application file-suffix convention with a new _admin suffix, backed by real django-filter FilterSets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
Admin panel — technical reference
Two read-only endpoints added for the admin panel: Promotions (every promotion received, per user) and Referral System (referral activity). Both live in apps/promotions, following this repo's existing _user / _application file-suffix convention with a new _admin suffix:
| File | Purpose |
|---|---|
apps/promotions/views_admin.py |
AdminPromotionListApiView, AdminPromotionDetailApiView, AdminReferralListApiView, AdminReferralDetailApiView |
apps/promotions/urls_admin.py |
URL routes, app_name = 'promotions-admin' |
apps/promotions/filters_admin.py |
AdminPromotionFilter, AdminReferralFilter (django-filter FilterSets) |
apps/promotions/serializers.py |
AdminPromotionSerializer, AdminReferralSerializer, AdminPlanSummarySerializer (added alongside the existing serializers, this repo does not split serializers by audience) |
Mounted in main/urls.py:
path('api/v2/promotions/admin/', include('apps.promotions.urls_admin', namespace='promotions-admin')),
No models or migrations were changed. Both endpoints are pure ListAPIView/RetrieveAPIView reads over the existing Promotion table.
Auth
Same pattern as every other view in this app: IsAuthenticatedOrTokenMatchesOASRequirements (apps/gooyal_oauth2/rest_framework.py) with a required_alternate_scopes dict keyed by HTTP method.
| Endpoint | Scope |
|---|---|
| Promotions (list + detail) | admin.promotions:retrieve |
| Referral System (list + detail) | admin.referrals:retrieve |
These are new scope names — introspected the same way as every other scope in this app, via the central accounts OAuth2 provider. They are not yet registered there; that's an operational step outside this checkout (see Known limitations).
1. Promotions
GET /api/v2/promotions/admin/promotions/ — list
GET /api/v2/promotions/admin/promotions/<uuid>/ — detail
Every Promotion row (i.e. every promotion a user has received or is in-flight for), across all plans and all users.
Query params (filters)
| Param | Type | Maps to | Notes |
|---|---|---|---|
user_uuid |
UUID | Promotion.user_uuid |
exact |
plan |
UUID | Promotion.plan.uuid |
exact |
promotion_type |
choice: percentage | referral | others |
Promotion.plan.processor |
see "Promotion type", below |
state |
choice: 1-7 |
Promotion.state |
see state table below |
event_label |
string | Promotion.event.label |
exact |
created_after |
ISO datetime | Promotion.created_at >= |
|
created_before |
ISO datetime | Promotion.created_at <= |
|
limit, offset |
int | pagination | LimitOffsetPagination, project default PAGE_SIZE=200 |
Response fields
| Field | Type | Description |
|---|---|---|
uuid |
UUID | Promotion row id |
user |
UUID | Promotion.user_uuid — who received/will receive the payout |
plan.uuid |
UUID | The triggering plan |
plan.title |
string | Plan title |
plan.processor |
string | Raw Plan.processor value |
promotion_type |
string | null | Same as plan.processor; null only if plan itself is null (see limitations) |
event_label |
string | null | Label of the Event that triggered this promotion; null for synchronous per-plan promotes with no linked event |
base_amount |
int | null | Amount before percentage/cap math |
reward_amount |
int | null | Promotion.promotion_amount — the actual payout amount |
state |
int | Raw PaymentStateChoices value (1-7) |
state_display |
string | Human-readable state |
date |
datetime | Promotion.created_at |
updated_at |
datetime | Promotion.updated_at |
Payment states
| Value | Label |
|---|---|
| 1 | created |
| 2 | delayed |
| 3 | pending |
| 4 | incomplete |
| 5 | success |
| 6 | failed |
| 7 | expected_failure |
Example
GET /api/v2/promotions/admin/promotions/?promotion_type=referral&state=5
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"uuid": "7cb39284-cf95-43a5-b9d0-9c230f7e2f21",
"user": "1bb3b561-2823-4a3e-be12-edc5a6e623e8",
"plan": {
"uuid": "57a9f996-5e64-4541-b928-1e0f82ca3795",
"title": "referral-plan",
"processor": "referral"
},
"promotion_type": "referral",
"event_label": "referral::signup",
"base_amount": 1000,
"reward_amount": 1000,
"state": 5,
"state_display": "success",
"date": "2026-08-23T12:41:09.823169Z",
"updated_at": "2026-08-23T12:41:09.823175Z"
}
]
}
2. Referral System
GET /api/v2/promotions/admin/referrals/ — list
GET /api/v2/promotions/admin/referrals/<uuid>/ — detail
Scoped to Promotion rows whose plan.processor == 'referral'. There is no dedicated referral model in this codebase — see Known limitations before relying on any field name below.
Field derivation (read this before trusting the data)
The referral recipient pattern used throughout apps/promotions/tests.py is:
Recipient.objects.create(
recipient_uuid_field="->event:referral", # pays whoever event.data['referral'] names
...
)
i.e. the calling app submits an Event with data = {"user": <the person who acted>, "referral": <the referrer>}, and the Recipient DSL resolves the payee off event.data['referral']. That resolved payee becomes Promotion.user_uuid. So:
invited_by=Promotion.user_uuid— the resolved recipient of the reward, i.e. the referrer. This comes straight off thePromotionrow (the value Recipient DSL actually resolved and paid), not raw event JSON, so it's authoritative even for more complex referral recipient expressions (e.g. theQS:Event:...settlement pattern also in the test suite).invited_user=event.data.get('user')— the person who performed the referred action. This is not authoritative: it's whatever the calling app happened to put indata['user']on event submission, with no model-level guarantee the key exists or is even a UUID.
Query params (filters)
| Param | Type | Maps to | Notes |
|---|---|---|---|
invited_by |
UUID | Promotion.user_uuid |
exact; the referrer being paid |
invited_user |
string | Event.data['user'] (JSON key lookup) |
exact string match; not type-checked |
plan |
UUID | Promotion.plan.uuid |
exact |
state |
choice: 1-7 |
Promotion.state |
|
created_after / created_before |
ISO datetime | Promotion.created_at |
Response fields
| Field | Type | Description |
|---|---|---|
uuid |
UUID | Promotion row id |
invited_user |
string | null | event.data['user'], raw — not a referral_code, doesn't exist |
invited_by |
UUID | Promotion.user_uuid — the referrer who was paid |
plan.uuid / .title / .processor |
The referral plan | |
event_label |
string | null | Triggering event's label |
reward_amount |
int | null | Promotion.promotion_amount |
state / state_display |
int / string | Same as Promotions endpoint |
date |
datetime | Promotion.created_at |
There is no referral_code field in the response — it does not exist anywhere in this codebase (models, migrations, or elsewhere). See Known limitations.
Example
GET /api/v2/promotions/admin/referrals/?invited_by=1bb3b561-2823-4a3e-be12-edc5a6e623e8
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"uuid": "7cb39284-cf95-43a5-b9d0-9c230f7e2f21",
"invited_user": "fe208494-254c-412b-8f81-f6f816b219fb",
"invited_by": "1bb3b561-2823-4a3e-be12-edc5a6e623e8",
"plan": {
"uuid": "57a9f996-5e64-4541-b928-1e0f82ca3795",
"title": "referral-plan",
"processor": "referral"
},
"event_label": "referral::signup",
"reward_amount": 1000,
"state": 5,
"state_display": "success",
"date": "2026-08-23T12:41:09.823169Z"
}
]
}
Known limitations
- No
referral_code. There is no referral-code concept anywhere in this codebase — grepped forreferral_code/invite_code/invited_byacross every.pyfile; nothing exists outside this new admin code. Referral linking today is entirely raw UUIDs passed throughEvent.databy convention, not a schema-enforced relationship. If a real invite-code system is wanted, that's a model change (new field or table) requiring a migration — intentionally out of scope here. invited_useris unreliable. It's read from untyped JSON (Event.data['user']) with no model-level guarantee it exists, is a UUID, or even refers to a real user. Treat it as informational only;invited_by(a real FK-adjacent field,Promotion.user_uuid) is the trustworthy half of this endpoint.Recipient.promotion_typeis not used forpromotion_type. That field exists on the model but its choices enum (PromotionTypeChoicesinhandlers.py) is empty, so it's always blank in real data.promotion_typehere isPlan.processorinstead.- The
ReferralHandlerinhandlers.pyis dead code.calculate()references an undefinedbase_amount,promote()opens with a barereturn. It plays no role in producing thePromotionrows this endpoint reads — referral payouts happen through the same genericRecipient.promote()path as every other plan type (see the repo'sREADME.md"Watch list"). - Detail routes are of limited independent use.
AdminPromotionDetailApiView/AdminReferralDetailApiViewreturn the same shape as one row of the list endpoint; included for REST completeness (e.g. deep-linking from a table row in the admin UI) but the list endpoint withplan/statefilters covers most real usage. - New scopes (
admin.promotions:retrieve,admin.referrals:retrieve) need to be registered on the central accounts OAuth2 provider before any real token can carry them — that's outside this checkout. - No automated tests were added for these two endpoints (matching the request's scope: verified via
manage.py check, URL resolution, and anAPIRequestFactorysmoke test against an in-memory SQLite DB with the app's Postgres/JSONField-GinIndexusage stubbed around, run manually — seeapps/promotions/tests.pyfor the existingAPITestCasepattern if formal tests are wanted later).