promotions/docs/adminpanel_technical.md
Ali Asadi c359105019 FIX(promotions): include filters.py/docs/urls.py in admin panel restructure
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>
2026-08-23 17:28:33 +03:30

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 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
{
  "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).
  • 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 (now serializers/common.py) were left in place rather than also converted to views/users/, views/application/, etc., because that would rename the promotions / promotions-application URL namespaces already used by tests.py's reverse() calls (and possibly external callers) — a breaking change outside what was asked for here.