From d27ae32e27174fbfad6d2668f6b0b38994d5a354 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 23 Aug 2026 17:10:49 +0330 Subject: [PATCH 1/3] FEATURE(promotions): add read-only admin endpoints for promotions and referrals 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 --- apps/promotions/filters_admin.py | 36 ++++++ apps/promotions/serializers.py | 81 ++++++++++++ apps/promotions/urls_admin.py | 12 ++ apps/promotions/views_admin.py | 62 +++++++++ docs/adminpanel_technical.md | 209 +++++++++++++++++++++++++++++++ main/urls.py | 1 + 6 files changed, 401 insertions(+) create mode 100644 apps/promotions/filters_admin.py create mode 100644 apps/promotions/urls_admin.py create mode 100644 apps/promotions/views_admin.py create mode 100644 docs/adminpanel_technical.md diff --git a/apps/promotions/filters_admin.py b/apps/promotions/filters_admin.py new file mode 100644 index 0000000..c7bbe77 --- /dev/null +++ b/apps/promotions/filters_admin.py @@ -0,0 +1,36 @@ +import django_filters + +from .handlers import ProcessorTypeChoices +from .models import Promotion, PaymentStateChoices + + +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 = 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'] diff --git a/apps/promotions/serializers.py b/apps/promotions/serializers.py index 50ed72d..939a7b7 100644 --- a/apps/promotions/serializers.py +++ b/apps/promotions/serializers.py @@ -90,6 +90,87 @@ class PromotionStatusSerializer(serializers.Serializer): promotion_amount = serializers.IntegerField(read_only=True, allow_null=True) +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 + + +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 + + class UserPlanSerializer(serializers.ModelSerializer): recipients = UserRecipientSerializer(many=True, read_only=True) class Meta: diff --git a/apps/promotions/urls_admin.py b/apps/promotions/urls_admin.py new file mode 100644 index 0000000..098c66d --- /dev/null +++ b/apps/promotions/urls_admin.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views_admin + +app_name = 'promotions-admin' + +urlpatterns = [ + path('promotions/', views_admin.AdminPromotionListApiView.as_view(), name='promotion-list'), + path('promotions//', views_admin.AdminPromotionDetailApiView.as_view(), name='promotion-detail'), + path('referrals/', views_admin.AdminReferralListApiView.as_view(), name='referral-list'), + path('referrals//', views_admin.AdminReferralDetailApiView.as_view(), name='referral-detail'), +] diff --git a/apps/promotions/views_admin.py b/apps/promotions/views_admin.py new file mode 100644 index 0000000..f770dd1 --- /dev/null +++ b/apps/promotions/views_admin.py @@ -0,0 +1,62 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.generics import ListAPIView, RetrieveAPIView + +from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements +from .filters_admin import AdminPromotionFilter, AdminReferralFilter +from .handlers import ProcessorTypeChoices +from .models import Promotion +from .serializers import AdminPromotionSerializer, AdminReferralSerializer + +ADMIN_PROMOTION_QUERYSET = Promotion.objects.select_related('plan', 'event').order_by('-created_at') + + +class AdminPromotionListApiView(ListAPIView): + """Read-only: every Promotion received, per user, with type/amount/date.""" + queryset = ADMIN_PROMOTION_QUERYSET + serializer_class = AdminPromotionSerializer + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["admin.promotions:retrieve"]], + } + filter_backends = [DjangoFilterBackend] + filterset_class = AdminPromotionFilter + + +class AdminPromotionDetailApiView(RetrieveAPIView): + queryset = ADMIN_PROMOTION_QUERYSET + serializer_class = AdminPromotionSerializer + lookup_field = 'uuid' + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["admin.promotions:retrieve"]], + } + + +class AdminReferralListApiView(ListAPIView): + """ + 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 + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["admin.referrals:retrieve"]], + } + filter_backends = [DjangoFilterBackend] + filterset_class = AdminReferralFilter + + def get_queryset(self): + return ADMIN_PROMOTION_QUERYSET.filter(plan__processor=ProcessorTypeChoices.REFERRAL) + + +class AdminReferralDetailApiView(RetrieveAPIView): + serializer_class = AdminReferralSerializer + lookup_field = 'uuid' + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + required_alternate_scopes = { + "GET": [["admin.referrals:retrieve"]], + } + + def get_queryset(self): + return ADMIN_PROMOTION_QUERYSET.filter(plan__processor=ProcessorTypeChoices.REFERRAL) diff --git a/docs/adminpanel_technical.md b/docs/adminpanel_technical.md new file mode 100644 index 0000000..2c00b09 --- /dev/null +++ b/docs/adminpanel_technical.md @@ -0,0 +1,209 @@ +# 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). diff --git a/main/urls.py b/main/urls.py index 1431bb6..5067a92 100644 --- a/main/urls.py +++ b/main/urls.py @@ -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//', include('apps.promotions.urls_application', namespace='promotions-application')), + path('api/v2/promotions/admin/', include('apps.promotions.urls_admin', namespace='promotions-admin')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) -- 2.45.3 From 1d77303f00e9c207661122a95f96f5db62244483 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 23 Aug 2026 17:27:15 +0330 Subject: [PATCH 2/3] REFACTOR(promotions): split admin panel into views/serializers/urls subpackages Restructures the admin promotions/referrals endpoints to match the admin/users folder split used by the sibling advertising service (apps/crm, apps/escrow, apps/stores there): views/admin/, serializers/admin/, and a shared urls/router.py with the two resources as GenericViewSets registered on one DefaultRouter, instead of flat _admin-suffixed modules with separate ListAPIView/RetrieveAPIView classes. Scoped to the admin slice only -- the pre-existing user-token/ application-token views, urls, and serializers keep their original _user/_application file layout and URL namespaces, since converting those too would rename routes tests.py and external callers depend on. filters.py stays a single flat file, matching how advertising's own filters.py is not split into subfolders either. Co-Authored-By: Claude Sonnet 5 --- apps/promotions/filters_admin.py | 36 ---- apps/promotions/serializers.py | 188 ------------------ apps/promotions/serializers/__init__.py | 25 +++ apps/promotions/serializers/admin/__init__.py | 8 + .../promotions/serializers/admin/promotion.py | 42 ++++ apps/promotions/serializers/admin/referral.py | 46 +++++ apps/promotions/serializers/common.py | 107 ++++++++++ apps/promotions/urls/__init__.py | 6 + apps/promotions/urls/admin_urls.py | 8 + apps/promotions/urls/router.py | 3 + apps/promotions/urls_admin.py | 12 -- apps/promotions/views/__init__.py | 6 + apps/promotions/views/admin/__init__.py | 7 + apps/promotions/views/admin/promotion.py | 26 +++ apps/promotions/views/admin/referral.py | 35 ++++ apps/promotions/views_admin.py | 62 ------ 16 files changed, 319 insertions(+), 298 deletions(-) delete mode 100644 apps/promotions/filters_admin.py delete mode 100644 apps/promotions/serializers.py create mode 100644 apps/promotions/serializers/__init__.py create mode 100644 apps/promotions/serializers/admin/__init__.py create mode 100644 apps/promotions/serializers/admin/promotion.py create mode 100644 apps/promotions/serializers/admin/referral.py create mode 100644 apps/promotions/serializers/common.py create mode 100644 apps/promotions/urls/__init__.py create mode 100644 apps/promotions/urls/admin_urls.py create mode 100644 apps/promotions/urls/router.py delete mode 100644 apps/promotions/urls_admin.py create mode 100644 apps/promotions/views/__init__.py create mode 100644 apps/promotions/views/admin/__init__.py create mode 100644 apps/promotions/views/admin/promotion.py create mode 100644 apps/promotions/views/admin/referral.py delete mode 100644 apps/promotions/views_admin.py diff --git a/apps/promotions/filters_admin.py b/apps/promotions/filters_admin.py deleted file mode 100644 index c7bbe77..0000000 --- a/apps/promotions/filters_admin.py +++ /dev/null @@ -1,36 +0,0 @@ -import django_filters - -from .handlers import ProcessorTypeChoices -from .models import Promotion, PaymentStateChoices - - -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 = 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'] diff --git a/apps/promotions/serializers.py b/apps/promotions/serializers.py deleted file mode 100644 index 939a7b7..0000000 --- a/apps/promotions/serializers.py +++ /dev/null @@ -1,188 +0,0 @@ -from rest_framework import serializers - -from .models import Promotion, Plan, Event, Recipient - - -class EventSerializer(serializers.ModelSerializer): - class Meta: - model = Event - fields = ( - "label", - "data", - "user", - "application", - ) - read_only_fields = ('user', 'application') - - -class PromotionSerializer(serializers.ModelSerializer): - # base_amount = serializers.IntegerField(required=True) - # promotion_amount = serializers.IntegerField(read_only=True) - event = EventSerializer(read_only=True) - user = serializers.UUIDField(source='user_uuid') - - class Meta: - model = Promotion - fields = ( - "uuid", - "event", - "promotion_amount", - "state", - "user", - ) - - -class PlanSerializer(serializers.ModelSerializer): - base_amount = serializers.IntegerField(required=True) - promotion_amount = serializers.IntegerField(read_only=True) - - class Meta: - model = Plan - fields = ("title", - "description", - "base_amount", - "application", - "promotion_amount" - ) - read_only_fields = ('application', "promotion_amount", "title", "description") - - # def get_promotion_amount(self, obj): - # user = self.context['request'].user - # return obj.calculate_promotion(user) - - -class PlanPromotSerializer(serializers.ModelSerializer): - class Meta: - model = Promotion - fields = ( - "user", - "application", - ) - read_only_fields = ('user', 'application') - - - -class PromoteSerializer(serializers.ModelSerializer): - label = serializers.CharField(write_only=True) - data = serializers.JSONField(write_only=True) - - promotions = PromotionSerializer(many=True, read_only=True) - - class Meta: - model = Promotion - fields = ( - "promotions", - "label", - "data", - ) - - -class UserRecipientSerializer(serializers.ModelSerializer): - class Meta: - model = Recipient - fields = ['label', - "base_amount_field"] - - -class PromotionStatusSerializer(serializers.Serializer): - event_label = serializers.CharField(read_only=True) - processed = serializers.BooleanField(read_only=True) - promotion_amount = serializers.IntegerField(read_only=True, allow_null=True) - - -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 - - -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 - - -class UserPlanSerializer(serializers.ModelSerializer): - recipients = UserRecipientSerializer(many=True, read_only=True) - class Meta: - model = Plan - fields = ("title", - 'banner', - "description", - "description_details", - "recipients", - ) - read_only_fields = ("title", "description", "description_details", 'banner', "recipients") - - # def get_promotion_amount(self, obj): - # user = self.context['request'].user - # return obj.calculate_promotion(user) diff --git a/apps/promotions/serializers/__init__.py b/apps/promotions/serializers/__init__.py new file mode 100644 index 0000000..9fd6158 --- /dev/null +++ b/apps/promotions/serializers/__init__.py @@ -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', +] diff --git a/apps/promotions/serializers/admin/__init__.py b/apps/promotions/serializers/admin/__init__.py new file mode 100644 index 0000000..617b516 --- /dev/null +++ b/apps/promotions/serializers/admin/__init__.py @@ -0,0 +1,8 @@ +from .promotion import AdminPlanSummarySerializer, AdminPromotionSerializer +from .referral import AdminReferralSerializer + +__all__ = [ + 'AdminPlanSummarySerializer', + 'AdminPromotionSerializer', + 'AdminReferralSerializer', +] diff --git a/apps/promotions/serializers/admin/promotion.py b/apps/promotions/serializers/admin/promotion.py new file mode 100644 index 0000000..98666be --- /dev/null +++ b/apps/promotions/serializers/admin/promotion.py @@ -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 diff --git a/apps/promotions/serializers/admin/referral.py b/apps/promotions/serializers/admin/referral.py new file mode 100644 index 0000000..3deea23 --- /dev/null +++ b/apps/promotions/serializers/admin/referral.py @@ -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 diff --git a/apps/promotions/serializers/common.py b/apps/promotions/serializers/common.py new file mode 100644 index 0000000..df22f47 --- /dev/null +++ b/apps/promotions/serializers/common.py @@ -0,0 +1,107 @@ +from rest_framework import serializers + +from apps.promotions.models import Promotion, Plan, Event, Recipient + + +class EventSerializer(serializers.ModelSerializer): + class Meta: + model = Event + fields = ( + "label", + "data", + "user", + "application", + ) + read_only_fields = ('user', 'application') + + +class PromotionSerializer(serializers.ModelSerializer): + # base_amount = serializers.IntegerField(required=True) + # promotion_amount = serializers.IntegerField(read_only=True) + event = EventSerializer(read_only=True) + user = serializers.UUIDField(source='user_uuid') + + class Meta: + model = Promotion + fields = ( + "uuid", + "event", + "promotion_amount", + "state", + "user", + ) + + +class PlanSerializer(serializers.ModelSerializer): + base_amount = serializers.IntegerField(required=True) + promotion_amount = serializers.IntegerField(read_only=True) + + class Meta: + model = Plan + fields = ("title", + "description", + "base_amount", + "application", + "promotion_amount" + ) + read_only_fields = ('application', "promotion_amount", "title", "description") + + # def get_promotion_amount(self, obj): + # user = self.context['request'].user + # return obj.calculate_promotion(user) + + +class PlanPromotSerializer(serializers.ModelSerializer): + class Meta: + model = Promotion + fields = ( + "user", + "application", + ) + read_only_fields = ('user', 'application') + + + +class PromoteSerializer(serializers.ModelSerializer): + label = serializers.CharField(write_only=True) + data = serializers.JSONField(write_only=True) + + promotions = PromotionSerializer(many=True, read_only=True) + + class Meta: + model = Promotion + fields = ( + "promotions", + "label", + "data", + ) + + +class UserRecipientSerializer(serializers.ModelSerializer): + class Meta: + model = Recipient + fields = ['label', + "base_amount_field"] + + +class PromotionStatusSerializer(serializers.Serializer): + event_label = serializers.CharField(read_only=True) + processed = serializers.BooleanField(read_only=True) + promotion_amount = serializers.IntegerField(read_only=True, allow_null=True) + + +class UserPlanSerializer(serializers.ModelSerializer): + recipients = UserRecipientSerializer(many=True, read_only=True) + class Meta: + model = Plan + fields = ("title", + 'banner', + "description", + "description_details", + "recipients", + ) + read_only_fields = ("title", "description", "description_details", 'banner', "recipients") + + # def get_promotion_amount(self, obj): + # user = self.context['request'].user + # return obj.calculate_promotion(user) diff --git a/apps/promotions/urls/__init__.py b/apps/promotions/urls/__init__.py new file mode 100644 index 0000000..a6bcff4 --- /dev/null +++ b/apps/promotions/urls/__init__.py @@ -0,0 +1,6 @@ +from .router import router +from . import admin_urls + +app_name = 'promotions-admin' + +urlpatterns = router.urls + admin_urls.urlpatterns diff --git a/apps/promotions/urls/admin_urls.py b/apps/promotions/urls/admin_urls.py new file mode 100644 index 0000000..59dc60d --- /dev/null +++ b/apps/promotions/urls/admin_urls.py @@ -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 = [] diff --git a/apps/promotions/urls/router.py b/apps/promotions/urls/router.py new file mode 100644 index 0000000..fd9849d --- /dev/null +++ b/apps/promotions/urls/router.py @@ -0,0 +1,3 @@ +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() diff --git a/apps/promotions/urls_admin.py b/apps/promotions/urls_admin.py deleted file mode 100644 index 098c66d..0000000 --- a/apps/promotions/urls_admin.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path - -from . import views_admin - -app_name = 'promotions-admin' - -urlpatterns = [ - path('promotions/', views_admin.AdminPromotionListApiView.as_view(), name='promotion-list'), - path('promotions//', views_admin.AdminPromotionDetailApiView.as_view(), name='promotion-detail'), - path('referrals/', views_admin.AdminReferralListApiView.as_view(), name='referral-list'), - path('referrals//', views_admin.AdminReferralDetailApiView.as_view(), name='referral-detail'), -] diff --git a/apps/promotions/views/__init__.py b/apps/promotions/views/__init__.py new file mode 100644 index 0000000..351edd6 --- /dev/null +++ b/apps/promotions/views/__init__.py @@ -0,0 +1,6 @@ +from .admin import AdminPromotionViewSet, AdminReferralViewSet + +__all__ = [ + 'AdminPromotionViewSet', + 'AdminReferralViewSet', +] diff --git a/apps/promotions/views/admin/__init__.py b/apps/promotions/views/admin/__init__.py new file mode 100644 index 0000000..1572ca1 --- /dev/null +++ b/apps/promotions/views/admin/__init__.py @@ -0,0 +1,7 @@ +from .promotion import AdminPromotionViewSet +from .referral import AdminReferralViewSet + +__all__ = [ + 'AdminPromotionViewSet', + 'AdminReferralViewSet', +] diff --git a/apps/promotions/views/admin/promotion.py b/apps/promotions/views/admin/promotion.py new file mode 100644 index 0000000..de479b5 --- /dev/null +++ b/apps/promotions/views/admin/promotion.py @@ -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"]], + } diff --git a/apps/promotions/views/admin/referral.py b/apps/promotions/views/admin/referral.py new file mode 100644 index 0000000..3483283 --- /dev/null +++ b/apps/promotions/views/admin/referral.py @@ -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') diff --git a/apps/promotions/views_admin.py b/apps/promotions/views_admin.py deleted file mode 100644 index f770dd1..0000000 --- a/apps/promotions/views_admin.py +++ /dev/null @@ -1,62 +0,0 @@ -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.generics import ListAPIView, RetrieveAPIView - -from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements -from .filters_admin import AdminPromotionFilter, AdminReferralFilter -from .handlers import ProcessorTypeChoices -from .models import Promotion -from .serializers import AdminPromotionSerializer, AdminReferralSerializer - -ADMIN_PROMOTION_QUERYSET = Promotion.objects.select_related('plan', 'event').order_by('-created_at') - - -class AdminPromotionListApiView(ListAPIView): - """Read-only: every Promotion received, per user, with type/amount/date.""" - queryset = ADMIN_PROMOTION_QUERYSET - serializer_class = AdminPromotionSerializer - permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] - required_alternate_scopes = { - "GET": [["admin.promotions:retrieve"]], - } - filter_backends = [DjangoFilterBackend] - filterset_class = AdminPromotionFilter - - -class AdminPromotionDetailApiView(RetrieveAPIView): - queryset = ADMIN_PROMOTION_QUERYSET - serializer_class = AdminPromotionSerializer - lookup_field = 'uuid' - permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] - required_alternate_scopes = { - "GET": [["admin.promotions:retrieve"]], - } - - -class AdminReferralListApiView(ListAPIView): - """ - 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 - permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] - required_alternate_scopes = { - "GET": [["admin.referrals:retrieve"]], - } - filter_backends = [DjangoFilterBackend] - filterset_class = AdminReferralFilter - - def get_queryset(self): - return ADMIN_PROMOTION_QUERYSET.filter(plan__processor=ProcessorTypeChoices.REFERRAL) - - -class AdminReferralDetailApiView(RetrieveAPIView): - serializer_class = AdminReferralSerializer - lookup_field = 'uuid' - permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] - required_alternate_scopes = { - "GET": [["admin.referrals:retrieve"]], - } - - def get_queryset(self): - return ADMIN_PROMOTION_QUERYSET.filter(plan__processor=ProcessorTypeChoices.REFERRAL) -- 2.45.3 From c35910501970ef9b417e4f7af6abcb8473ba5191 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 23 Aug 2026 17:28:33 +0330 Subject: [PATCH 3/3] 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 --- apps/promotions/filters.py | 37 ++++++++++++++++++++++++++++++------ docs/adminpanel_technical.md | 34 +++++++++++++++++++++++++-------- main/urls.py | 2 +- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/apps/promotions/filters.py b/apps/promotions/filters.py index 33050b7..c7bbe77 100644 --- a/apps/promotions/filters.py +++ b/apps/promotions/filters.py @@ -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'] diff --git a/docs/adminpanel_technical.md b/docs/adminpanel_technical.md index 2c00b09..d786643 100644 --- a/docs/adminpanel_technical.md +++ b/docs/adminpanel_technical.md @@ -1,21 +1,38 @@ # 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: +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. -| File | Purpose | +| Path | 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) | +| `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_admin', namespace='promotions-admin')), +path('api/v2/promotions/admin/', include('apps.promotions.urls', namespace='promotions-admin')), ``` -No models or migrations were changed. Both endpoints are pure `ListAPIView`/`RetrieveAPIView` reads over the existing `Promotion` table. +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//` | +| `promotions-admin:admin-referrals-list` | `GET /api/v2/promotions/admin/referrals/` | +| `promotions-admin:admin-referrals-detail` | `GET /api/v2/promotions/admin/referrals//` | --- @@ -207,3 +224,4 @@ GET /api/v2/promotions/admin/referrals/?invited_by=1bb3b561-2823-4a3e-be12-edc5a - **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. diff --git a/main/urls.py b/main/urls.py index 5067a92..a0eda91 100644 --- a/main/urls.py +++ b/main/urls.py @@ -30,7 +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//', include('apps.promotions.urls_application', namespace='promotions-application')), - path('api/v2/promotions/admin/', include('apps.promotions.urls_admin', namespace='promotions-admin')), + path('api/v2/promotions/admin/', include('apps.promotions.urls', namespace='promotions-admin')), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) -- 2.45.3