From 09ba9ff64321c8c1516675f1dc428a49d083b580 Mon Sep 17 00:00:00 2001 From: Rohan Gupta Date: Wed, 6 Jul 2022 04:10:47 -0700 Subject: [PATCH] holding, validation improvements, perms --- .../migrations/0093_auto_20220706_0548.py | 51 +++ backend/clubs/models.py | 14 + backend/clubs/permissions.py | 11 +- backend/clubs/views.py | 417 +++++++++--------- 4 files changed, 292 insertions(+), 201 deletions(-) create mode 100644 backend/clubs/migrations/0093_auto_20220706_0548.py diff --git a/backend/clubs/migrations/0093_auto_20220706_0548.py b/backend/clubs/migrations/0093_auto_20220706_0548.py new file mode 100644 index 000000000..3a2c38eda --- /dev/null +++ b/backend/clubs/migrations/0093_auto_20220706_0548.py @@ -0,0 +1,51 @@ +# Generated by Django 3.2.8 on 2022-07-06 09:48 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("clubs", "0092_auto_20220211_1732"), + ] + + operations = [ + migrations.RemoveField( + model_name="ticket", + name="held", + ), + migrations.AddField( + model_name="ticket", + name="holder", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="held_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="cart", + name="owner", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="cart", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="ticket", + name="owner", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="owned_tickets", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 8fd25c9f4..a9f90de55 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1716,6 +1716,19 @@ class Cart(models.Model): ) +class TicketManager(models.Manager): + + # Update holds for all tickets + def update_holds(self): + expired_tickets = self.select_for_update().filter( + holder__isnull=False, holding_expiration__lte=timezone.now() + ) + with transaction.atomic(): + for ticket in expired_tickets: + ticket.holder = None + self.bulk_update(expired_tickets, ["holder"]) + + class Ticket(models.Model): """ Represents an instance of a ticket for an event @@ -1742,6 +1755,7 @@ class Ticket(models.Model): ) holding_expiration = models.DateTimeField(null=True, blank=True) carts = models.ManyToManyField(Cart, related_name="tickets", blank=True) + objects = TicketManager() def get_qr(self): """ diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 09117f98a..4297d9fcc 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -188,7 +188,7 @@ def has_permission(self, request, view): class EventPermission(permissions.BasePermission): """ - Officers and above can create/update/delete events. + Officers and above can create/update/delete events and view ticket buyers. Everyone else can view and list events. """ @@ -224,7 +224,14 @@ def has_object_permission(self, request, view, obj): if not old_type == FAIR_TYPE and new_type == FAIR_TYPE: return False - + elif view.action in ["buyers", "create_tickets"]: + if not request.user.is_authenticated: + return False + membership = find_membership_helper(request.user, obj.club) + return membership is not None and membership.role <= Membership.ROLE_OFFICER + elif view.action in ["add_to_cart", "remove_from_cart"]: + return request.user.is_authenticated + print("action", view.action) return True diff --git a/backend/clubs/views.py b/backend/clubs/views.py index fe3e1eb39..5b23f0ae3 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -8,6 +8,7 @@ import re import secrets import string +from functools import wraps from urllib.parse import urlparse import pytz @@ -171,6 +172,19 @@ from clubs.utils import fuzzy_lookup_club, html_to_text +def update_holds(func): + """ + Decorator to update ticket holds + """ + + @wraps(func) + def wrap(self, request, *args, **kwargs): + Ticket.objects.update_holds() + return func(self, request, *args, **kwargs) + + return wrap + + def file_upload_endpoint_helper(request, code): obj = get_object_or_404(Club, code=code) if "file" in request.data and isinstance(request.data["file"], UploadedFile): @@ -1561,7 +1575,6 @@ def booths(self, request, *args, **kwargs): """ club = self.get_object() res = ClubFairBooth.objects.filter(club=club).select_related("club").all() - return Response(ClubBoothSerializer(res, many=True).data) def get_operation_id(self, **kwargs): @@ -2221,36 +2234,9 @@ def get_serializer_class(self): return EventWriteSerializer return EventSerializer - def update_holds(self): - """ - Update ticket holds for *all* tickets - """ - held_tickets = Ticket.objects.filter(holder__isnull=False).all() - for ticket in held_tickets: - if ticket.holding_expiration <= timezone.now(): - ticket.holder = None - ticket.save() - - @action(detail=False, methods=["get"]) - def view_cart(self): - """ - View contents of the cart - --- - requestBody: {} - responses: - "200": - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Cart" - - """ - cart = Cart.objects.get_or_create(owner=self.request.user) - return None # Response(CartSerializer(cart.data)) - @action(detail=True, methods=["post"]) @transaction.atomic + @update_holds def add_to_cart(self, request, *args, **kwargs): """ Add a certain number of tickets to the cart @@ -2282,13 +2268,12 @@ def add_to_cart(self, request, *args, **kwargs): type: string --- """ - self.update_holds() type = request.data.get("type") count = request.data.get("count") event = self.get_object() cart = Cart.objects.get_or_create(owner=self.request.user) - # count unowned/unheld tickets of requested type + # Count unowned/unheld tickets of requested type tickets = ( Ticket.objects.select_for_update(skip_locked=True) .filter(event=event, type=type, owner__isnull=True, holder__isnull=True) @@ -2307,6 +2292,7 @@ def add_to_cart(self, request, *args, **kwargs): @action(detail=True, methods=["post"]) @transaction.atomic + @update_holds def remove_from_cart(self, request, *args, **kwargs): """ Remove a certain type/number of tickets from the cart @@ -2332,10 +2318,9 @@ def remove_from_cart(self, request, *args, **kwargs): --- """ - self.update_holds() type = request.data.get("type") event = self.get_object() - cart = get_object_or_404(owner=self.request.user) + cart = get_object_or_404(Cart, owner=self.request.user) tickets = cart.tickets.filter(type=type, event=event) # Ensure we don't try to remove more tickets than we can @@ -2346,101 +2331,10 @@ def remove_from_cart(self, request, *args, **kwargs): return Response({"detail": "Successfully removed from cart"}) - @action(detail=False, methods=["post"]) - @transaction.atomic - def validate_cart(self, request, *args, **kwargs): - """ - Validate tickets in a cart - --- - requestBody: - content: - application/json: - schema: - responses: - "200": - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Ticket" - --- - """ - self.update_holds() - - cart = get_object_or_404(Cart, owner=self.request.user) - sold_out_flag = False - - for ticket in cart.tickets.all(): - # if ticket in cart has been bought, try to replace - if ticket.owner or ticket.holder: - # lock new ticket until transaction is completed - new_ticket = ( - Ticket.objects.select_for_update(skip_locked=True) - .filter( - event=ticket.event, - type=ticket.type, - owner__isnull=True, - holder__isnull=True, - ) - .first() - ) - cart.tickets.remove(ticket) - if new_ticket: - cart.tickets.add(new_ticket) - else: - sold_out_flag = True - cart.save() - - return Response( - {"detail": "Validated" + ("" if not sold_out_flag else " with changes")} - ) - - @action(detail=False, methods=["post"]) - @transaction.atomic - def checkout(self, request, *args, **kwargs): - """ - Checkout all tickets in cart, to be called after validate_cart - - NOTE: this does NOT buy tickets, it simply initiates a checkout process - which includes a 10-minute ticket hold - --- - requestBody: - content: - application/json: - schema: - type: object - - responses: - "200": - content: - application/json: - schema: - properties: - detail: - type: string - --- - """ - cart = get_object_or_404(Cart, owner=self.request.user) - - # The assumption is that this filter query should return all tickets in the cart (if run after validate_cart); - # however we cannot guarantee atomicity between validate_cart and checkout - # - # customers will be prompted to review the cart before payment - - for ticket in cart.tickets.select_for_update().filter( - owner__isnull=True, holder__isnull=True - ): - ticket.holder = self.request.user - ticket.holding_expiration = timezone.now() + datetime.timedelta(minutes=10) - ticket.save() - - return Response({"detail": "Successfully initated checkout"}) - - @action(detail=False, methods=["post"]) - @transaction.atomic - def checkout_success_callback(self, request, *args, **kwargs): + @action(detail=True, methods=["get"]) + def buyers(self, request, *args, **kwargs): """ - Callback after third party payment succeeds + Get information about ticket buyers --- requestBody: {} responses: @@ -2448,48 +2342,32 @@ def checkout_success_callback(self, request, *args, **kwargs): content: application/json: schema: - properties: - detail: - type: string - - """ - cart = get_object_or_404(Cart, owner=self.request.user) - - for ticket in cart.tickets.select_for_update().all(): - ticket.owner = request.user - ticket.carts.remove(cart) - # ticket.send_confirmation_email() - ticket.save() - - return Response({"detail": "callback successful"}) - - @action(detail=True, methods=["post"]) - @transaction.atomic - def buy(self, request, *args, **kwargs): - """ - Buy tickets in a cart - --- - requestBody: - content: {} - responses: - "200": - content: - application/json: - schema: - allOf: - - $ref: "#/components/schemas/Ticket" + type: object + properties: + buyers: + type: array + items: + type: object + properties: + fullname: + type: string + id: + type: string + owner_id: + type: int + type: + type: string --- """ + tickets = Ticket.objects.filter(event=self.get_object()).annotate( + fullname=Concat("owner__first_name", Value(" "), "owner__last_name") + ) - # Some logic here to serialize all held tickets down to whatever - # format third party asks for - - cart = get_object_or_404(Cart, owner=self.request.user) - - for ticket in cart.tickets.filter(holder=self.request.user): - pass + buyers = tickets.filter(owner__isnull=False).values( + "id", "fullname", "owner_id", "type" + ) - pass + return Response({"buyers": buyers}) @action(detail=True, methods=["get"]) def tickets(self, request, *args, **kwargs): @@ -2504,17 +2382,6 @@ def tickets(self, request, *args, **kwargs): schema: type: object properties: - buyers: - type: array - items: - type: object - properties: - fullname: - type: string - id: - type: string - type: - type: string totals: type: array items: @@ -2536,10 +2403,9 @@ def tickets(self, request, *args, **kwargs): --- """ event = self.get_object() - tickets = Ticket.objects.filter(event=event).annotate( - fullname=Concat("owner__first_name", Value(" "), "owner__last_name") - ) + tickets = Ticket.objects.filter(event=event) types = tickets.values_list("type", flat=True).distinct() + totals = [] available = [] @@ -2552,9 +2418,7 @@ def tickets(self, request, *args, **kwargs): } ) - buyers = tickets.filter(owner__isnull=False).values("id", "fullname", "type") - - return Response({"totals": totals, "available": available, "buyers": buyers}) + return Response({"totals": totals, "available": available}) @tickets.mapping.put def create_tickets(self, request, *args, **kwargs): @@ -2583,24 +2447,19 @@ def create_tickets(self, request, *args, **kwargs): properties: detail: type: string - description: Empty array for success """ event = self.get_object() quantities = request.data.get("quantities") - membership = find_membership_helper(request.user, event.club) - if membership.role <= 10: # Create tickets allowed if officer+ + print(request.method) - Ticket.objects.filter(event=event).delete() # Idempotency + Ticket.objects.filter(event=event).delete() # Idempotency - for item in quantities: - for _ in range(item["count"]): - Ticket.objects.create(event=event, type=item["type"]) - return Response([]) - else: - return Response( - {"detail": "Unauthorized"}, status=status.HTTP_401_UNAUTHORIZED - ) + for item in quantities: + for _ in range(item["count"]): + Ticket.objects.create(event=event, type=item["type"]) + + return Response({"detail": "success"}) @action(detail=True, methods=["post"]) def upload(self, request, *args, **kwargs): @@ -4599,11 +4458,17 @@ def get_object(self): class TicketViewSet(viewsets.ModelViewSet): """ - list: + get: List all tickets owned by user - retrieve: - Retrieve an individual ticket's data + cart: + List all unowned/unheld tickets currently in user's cart + + checkout: + Initiate a hold on the tickets in a user's cart + + buy: + Buy the tickets in a user's cart qr: Get a ticket's QR code @@ -4614,8 +4479,162 @@ class TicketViewSet(viewsets.ModelViewSet): http_method_names = ["get", "post"] lookup_field = "id" - def retrieve(self, request, *args, **kwargs): - return Response(TicketSerializer(Ticket.objects.get(id=kwargs["id"])).data) + @transaction.atomic + @update_holds + @action(detail=False, methods=["get"]) + def cart(self, request, *args, **kwargs): + """ + Validate tickets in a cart + --- + requestBody: + content: {} + responses: + "200": + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Ticket" + "204": + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Ticket" + --- + """ + + cart, _ = Cart.objects.get_or_create(owner=self.request.user) + + # this flag is true when a validate operation fails to + # replace a ticket. return 200 if true, otherwise 204 + sold_out_flag = False + + for ticket in cart.tickets.all(): + # if ticket in cart has been bought, try to replace + if ticket.owner or ticket.holder: + # lock new ticket until transaction is completed + new_ticket = ( + Ticket.objects.select_for_update(skip_locked=True) + .filter( + event=ticket.event, + type=ticket.type, + owner__isnull=True, + holder__isnull=True, + ) + .first() + ) + cart.tickets.remove(ticket) + if new_ticket: + cart.tickets.add(new_ticket) + else: + sold_out_flag = True + cart.save() + + return Response( + TicketSerializer(cart.tickets.all(), many=True).data, + status=200 if sold_out_flag else 204, + ) + + @action(detail=False, methods=["post"]) + @update_holds + @transaction.atomic + def checkout(self, request, *args, **kwargs): + """ + Checkout all tickets in cart + + NOTE: this does NOT buy tickets, it simply initiates a checkout process + which includes a 10-minute ticket hold + --- + requestBody: + content: + application/json: + schema: + type: object + + responses: + "200": + content: + application/json: + schema: + properties: + detail: + type: string + --- + """ + cart = get_object_or_404(Cart, owner=self.request.user) + + # The assumption is that this filter query should return all tickets in the cart + # however we cannot guarantee atomicity between cart and checkout + # + # customers will be prompted to review the cart before payment + + tickets = cart.tickets.select_for_update().filter( + Q(holder__isnull=True) | Q(holder=self.request.user), owner__isnull=True + ) + + for ticket in tickets: + ticket.holder = self.request.user + ticket.holding_expiration = timezone.now() + datetime.timedelta(minutes=10) + + Ticket.objects.bulk_update(tickets, ["holder", "holding_expiration"]) + + return Response({"detail": "Successfully initated checkout"}) + + @action(detail=False, methods=["post"]) + @transaction.atomic + def checkout_success_callback(self, request, *args, **kwargs): + """ + Callback after third party payment succeeds + --- + requestBody: {} + responses: + "200": + content: + application/json: + schema: + properties: + detail: + type: string + + """ + cart = get_object_or_404(Cart, owner=self.request.user) + + for ticket in cart.tickets.select_for_update().all(): + ticket.owner = request.user + ticket.carts.clear() + # ticket.send_confirmation_email() + ticket.save() + + return Response({"detail": "callback successful"}) + + @action(detail=False, methods=["post"]) + @transaction.atomic + def buy(self, request, *args, **kwargs): + """ + Buy held tickets in a cart + --- + requestBody: + content: {} + responses: + "200": + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Ticket" + --- + """ + + # Some logic here to serialize all held tickets down to whatever + # format third party asks for + + cart = get_object_or_404(Cart, owner=self.request.user) + + for ticket in cart.tickets.filter(holder=self.request.user): + pass + + return Response({}) @action(detail=True, methods=["get"]) def qr(self, request, *args, **kwargs):