# 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 `FilterSet`s) | | `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`: ```python 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//` — 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 ``` ```json { "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//` — 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: ```python 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": , "referral": }`, 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 the `Promotion` row (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. the `QS: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 in `data['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 ``` ```json { "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 for `referral_code` / `invite_code` / `invited_by` across every `.py` file; nothing exists outside this new admin code. Referral linking today is entirely raw UUIDs passed through `Event.data` by 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_user` is 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_type` is not used for `promotion_type`.** That field exists on the model but its choices enum (`PromotionTypeChoices` in `handlers.py`) is empty, so it's always blank in real data. `promotion_type` here is `Plan.processor` instead. - **The `ReferralHandler` in `handlers.py` is dead code.** `calculate()` references an undefined `base_amount`, `promote()` opens with a bare `return`. It plays no role in producing the `Promotion` rows this endpoint reads — referral payouts happen through the same generic `Recipient.promote()` path as every other plan type (see the repo's `README.md` "Watch list"). - **Detail routes are of limited independent use.** `AdminPromotionDetailApiView` / `AdminReferralDetailApiView` return 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 with `plan`/`state` filters 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 an `APIRequestFactory` smoke test against an in-memory SQLite DB with the app's Postgres/JSONField-`GinIndex` usage stubbed around, run manually — see `apps/promotions/tests.py` for the existing `APITestCase` pattern if formal tests are wanted later).