Merge pull request 'feature/allow-users' (#1) from feature/allow-users into master
Reviewed-on: #1
This commit is contained in:
commit
c2a0c2f1d8
4 changed files with 401 additions and 3 deletions
322
README.md
Normal file
322
README.md
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
# Promotions
|
||||
|
||||
**Gooyal platform — service brief**
|
||||
|
||||
The reward-payout engine: it turns "a user did X" events from other Gooyal apps into real wallet deposits. This is a map of how it works internally and how it fits into the rest of the platform, written for the next person extending it.
|
||||
|
||||
`apps/promotions` · Django 5.1 · DRF · Postgres · Celery/Redis
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **It's a rules engine, not a payment processor.** Other Gooyal apps report *events* ("user created their first ad"); promotions matches them against configured *plans* and decides who gets paid what.
|
||||
- **It never moves money itself.** Every payout is a deposit submitted to the separate **wallet** service, then verified. Promotions only tracks whether that deposit succeeded.
|
||||
- **Identity is borrowed, not owned.** Users and OAuth2 applications are lazily mirrored here from the central **accounts** service the moment they're first seen.
|
||||
- **Two front doors, one engine.** A user-token API (self-service) and an application-token API (server-to-server, keyed by `user_uuid`) both funnel into the same `Plan` / `Recipient` / `Promotion` models.
|
||||
|
||||
---
|
||||
|
||||
## System map
|
||||
|
||||
Gooyal is a constellation of small Django services (accounts, wallet, notifications, advertising, chat, campaign, settlement, ipg…) that all authenticate through one central OAuth2 provider. Promotions is a consumer-facing resource server that in turn calls three of its siblings.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
classDef svc fill:#3550d6,stroke:#3550d6,color:#fff,rx:6,ry:6
|
||||
classDef ext fill:#2e7d93,stroke:#2e7d93,color:#fff,rx:6,ry:6
|
||||
classDef infra fill:#eeeeee,stroke:#999,color:#333,rx:6,ry:6
|
||||
classDef caller fill:#ffffff,stroke:#888,color:#333,rx:6,ry:6
|
||||
|
||||
subgraph Callers[" calling applications, each with its own client-credentials token "]
|
||||
direction TB
|
||||
ADV["advertising"]:::caller
|
||||
CHAT["chat"]:::caller
|
||||
CAMP["campaign"]:::caller
|
||||
SET["settlement"]:::caller
|
||||
end
|
||||
|
||||
PROMO["promotions\n(this service)"]:::svc
|
||||
|
||||
ADV --> PROMO
|
||||
CHAT --> PROMO
|
||||
CAMP --> PROMO
|
||||
SET --> PROMO
|
||||
|
||||
PROMO -- "introspect bearer token\n+ fetch user profile" --> ACC["accounts\nOAuth2 provider + identity\n(external, not in this checkout)"]:::ext
|
||||
PROMO -- "submit + verify deposit" --> WAL["wallet\napplication & user ledgers\n(sibling repo)"]:::svc
|
||||
PROMO -- "push notification\n(fire-and-forget)" --> NOT["notifications\n(external, not in this checkout)"]:::ext
|
||||
PROMO -- "queue analyze_event_task" --> RED[("Redis")]:::infra
|
||||
RED -- "Celery worker" --> PROMO
|
||||
PROMO --- PG[("Postgres")]:::infra
|
||||
```
|
||||
|
||||
Solid arrows are outbound REST calls made by promotions via generated OpenAPI clients in `utils/clients/`. Blue = Django services; teal = platform services whose source isn't checked out next to this repo.
|
||||
|
||||
---
|
||||
|
||||
## Identity & auth
|
||||
|
||||
Every request is a borrowed identity. Promotions has no login form and no password table that matters. It's a pure OAuth2 *resource server*: the **accounts** service is the one real authorization server on the platform, and every sibling service — promotions included — just validates tokens against it.
|
||||
|
||||
The `apps/gooyal_oauth2` app looks like a full OAuth2 provider (it defines `Application`, `AccessToken`, `Grant`, `RefreshToken`, `IDToken` models) but that's boilerplate shared across every Gooyal service, not a second identity system. In practice it's used as a **local cache**: `apps/gooyal_oauth2/validators.py` overrides django-oauth-toolkit's introspection so that the first time a bearer token is seen, promotions POSTs it to accounts's `/oauth2/introspect/`, then a second time to `/oauth2/introspect_application/`, and materializes the result as local `Application` / `AccessToken` rows. That's what lets `Plan.application` and `Promotion.application` be plain foreign keys instead of remote lookups on every access.
|
||||
|
||||
`apps/users.User` works the same way: a slim shadow row (UUID primary key, almost no other fields) created on first contact — either by the introspection validator (`get_or_create_user_from_content`) or explicitly in the application-token views, which call `utils/clients/accounts_client.get_user_info()` when a `user_uuid` in the URL doesn't have a local row yet.
|
||||
|
||||
Authorization itself is scope-based, checked per DRF view via two custom permission classes in `apps/gooyal_oauth2/rest_framework.py`: `IsAuthenticatedOrTokenMatchesOASRequirements` (plain endpoints) and `TokenMatchesViewSetActions` (viewsets, matches scopes per `action` rather than per HTTP method). Real scopes in use, pulled from the test suite:
|
||||
|
||||
| Scope | Grants |
|
||||
|---|---|
|
||||
| `promotions.application.event:submit` | App-to-app: submit an event on a user's behalf |
|
||||
| `promotions.application.user-plan:promote` | App-to-app: trigger promotion evaluation for a plan directly |
|
||||
| `promotions.application.user-plan:list-retrieve` | App-to-app: read plan/recipient configuration |
|
||||
| `promotions.application.user-promotions:promote` | App-to-app: create promotions via `ApplicationPromoteUserApiView` |
|
||||
| `promotions.application.user-promotions:list-retrieve` | App-to-app: list a user's promotion history |
|
||||
| `promotions.user.self-plans:list-retrieve` | End user (session or user-token): browse their own plans |
|
||||
|
||||
---
|
||||
|
||||
## Domain model
|
||||
|
||||
Everything funnels through `apps/promotions/models.py`. It's compact — five real models — but `Recipient` carries a hand-rolled mini query language that's easy to misread the first time.
|
||||
|
||||
| Model | Role |
|
||||
|---|---|
|
||||
| `EventSaver` | Registers a valid `event_label` and whether it may only ever fire once per user (`save_once`). Acts as the allow-list for incoming events. |
|
||||
| `Event` | One occurrence: `user` (raw UUID, not FK), `application`, `label`, free-form `data` JSON. GIN-indexed on `data`. |
|
||||
| `Plan` | A campaign: a `balance`, the list of event labels that trigger it (`event_list`, GIN-indexed), a banner/description for display, and one or more `Recipient`s. |
|
||||
| `Recipient` | Who gets paid from a `Plan` and how much — expressed as two small DSL strings rather than fixed fields (below). |
|
||||
| `Promotion` | One payout attempt: state machine, links back to the triggering `Event`/`Plan`/`Recipient`, drives the real wallet deposit. |
|
||||
|
||||
### The recipient DSL
|
||||
|
||||
Two `Recipient` fields are tiny expression languages evaluated against whichever `Event` and `Plan` triggered the check.
|
||||
|
||||
`recipient_uuid_field` — who gets paid:
|
||||
|
||||
| Form | Meaning |
|
||||
|---|---|
|
||||
| `<a literal UUID>` | Fixed recipient, e.g. a promo operator account. |
|
||||
| `->event:user` | The most common case: pay the user who fired the event. (`event.user` is read directly — no filter clause before the `->`.) |
|
||||
| `model:key=value&…->model:key` | Conditional form: only resolves if every `key=value` check against `event`/`plan` (or their `data` JSON via `data__key`) matches; then reads the recipient off the named object. |
|
||||
| `QS:Model:key=value&…->field` | Looks the *most recent* matching `Event`/`Plan`/`Promotion` row up by filter, then returns `field` as a literal UUID if it parses as one, else `row.data[field]`. |
|
||||
|
||||
`base_amount_field` is simpler: either a raw integer literal, or `event:key` / `plan:key` to pull the base amount out of that object's `data` JSON. `Recipient.data` then layers `promotion_percentage` (default 100) and an optional `max_promotion_amount` cap on top — see the watch list below for a live bug in that capping path.
|
||||
|
||||
---
|
||||
|
||||
## Request flow
|
||||
|
||||
From event to money in a wallet. The generic, asynchronous path: an app submits an event, promotions figures out the rest on a Celery worker. (The synchronous "promote this specific plan right now" endpoints skip the queue but land on the same `Recipient.promote()` call.)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as Calling app
|
||||
participant Promo as promotions (API)
|
||||
participant Q as Celery / Redis
|
||||
participant Wal as wallet
|
||||
participant Notif as notifications
|
||||
|
||||
App->>Promo: POST /api/v1/events/submit<br/>{label, data} + Bearer token
|
||||
Promo->>Promo: EventSaver.save_event() → Event row
|
||||
Promo-->>App: 201 Created
|
||||
Promo->>Q: analyze_event_task.delay(event.uuid)
|
||||
Q->>Promo: Event.analyze()
|
||||
Promo->>Promo: Plan.objects.related_to_event(event)
|
||||
loop each matching Plan
|
||||
Promo->>Promo: Recipient.promote(plan, event)
|
||||
Promo->>Promo: resolve DSL → recipient uuid + amount
|
||||
Promo->>Promo: Plan.reserve_promotion_amount() (atomic)
|
||||
Promo->>Wal: POST application/<wallet>/deposit/
|
||||
Wal-->>Promo: transaction uuid, state=PENDING
|
||||
Promo->>Wal: GET .../deposit/<uuid>/verify
|
||||
Wal-->>Promo: state=SUCCESS
|
||||
Promo->>Notif: POST push (best-effort, errors swallowed)
|
||||
Promo->>Promo: Promotion.state → SUCCESS
|
||||
end
|
||||
```
|
||||
|
||||
`apps/promotions/tasks.py` · `apps/promotions/models.py` (`Event.analyze`, `Plan.process_event`, `Recipient.promote`, `Promotion.promote`)
|
||||
|
||||
---
|
||||
|
||||
## Payment states
|
||||
|
||||
One state machine, defined twice. `PaymentStateChoices` in promotions and `StateChoices` in wallet share the same integer values (1–7) by convention, not by import — worth knowing before you add a state to one and not the other.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> CREATED
|
||||
CREATED --> SUCCESS: amount is 0, or plan has a balance_holder
|
||||
CREATED --> PENDING: deposit submitted to wallet
|
||||
PENDING --> SUCCESS: wallet verify returns state 5
|
||||
PENDING --> FAILED: wallet verify returns a non-success state
|
||||
PENDING --> EXPECTED_FAILURE: submit/verify call itself raised
|
||||
FAILED --> [*]
|
||||
SUCCESS --> [*]
|
||||
EXPECTED_FAILURE --> [*]
|
||||
```
|
||||
|
||||
`EXPECTED_FAILURE` means "we don't actually know" — the HTTP call to wallet errored, so promotions guesses failure but the deposit may have gone through. There's no automated reconciliation job for this state today.
|
||||
|
||||
---
|
||||
|
||||
## API surface
|
||||
|
||||
Two front doors into the same engine. User-token endpoints live under `/promotions/`; application-token endpoints live under `/api/v2/promotions/application/<user_uuid>/` for callers acting on behalf of a user they hold no session for.
|
||||
|
||||
| Path | View | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST /promotions/api/v1/events/submit` | `ApplicationEventSubmitAPIView` | token | Fire-and-forget: save the event, queue `analyze_event_task`. |
|
||||
| `GET /promotions/api/v1/events/<label>/status/` | `ApplicationEventStatusApiView` | token | Has this user already been paid for this event label, and if not, what would they get? |
|
||||
| `POST /promotions/api/v1/plans/<plan>/` | `ApplicationPromoteUserApiView` | token | Synchronous: evaluate one specific plan right now, no queue. |
|
||||
| `GET /promotions/api/v1/plans/<plan>/promotins` | `ApplicationPromotionListApiView` | token | List this user's promotion history for a plan. (Route typo — "promotins" — is load-bearing; don't casually rename.) |
|
||||
| `GET/POST /promotions/api/v2/plans/` | `UserPlanViewSet` | token | Self-service plan browsing (DRF router). |
|
||||
| `POST .../application/<user_uuid>/event/` | `ApplicationEventViewSet` | app | Same as event submit, but the caller supplies `user_uuid` instead of holding the user's own token; lazily creates the shadow `User` via accounts. |
|
||||
| `GET .../application/<user_uuid>/event/<label>/status/` | `ApplicationEventViewSet.status` | app | App-side equivalent of the status check above. |
|
||||
| `GET/POST .../application/<user_uuid>/plan/` | `ApplicationUserPlanViewSet` | app | List plans; `promote` action evaluates one on the user's behalf. |
|
||||
|
||||
---
|
||||
|
||||
## Playbook: adding a new promotion, end to end
|
||||
|
||||
Concrete steps, mirroring the real `first-ad-create` fixture in `apps/promotions/tests.py`. No code changes required for a straightforward percentage-of-nothing flat reward — it's entirely data.
|
||||
|
||||
1. **Register the trigger event.** Create an `EventSaver` row naming the event label the calling app will send, and whether it's once-per-user.
|
||||
|
||||
```python
|
||||
EventSaver.objects.create(
|
||||
event_label="ads::first-ad-create",
|
||||
title="first-ad-create",
|
||||
save_once=True, # duplicate submits raise, don't re-pay
|
||||
)
|
||||
```
|
||||
|
||||
2. **Create the plan.** Fund it with a `balance` and list the event label(s) that should trigger it. `processor` is required by the model but currently has no behavioral effect (see watch list) — `OTHERS` is the safe default.
|
||||
|
||||
```python
|
||||
Plan.objects.create(
|
||||
title="first-ad-create",
|
||||
event_list=["ads::first-ad-create"],
|
||||
balance=30_000_000,
|
||||
processor=ProcessorTypeChoices.OTHERS,
|
||||
)
|
||||
```
|
||||
|
||||
3. **Attach a recipient.** The common case: pay the user who fired the event, a flat amount, no percentage math.
|
||||
|
||||
```python
|
||||
Recipient.objects.create(
|
||||
plan=plan,
|
||||
label="first-ad-create",
|
||||
recipient_uuid_field="->event:user",
|
||||
base_amount_field="30000", # literal, or "event:data_key"
|
||||
)
|
||||
```
|
||||
|
||||
4. **Have the calling app submit the event.** Either the async application-token route, or the sync per-plan route if the app wants the result inline.
|
||||
|
||||
```http
|
||||
POST /api/v2/promotions/application/<user_uuid>/event/
|
||||
{ "label": "ads::first-ad-create", "data": {} }
|
||||
```
|
||||
|
||||
5. **Watch it resolve.** Async submits fan out through `analyze_event_task` to every `Plan` whose `event_list` matches, each producing a `Promotion` row you can inspect in admin or via the status endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Watch list
|
||||
|
||||
Know these before you touch the calculation path. Ranked by how badly they'll surprise you, not by file order.
|
||||
|
||||
### 🔴 crash — Setting `max_promotion_amount` breaks amount calculation
|
||||
|
||||
`apps/promotions/models.py` — `Recipient.get_promotion_amount()`, lines ~276–307
|
||||
|
||||
`get_promotion_amount()` returns a single `int` when `max_promotion_amount` is set on `Recipient.data`, but returns a `(base_amount, promotion_amount)` tuple otherwise. Both call sites — `Recipient.promote()` and `Plan.get_configured_promotion_amount()` — unconditionally do `base_amount, promotion_amount = recipient.get_promotion_amount(...)`. The capped branch raises `TypeError: cannot unpack non-iterable int object`.
|
||||
|
||||
**Fix:** never set `max_promotion_amount` on a live `Recipient` today. If you need a cap, make `get_promotion_amount` always return the tuple and clamp `promotion_amount` inside it instead of short-circuiting.
|
||||
|
||||
### 🟠 correctness + perf — `event_list__icontains` is a substring text match, not JSON containment
|
||||
|
||||
`apps/promotions/models.py` — `PlanQuerySet.related_to_event()`, `get_event_status_for_user()`
|
||||
|
||||
Both matching paths filter with `event_list__icontains=event.label`. On a JSONField this casts to text and does a case-insensitive `LIKE` — it does not use the `GinIndex` already defined on `event_list`, and it will false-positive match a plan for `"ads::first-ad-create-v2"` when the incoming label is `"ads::first-ad-create"`.
|
||||
|
||||
**Fix:** use `event_list__contains=[event.label]` (jsonb `@>`), which is both exact and index-backed.
|
||||
|
||||
### ⚪ dead code — the processor/handler abstraction isn't wired up
|
||||
|
||||
`apps/promotions/handlers.py`, `models.py Plan.process_event()`
|
||||
|
||||
`Plan.process_event()` ignores `self.processor` entirely and always calls `promote_all()`. `PercentageHandler` and `ReferralHandler` in `handlers.py` both `return` before their real logic and reference an undefined `self.balance` — they're unreachable and would error if called.
|
||||
|
||||
**Fix:** if a new promotion type needs calculation logic different from the generic percentage-with-cap in `Recipient.get_promotion_amount`, this is the intended seam — but it needs to be actually dispatched from `process_event`, not assumed to work.
|
||||
|
||||
### ⚪ duplication — `views_user.py` and `views_application.py` largely repeat each other
|
||||
|
||||
`apps/promotions/views_user.py`, `views_application.py`
|
||||
|
||||
`ApplicationPromoteUserApiView`, `ApplicationPromotionListApiView`, and `ApplicationEventSubmitAPIView` are near-identical copies across both files. `get_queryset()`/`_resolve_user()` also mutate `self.request.user` as a side effect, which is easy to miss when tracing a bug.
|
||||
|
||||
**Fix:** before adding a third variant of any of these, factor the shared body into a mixin — otherwise a fix applied to one silently doesn't apply to its twin.
|
||||
|
||||
### ⚪ idempotency — `Promotion.get_or_create` keys on computed amounts, not just identity
|
||||
|
||||
`apps/promotions/models.py` — `Recipient.promote()`, lines ~319–326
|
||||
|
||||
`get_or_create(promotion_amount=..., user_uuid=..., event=..., plan=..., recipient=..., base_amount=...)` has no `defaults=`; every field is part of the lookup. If the amount calculation for the same (user, event, plan, recipient) ever produces a different number on a retry — a config change mid-flight, or the bug above getting fixed — you get a second `Promotion` row instead of a duplicate-prevented one.
|
||||
|
||||
**Fix:** the real duplicate guard is `EventSaver.save_once` one layer up; don't rely on this `get_or_create` for idempotency if you change how amounts are computed.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**apps/promotions — the engine**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `models.py` | Every entity above, plus the recipient DSL and the payment state machine. |
|
||||
| `handlers.py` | Unwired processor/handler scaffolding — see watch list. |
|
||||
| `views_user.py` / `urls_user.py` | User-token API, mounted at `/promotions/`. |
|
||||
| `views_application.py` / `urls_application.py` | Application-token API, mounted at `/api/v2/promotions/application/<user_uuid>/`. |
|
||||
| `serializers.py` | Thin DRF serializers; business logic stays in models.py. |
|
||||
| `tasks.py` | One Celery task: `analyze_event_task`. |
|
||||
| `tests.py` | APITestCase flows with wallet calls mocked — the best template for testing a new plan. |
|
||||
|
||||
**apps/gooyal_oauth2 & apps/users — borrowed identity**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `gooyal_oauth2/validators.py` | Custom introspection against the central accounts service; materializes local Application/AccessToken rows. |
|
||||
| `gooyal_oauth2/rest_framework.py` | Scope-checking DRF permission classes used across every view. |
|
||||
| `users/models.py` | Shadow `User` — UUID PK, created on first contact, no local auth. |
|
||||
|
||||
**utils/clients — outbound calls to siblings**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `wallet_client.py` | Deposit submit/verify, withdraw, wallet balance lookups. |
|
||||
| `accounts_client.py` | User profile + application detail lookups. |
|
||||
| `notifications_client.py` | Push + email, both best-effort. |
|
||||
| `gooyal_*_client/` | Generated openapi-python-client SDKs per README.srt — regenerate, don't hand-edit. |
|
||||
|
||||
---
|
||||
|
||||
## Config reference
|
||||
|
||||
Non-secret values only — pulled from `main/settings.py` and this checkout's own `.env`.
|
||||
|
||||
| Variable | Points at |
|
||||
|---|---|
|
||||
| `OAUTH2_PROVIDER_PUBLIC_URL` / `_PRIVATE_URL` | Central accounts service's OAuth2 endpoints (token issuance, introspection). Points at the accounts service's staging environment — see this checkout's own `.env` for the actual hostname. |
|
||||
| `ACCOUNTS_BASE_PUBLIC_URL` | Accounts service REST API (user/application profile reads). |
|
||||
| `WALLET_BASE_PUBLIC_URL` | Wallet service REST API (deposit submit/verify). |
|
||||
| `WALLET_RIAL` / `WALLET_REWARD` | UUIDs of specific `Wallet` (currency pool) rows in the wallet service — not amounts. `WALLET_REWARD` is the pool promotions pays out of; passed as both the deposit's `payer_wallet` and its `payee_wallet`, since payer (this app's pool) and payee (the user) share one currency. |
|
||||
| `NOTIFICATIONS_BASE_PUBLIC_URL` | Notifications service REST API. |
|
||||
| `OAUTH2_CLIENT_ID` / `_SECRET` / `_SCOPES` | This service's own client-credentials identity, used for every outbound call above via `login_as_client_credentials()` (token cached under the key `promotions_access_token`). |
|
||||
|
||||
---
|
||||
|
||||
*Compiled by reading `apps/promotions`, `apps/gooyal_oauth2`, `apps/users`, `utils/clients` in this checkout, cross-referenced against the sibling `wallet` repo and the `.env` files of `chat`/`advertising`/`campaign` under `Winsoo/` to confirm which services are real network boundaries versus copy-pasted boilerplate. The `accounts` and `notifications` services are referenced only through their generated API clients — their source isn't checked out locally.*
|
||||
|
|
@ -2,7 +2,7 @@ from functools import update_wrapper
|
|||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Promotion, Plan, Event, Recipient, EventSaver
|
||||
from .models import Promotion, Plan, Event, Recipient, EventSaver, AllowedUser
|
||||
|
||||
class PromotionAdmin(admin.ModelAdmin):
|
||||
list_display = ['user_uuid', 'plan', 'promotion_amount', 'recipient', 'event', 'created_at']
|
||||
|
|
@ -10,9 +10,13 @@ class PromotionAdmin(admin.ModelAdmin):
|
|||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ['uuid', 'title', 'balance']
|
||||
|
||||
class AllowedUserAdmin(admin.ModelAdmin):
|
||||
list_display = ['user', 'recipient', 'created_at']
|
||||
|
||||
admin.site.register(Plan, PlanAdmin)
|
||||
admin.site.register(Promotion, PromotionAdmin)
|
||||
admin.site.register(Event)
|
||||
admin.site.register(EventSaver)
|
||||
admin.site.register(Recipient)
|
||||
admin.site.register(AllowedUser, AllowedUserAdmin)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('promotions', '0008_plan_balance_holder_alter_plan_description_details'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipient',
|
||||
name='access_type',
|
||||
field=models.CharField(choices=[('restricted', 'restricted'), ('public', 'public')],
|
||||
db_index=True, default='public', max_length=64, verbose_name='access type'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AllowedUser',
|
||||
fields=[
|
||||
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True,
|
||||
serialize=False, unique=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='allowed_recipients', to=settings.AUTH_USER_MODEL)),
|
||||
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='allowed_users', to='promotions.recipient')),
|
||||
],
|
||||
options={
|
||||
'constraints': [
|
||||
models.UniqueConstraint(fields=('user', 'recipient'), name='unique_allowed_user_recipient'),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -135,9 +135,11 @@ class Plan(BaseModel):
|
|||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_configured_promotion_amount(self):
|
||||
def get_configured_promotion_amount(self, user_uuid=None):
|
||||
amount = 0
|
||||
for recipient in self.recipients.filter(recipient_uuid_field='->event:user'): #TODO: instead of hard code use better soloution
|
||||
if not recipient.is_user_allowed(user_uuid):
|
||||
continue
|
||||
base_amount, promotion_amount = recipient.get_promotion_amount()
|
||||
amount += promotion_amount
|
||||
return amount
|
||||
|
|
@ -180,12 +182,19 @@ class Plan(BaseModel):
|
|||
return updated
|
||||
|
||||
|
||||
class RecipientTypeChoices(models.TextChoices):
|
||||
RESTRICTED = 'restricted', _('restricted')
|
||||
PUBLIC = 'public', _('public')
|
||||
|
||||
|
||||
class Recipient(BaseModel):
|
||||
label = models.CharField(max_length=255, db_index=True)
|
||||
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name='recipients', null=True, blank=True)
|
||||
wallet_uuid = models.UUIDField(null=True, blank=True)
|
||||
promotion_type = models.CharField(max_length=64, verbose_name=_('promotion type'), db_index=True,
|
||||
choices=PromotionTypeChoices.choices, null=True, blank=True)
|
||||
access_type = models.CharField(max_length=64, verbose_name=_('access type'), db_index=True,
|
||||
choices=RecipientTypeChoices.choices, default=RecipientTypeChoices.PUBLIC)
|
||||
|
||||
recipient_uuid_field = models.CharField(max_length=255, db_index=True)
|
||||
base_amount_field = models.CharField(max_length=255, db_index=True)
|
||||
|
|
@ -207,6 +216,13 @@ class Recipient(BaseModel):
|
|||
def get_wallet_category_uuid(self):
|
||||
return self.wallet_uuid or settings.WALLET_PROMOTION_CATEGORY_UUID
|
||||
|
||||
def is_user_allowed(self, user_uuid):
|
||||
if self.access_type != RecipientTypeChoices.RESTRICTED:
|
||||
return True
|
||||
if not user_uuid:
|
||||
return False
|
||||
return self.allowed_users.filter(user__uuid=user_uuid).exists()
|
||||
|
||||
def get_recipient_uuid(self, plan=None, event=None):
|
||||
if ':' in self.recipient_uuid_field and not self.recipient_uuid_field.startswith('QS:'):
|
||||
filters_key_values, recipient_candidate = self.recipient_uuid_field.split('->')
|
||||
|
|
@ -315,6 +331,9 @@ class Recipient(BaseModel):
|
|||
if not recipient:
|
||||
return None
|
||||
|
||||
if not self.is_user_allowed(recipient):
|
||||
return None
|
||||
|
||||
base_amount, promotion_amount = self.get_promotion_amount(plan=plan, event=event)
|
||||
promotion, created = Promotion.objects.get_or_create(
|
||||
promotion_amount=promotion_amount,
|
||||
|
|
@ -341,6 +360,19 @@ class Recipient(BaseModel):
|
|||
return None
|
||||
|
||||
|
||||
class AllowedUser(BaseModel):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='allowed_recipients')
|
||||
recipient = models.ForeignKey(Recipient, on_delete=models.CASCADE, related_name='allowed_users')
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'recipient'], name='unique_allowed_user_recipient')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} -> {self.recipient}"
|
||||
|
||||
|
||||
class PaymentStateChoices(models.IntegerChoices):
|
||||
CREATED = 1, _('created')
|
||||
DELAYED = 2, _('delayed') # delayed as user wish
|
||||
|
|
@ -365,7 +397,7 @@ def get_event_status_for_user(user_uuid, event_label):
|
|||
if not processed:
|
||||
plan = Plan.objects.filter(event_list__icontains=event_label).first()
|
||||
if plan:
|
||||
promotion_amount = plan.get_configured_promotion_amount()
|
||||
promotion_amount = plan.get_configured_promotion_amount(user_uuid=user_uuid)
|
||||
return {
|
||||
'event_label': event_label,
|
||||
'processed': processed,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue