feature/adminpanel #6
15 changed files with 472 additions and 7 deletions
|
|
@ -1,11 +1,36 @@
|
|||
import django_filters
|
||||
|
||||
from .models import Sample
|
||||
from .handlers import ProcessorTypeChoices
|
||||
from .models import Promotion, PaymentStateChoices
|
||||
|
||||
|
||||
class SampleFilter(django_filters.FilterSet):
|
||||
class AdminPromotionFilter(django_filters.FilterSet):
|
||||
user_uuid = django_filters.UUIDFilter(field_name='user_uuid')
|
||||
plan = django_filters.UUIDFilter(field_name='plan__uuid')
|
||||
promotion_type = django_filters.ChoiceFilter(field_name='plan__processor', choices=ProcessorTypeChoices.choices)
|
||||
state = django_filters.ChoiceFilter(field_name='state', choices=PaymentStateChoices.choices)
|
||||
event_label = django_filters.CharFilter(field_name='event__label', lookup_expr='exact')
|
||||
created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
|
||||
created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
|
||||
|
||||
class Meta:
|
||||
model = Sample
|
||||
fields = {
|
||||
'data': ['exact'],
|
||||
}
|
||||
model = Promotion
|
||||
fields = ['user_uuid', 'plan', 'promotion_type', 'state', 'event_label', 'created_after', 'created_before']
|
||||
|
||||
|
||||
class AdminReferralFilter(django_filters.FilterSet):
|
||||
# invited_by == Promotion.user_uuid: in the referral recipient DSL
|
||||
# ("->event:referral") this is who gets paid, i.e. the referrer.
|
||||
invited_by = django_filters.UUIDFilter(field_name='user_uuid')
|
||||
# invited_user lives only in free-form JSON (event.data['user']), so this is a
|
||||
# CharFilter, not UUIDFilter: JSONField key-transform lookups compare against
|
||||
# the stored string, not a native UUID the adapter can serialize.
|
||||
invited_user = django_filters.CharFilter(field_name='event__data__user')
|
||||
plan = django_filters.UUIDFilter(field_name='plan__uuid')
|
||||
state = django_filters.ChoiceFilter(field_name='state', choices=PaymentStateChoices.choices)
|
||||
created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
|
||||
created_before = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='lte')
|
||||
|
||||
class Meta:
|
||||
model = Promotion
|
||||
fields = ['invited_user', 'invited_by', 'plan', 'state', 'created_after', 'created_before']
|
||||
|
|
|
|||
25
apps/promotions/serializers/__init__.py
Normal file
25
apps/promotions/serializers/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from .common import (
|
||||
EventSerializer,
|
||||
PromotionSerializer,
|
||||
PlanSerializer,
|
||||
PlanPromotSerializer,
|
||||
PromoteSerializer,
|
||||
UserRecipientSerializer,
|
||||
PromotionStatusSerializer,
|
||||
UserPlanSerializer,
|
||||
)
|
||||
from .admin import AdminPlanSummarySerializer, AdminPromotionSerializer, AdminReferralSerializer
|
||||
|
||||
__all__ = [
|
||||
'EventSerializer',
|
||||
'PromotionSerializer',
|
||||
'PlanSerializer',
|
||||
'PlanPromotSerializer',
|
||||
'PromoteSerializer',
|
||||
'UserRecipientSerializer',
|
||||
'PromotionStatusSerializer',
|
||||
'UserPlanSerializer',
|
||||
'AdminPlanSummarySerializer',
|
||||
'AdminPromotionSerializer',
|
||||
'AdminReferralSerializer',
|
||||
]
|
||||
8
apps/promotions/serializers/admin/__init__.py
Normal file
8
apps/promotions/serializers/admin/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .promotion import AdminPlanSummarySerializer, AdminPromotionSerializer
|
||||
from .referral import AdminReferralSerializer
|
||||
|
||||
__all__ = [
|
||||
'AdminPlanSummarySerializer',
|
||||
'AdminPromotionSerializer',
|
||||
'AdminReferralSerializer',
|
||||
]
|
||||
42
apps/promotions/serializers/admin/promotion.py
Normal file
42
apps/promotions/serializers/admin/promotion.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.promotions.models import Promotion, Plan
|
||||
|
||||
|
||||
class AdminPlanSummarySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Plan
|
||||
fields = ("uuid", "title", "processor")
|
||||
|
||||
|
||||
class AdminPromotionSerializer(serializers.ModelSerializer):
|
||||
"""Every Promotion row, i.e. every promotion received by a user."""
|
||||
user = serializers.UUIDField(source='user_uuid', read_only=True)
|
||||
plan = AdminPlanSummarySerializer(read_only=True)
|
||||
promotion_type = serializers.SerializerMethodField()
|
||||
event_label = serializers.SerializerMethodField()
|
||||
state_display = serializers.CharField(source='get_state_display', read_only=True)
|
||||
reward_amount = serializers.IntegerField(source='promotion_amount', read_only=True)
|
||||
date = serializers.DateTimeField(source='created_at', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Promotion
|
||||
fields = (
|
||||
"uuid",
|
||||
"user",
|
||||
"plan",
|
||||
"promotion_type",
|
||||
"event_label",
|
||||
"base_amount",
|
||||
"reward_amount",
|
||||
"state",
|
||||
"state_display",
|
||||
"date",
|
||||
"updated_at",
|
||||
)
|
||||
|
||||
def get_promotion_type(self, obj):
|
||||
return obj.plan.processor if obj.plan_id else None
|
||||
|
||||
def get_event_label(self, obj):
|
||||
return obj.event.label if obj.event_id else None
|
||||
46
apps/promotions/serializers/admin/referral.py
Normal file
46
apps/promotions/serializers/admin/referral.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.promotions.models import Promotion
|
||||
from .promotion import AdminPlanSummarySerializer
|
||||
|
||||
|
||||
class AdminReferralSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Referral activity, derived from Promotion rows on plans whose processor is
|
||||
'referral'. There is no dedicated referral model and no referral_code field
|
||||
in this codebase today (see docs/adminpanel_technical.md, "Known limitations").
|
||||
|
||||
In the recipient DSL a referral plan's Recipient resolves to
|
||||
"->event:referral", so Promotion.user_uuid is the referrer being paid
|
||||
(invited_by) -- not the person who triggered the event. The invited user
|
||||
only exists as free-form JSON on the triggering Event (event.data['user']).
|
||||
"""
|
||||
invited_by = serializers.UUIDField(source='user_uuid', read_only=True)
|
||||
invited_user = serializers.SerializerMethodField()
|
||||
plan = AdminPlanSummarySerializer(read_only=True)
|
||||
event_label = serializers.SerializerMethodField()
|
||||
reward_amount = serializers.IntegerField(source='promotion_amount', read_only=True)
|
||||
state_display = serializers.CharField(source='get_state_display', read_only=True)
|
||||
date = serializers.DateTimeField(source='created_at', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Promotion
|
||||
fields = (
|
||||
"uuid",
|
||||
"invited_user",
|
||||
"invited_by",
|
||||
"plan",
|
||||
"event_label",
|
||||
"reward_amount",
|
||||
"state",
|
||||
"state_display",
|
||||
"date",
|
||||
)
|
||||
|
||||
def get_invited_user(self, obj):
|
||||
if obj.event_id and obj.event.data:
|
||||
return obj.event.data.get('user')
|
||||
return None
|
||||
|
||||
def get_event_label(self, obj):
|
||||
return obj.event.label if obj.event_id else None
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .models import Promotion, Plan, Event, Recipient
|
||||
from apps.promotions.models import Promotion, Plan, Event, Recipient
|
||||
|
||||
|
||||
class EventSerializer(serializers.ModelSerializer):
|
||||
6
apps/promotions/urls/__init__.py
Normal file
6
apps/promotions/urls/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .router import router
|
||||
from . import admin_urls
|
||||
|
||||
app_name = 'promotions-admin'
|
||||
|
||||
urlpatterns = router.urls + admin_urls.urlpatterns
|
||||
8
apps/promotions/urls/admin_urls.py
Normal file
8
apps/promotions/urls/admin_urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from apps.promotions.views import AdminPromotionViewSet, AdminReferralViewSet
|
||||
|
||||
from .router import router
|
||||
|
||||
router.register('promotions', AdminPromotionViewSet, basename='admin-promotions')
|
||||
router.register('referrals', AdminReferralViewSet, basename='admin-referrals')
|
||||
|
||||
urlpatterns = []
|
||||
3
apps/promotions/urls/router.py
Normal file
3
apps/promotions/urls/router.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
router = DefaultRouter()
|
||||
6
apps/promotions/views/__init__.py
Normal file
6
apps/promotions/views/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .admin import AdminPromotionViewSet, AdminReferralViewSet
|
||||
|
||||
__all__ = [
|
||||
'AdminPromotionViewSet',
|
||||
'AdminReferralViewSet',
|
||||
]
|
||||
7
apps/promotions/views/admin/__init__.py
Normal file
7
apps/promotions/views/admin/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from .promotion import AdminPromotionViewSet
|
||||
from .referral import AdminReferralViewSet
|
||||
|
||||
__all__ = [
|
||||
'AdminPromotionViewSet',
|
||||
'AdminReferralViewSet',
|
||||
]
|
||||
26
apps/promotions/views/admin/promotion.py
Normal file
26
apps/promotions/views/admin/promotion.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
from apps.promotions.filters import AdminPromotionFilter
|
||||
from apps.promotions.models import Promotion
|
||||
from apps.promotions.serializers import AdminPromotionSerializer
|
||||
|
||||
|
||||
class AdminPromotionViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
GenericViewSet
|
||||
):
|
||||
"""Read-only: every Promotion received, per user, with type/amount/date."""
|
||||
queryset = Promotion.objects.select_related('plan', 'event').order_by('-created_at')
|
||||
serializer_class = AdminPromotionSerializer
|
||||
lookup_field = 'uuid'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = AdminPromotionFilter
|
||||
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [["admin.promotions:retrieve"]],
|
||||
}
|
||||
35
apps/promotions/views/admin/referral.py
Normal file
35
apps/promotions/views/admin/referral.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||||
from apps.promotions.filters import AdminReferralFilter
|
||||
from apps.promotions.handlers import ProcessorTypeChoices
|
||||
from apps.promotions.models import Promotion
|
||||
from apps.promotions.serializers import AdminReferralSerializer
|
||||
|
||||
|
||||
class AdminReferralViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
GenericViewSet
|
||||
):
|
||||
"""
|
||||
Read-only: referral activity. Scoped to Promotion rows on plans whose
|
||||
processor == 'referral'. No dedicated Referral model / referral_code
|
||||
field exists in this codebase; see docs/adminpanel_technical.md.
|
||||
"""
|
||||
serializer_class = AdminReferralSerializer
|
||||
lookup_field = 'uuid'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = AdminReferralFilter
|
||||
|
||||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||||
required_alternate_scopes = {
|
||||
"GET": [["admin.referrals:retrieve"]],
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
return Promotion.objects.select_related('plan', 'event').filter(
|
||||
plan__processor=ProcessorTypeChoices.REFERRAL
|
||||
).order_by('-created_at')
|
||||
227
docs/adminpanel_technical.md
Normal file
227
docs/adminpanel_technical.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# 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`:
|
||||
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
```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/<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:
|
||||
|
||||
```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": <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
|
||||
```
|
||||
|
||||
```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).
|
||||
- **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.
|
||||
|
|
@ -30,6 +30,7 @@ urlpatterns = [
|
|||
path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')),
|
||||
path('promotions/', include('apps.promotions.urls_user', namespace='promotions')),
|
||||
path('api/v2/promotions/application/<user_uuid>/', include('apps.promotions.urls_application', namespace='promotions-application')),
|
||||
path('api/v2/promotions/admin/', include('apps.promotions.urls', namespace='promotions-admin')),
|
||||
]
|
||||
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue