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>
357 lines
13 KiB
Python
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)
|
|
#
|