These were dropped from the prior restructuring commit by a failed multi-pathspec git add (one invalid path aborted the whole call). Wires up filters.py's Admin*Filter classes, mounts apps.promotions.urls (the new router package) instead of the deleted urls_admin module, and brings docs/adminpanel_technical.md in sync with the new layout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 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). They live under apps/promotions, structured to match the admin/users folder split used by the sibling advertising service (apps/crm, apps/escrow, apps/stores there each split views/, serializers/, and urls/ into admin//users/ subpackages, sharing one DefaultRouter). Applied here for the admin slice only — the pre-existing views_user.py / views_application.py / urls_user.py / urls_application.py (user-token and application-token APIs) were intentionally left untouched, since restructuring those would rename URL namespaces already relied on by tests.py and any external caller.
| Path | Purpose |
|---|---|
apps/promotions/views/admin/promotion.py |
AdminPromotionViewSet |
apps/promotions/views/admin/referral.py |
AdminReferralViewSet |
apps/promotions/views/admin/__init__.py, apps/promotions/views/__init__.py |
re-export the two viewsets |
apps/promotions/urls/router.py |
shared DefaultRouter for the admin API |
apps/promotions/urls/admin_urls.py |
registers both viewsets on the router |
apps/promotions/urls/__init__.py |
combines router.urls, sets app_name = 'promotions-admin' |
apps/promotions/serializers/admin/promotion.py |
AdminPlanSummarySerializer, AdminPromotionSerializer |
apps/promotions/serializers/admin/referral.py |
AdminReferralSerializer |
apps/promotions/serializers/admin/__init__.py, apps/promotions/serializers/__init__.py |
re-export, alongside the pre-existing (now serializers/common.py) serializers |
apps/promotions/filters.py |
AdminPromotionFilter, AdminReferralFilter — stays a single flat file, matching how advertising's filters.py (e.g. apps/crm/filters.py) is not split into subfolders even though views/serializers/urls are |
Mounted in main/urls.py:
path('api/v2/promotions/admin/', include('apps.promotions.urls', namespace='promotions-admin')),
No models or migrations were changed. Both resources are GenericViewSet + ListModelMixin/RetrieveModelMixin (matching AdminTicketViewSet in advertising's apps/crm/views/admin/ticket.py) registered on one router, not separate ListAPIView/RetrieveAPIView classes.
URL names
Router-generated, under the promotions-admin namespace:
| Name | Path |
|---|---|
promotions-admin:admin-promotions-list |
GET /api/v2/promotions/admin/promotions/ |
promotions-admin:admin-promotions-detail |
GET /api/v2/promotions/admin/promotions/<uuid>/ |
promotions-admin:admin-referrals-list |
GET /api/v2/promotions/admin/referrals/ |
promotions-admin:admin-referrals-detail |
GET /api/v2/promotions/admin/referrals/<uuid>/ |
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). - The admin/users folder split was applied to the admin slice only.
views_user.py,views_application.py,urls_user.py,urls_application.py, and the non-admin serializers (nowserializers/common.py) were left in place rather than also converted toviews/users/,views/application/, etc., because that would rename thepromotions/promotions-applicationURL namespaces already used bytests.py'sreverse()calls (and possibly external callers) — a breaking change outside what was asked for here.