promotions/apps/promotions/views_application.py
Ali Asadi ea070ad293 FEATURE(promotions): wallet-transaction ledger + reversible payouts
The promotions service had no record of the wallet transfers it makes --
the Promotion row's state was the only trace. Add PromotionTransaction, a
ledger row per transfer (mirrors advertising's AdPayment / escrow's
EscrowWalletPayment): PAYOUT (promotions credit -> recipient wallet) and
ROLLBACK (advertising transit -> promotions credit).

- Promotion.promote() now drives its payout through a PromotionTransaction
  PAYOUT row (.execute() does the submit/verify dance) instead of an
  inline, unrecorded wallet call.
- rollback_promotion_payout(user, event_label): reverses a payout that
  landed in the advertising transit wallet, back to the promotions credit
  wallet, when the advertising side discards what it paid for (e.g. a
  captured billboard deleted while pending approval). The Promotion stays
  consumed -- only the money moves; payouts straight to the user's wallet
  are not reversible. Idempotent; returns reversed | deferred | nothing.
- The ROLLBACK row doubles as the async-race marker: when the request
  arrives before the Celery payout task has run, a ROLLBACK row is
  recorded and Recipient.promote() suppresses (or, if it raced, reverses)
  the payout. Replaces the separate PromotionRollback table from the first
  cut of this change.
- POST .../application/<user>/event/<event_label>/rollback/
- migration 0011 (hand-written; verified via makemigrations --dry-run + check)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 15:07:37 +03:30

357 lines
13 KiB
Python

from drf_spectacular.utils import extend_schema
from rest_framework import mixins, status
from rest_framework.decorators import action
from rest_framework import exceptions
from rest_framework.exceptions import NotFound
from rest_framework.generics import CreateAPIView, get_object_or_404, RetrieveAPIView, ListAPIView
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.viewsets import GenericViewSet
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements, TokenMatchesViewSetActions
from apps.gooyal_oauth2.utils import get_application
from utils.clients.accounts_client import get_user_info
from utils.exceptions import UnprocessableEntity
from .models import Plan, Promotion, EventSaver, get_event_status_for_user, rollback_promotion_payout
from .serializers import (
PlanSerializer,
PromotionSerializer,
EventSerializer,
PromoteSerializer,
UserPlanSerializer,
PromotionStatusSerializer,
PromotionRollbackSerializer,
)
from .tasks import analyze_event_task
from ..users.models import User
from django.utils.translation import gettext as _
class ApplicationUserPlanViewSet(
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
# mixins.CreateModelMixin,
GenericViewSet
):
serializer_class = UserPlanSerializer
permission_classes = [TokenMatchesViewSetActions]
required_action_scopes = {
"promote": [["promotions.application.user-plan:promote"]],
"retrieve": [["promotions.application.user-plan:list-retrieve"]],
"list": [["promotions.application.user-plan:list-retrieve"]],
}
def get_queryset(self):
application = get_application(self.request)
if not self.request.user:
user_uuid = self.kwargs.get('user_uuid')
user = User.objects.filter(uuid=user_uuid).first()
if not user:
user_info = get_user_info(user_uuid)
if user_info:
user, created = User.objects.get_or_create(uuid=user_uuid)
if not user:
raise NotFound(_('user {user_uuid} not found').format(user_uuid=user_uuid))
self.request.user = user
# return Promotion.objects.filter(application=application)
return Plan.objects.all()
# def perform_create(self, serializer):
# user = self.get_user()
# application = get_application(self.request)
#
# plan = serializer.validated_data['plan']
#
# promotion_args = dict(user=user, application=application, **serializer.validated_data)
# promotion_amount = plan.calculate_promotion(**promotion_args)
# if promotion_amount:
# promotion = serializer.save(promotion_amount=promotion_amount, user=user, application=application)
# promotion.promote(**promotion_args)
@action(detail=True, methods=['POST'], serializer_class=PromoteSerializer)
def promote(self, request, *args, pk=None, **kwargs):
plan = self.get_object()
application = get_application(self.request)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
# TODO
# try:
event = event_saver.save_event(user=self.request.user.pk, application=application, **serializer.validated_data)
# except Exception as e:
# raise exceptions.ValidationError(str(e))
result_list = plan.process_event(event)
data["promotions"] = result_list
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_success_headers(self, data):
try:
return {'Location': str(data[api_settings.URL_FIELD_NAME])}
except (TypeError, KeyError):
return {}
class ApplicationEventViewSet(
mixins.CreateModelMixin,
GenericViewSet
):
serializer_class = EventSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"POST": [["promotions.application.event:submit"]],
"GET": [["promotions.application.event:submit"]],
}
def _resolve_user(self):
user = getattr(self.request, 'user', None)
if user and getattr(user, 'uuid', None):
return user
user_uuid = self.kwargs.get('user_uuid')
user = User.objects.filter(uuid=user_uuid).first()
if not user:
user_info = get_user_info(user_uuid)
if user_info:
user, _ = User.objects.get_or_create(uuid=user_uuid)
if not user:
raise NotFound(_('user {user_uuid} not found').format(user_uuid=user_uuid))
self.request.user = user
return user
@action(
detail=False,
methods=['GET'],
url_path=r'(?P<event_label>.+)/status',
serializer_class=PromotionStatusSerializer,
)
def status(self, request, user_uuid=None, event_label=None):
user = self._resolve_user()
serializer = self.get_serializer(get_event_status_for_user(user.uuid, event_label))
return Response(serializer.data)
@action(
detail=False,
methods=['POST'],
url_path=r'(?P<event_label>.+)/rollback',
serializer_class=PromotionRollbackSerializer,
)
def rollback(self, request, user_uuid=None, event_label=None):
"""Pull a promotion's payout back out of the advertising transit wallet.
Idempotent. The promotion stays "used" -- only the money is returned,
and only if it landed in the transit wallet (a payout straight to the
user's wallet is not reversible). If the payout has not been processed
yet the request is recorded and the payout is suppressed when it runs.
"""
user = self._resolve_user()
state, amount = rollback_promotion_payout(user.uuid, event_label)
serializer = self.get_serializer({
'event_label': event_label,
'status': state,
'amount': amount,
})
return Response(serializer.data)
def perform_create(self, serializer: EventSerializer):
user = self._resolve_user()
application = get_application(self.request)
# EventSaver.objects.first(event_label=serializer.validated_data['event_label'], valid_application__contains=application.uuid)
event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
try:
event = event_saver.save_event(user=user.uuid, application=application, **serializer.validated_data)
except Exception as e:
raise UnprocessableEntity(str(e))
analyze_event_task.delay(event.uuid)
serializer.instance = event
class ApplicationPlanViewSet(
GenericViewSet
):
serializer_class = PlanSerializer
permission_classes = [TokenMatchesViewSetActions]
required_action_scopes = {
"retrieve": [["promotions.application.plan:list-retrieve"]],
"list": [["promotions.application.plan:list-retrieve"]],
}
def get_queryset(self):
application = get_application(self.request)
# return Promotion.objects.filter(application=application)
return Plan.objects.all()
# NOT
class UserPlanViewSet(mixins.RetrieveModelMixin,
# mixins.ListModelMixin,
# mixins.CreateModelMixin,
GenericViewSet):
serializer_class = UserPlanSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
# required_alternate_scopes = {
# "GET": [["promotions.user.plans:list-retrieve"]],
# "POST": [["promotions.user.plans:list-retrieve"]],
# }
required_alternate_scopes = {
"GET": [[]],
"POST": [[]],
}
def get_queryset(self):
user = self.request.user
application = get_application(self.request)
queryset = Plan.objects.all()
return queryset
# @extend_schema(responses=PlanSerializer(many=True))
# @action(detail=False, methods=['POST'], serializer_class=PlanSerializer)
# def available(self, request):
# serializer = self.get_serializer(data=self.request.data)
# serializer.is_valid(raise_exception=True)
# print(serializer.validated_data)
# plans = self.paginate_queryset(self.filter_queryset(self.get_queryset()))
# for plan in plans:
# plan.calculate_promotion(**serializer.validated_data)
# serializer = self.get_serializer(plans, many=True)
# return self.get_paginated_response(serializer.data)
class ApplicationPromoteUserApiView(CreateAPIView):
model = Promotion
serializer_class = PromoteSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"POST": [[]],
}
def get_plan(self):
plan_uuid = self.kwargs.get('plan')
plan = get_object_or_404(Plan, uuid=plan_uuid)
return plan
def create(self, request, *args, **kwargs):
plan = self.get_plan()
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
application = get_application(self.request)
event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
user = self.request.user.pk or serializer.validated_data['user']
# try:
event = event_saver.save_event(user=user, application=application, **serializer.validated_data)
# except Exception as e:
# raise exceptions.ValidationError(str(e))
result_list = list(plan.process_event(event))
# data["promotions"] = list(result_list)
serializer = self.get_serializer(instance=plan)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class ApplicationPromotionListApiView(ListAPIView):
model = Promotion
serializer_class = PromotionSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"GET": [[]],
}
def get_plan(self):
plan_uuid = self.kwargs.get('plan')
plan = get_object_or_404(Plan, uuid=plan_uuid)
return plan
def get_queryset(self):
user = self.request.user
plan = self.get_plan()
qs = Promotion.objects.filter(user_uuid=user.uuid, plan=plan)
return qs
# def create(self, request, *args, **kwargs):
# plan = self.get_plan()
# serializer = self.get_serializer(data=request.data)
# serializer.is_valid(raise_exception=True)
# data = serializer.validated_data
# application = get_application(self.request)
# event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
# # try:
# event = event_saver.save_event(user=None, application=application, **serializer.validated_data)
# # except Exception as e:
# # raise exceptions.ValidationError(str(e))
#
# result_list = plan.process_event(event)
# data["promotions"] = result_list
#
# headers = self.get_success_headers(serializer.data)
# return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class ApplicationEventSubmitAPIView(CreateAPIView):
model = Promotion
serializer_class = PromoteSerializer
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"POST": [[]],
}
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
application = get_application(self.request)
event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
user = self.request.user.pk or serializer.validated_data['user']
try:
event = event_saver.save_event(user=user, application=application, **serializer.validated_data)
except Exception as e:
raise UnprocessableEntity(str(e))
analyze_event_task.delay(event.uuid)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
# class ApplicationEventRetrieveAPIView(RetrieveAPIView):
# model = Promotion
# serializer_class = PromoteSerializer
#
# def create(self, request, *args, **kwargs):
# serializer = self.get_serializer(data=request.data)
# serializer.is_valid(raise_exception=True)
# data = serializer.validated_data
# application = get_application(self.request)
# event_saver: EventSaver = EventSaver.objects.filter(event_label=serializer.validated_data['label']).first()
# event = event_saver.save_event(user=None, application=application, **serializer.validated_data)
#
# analyze_event_task.delay(event.uuid)
#
# headers = self.get_success_headers(serializer.data)
# return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
#