diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index d03a05573..daf103333 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -223,7 +223,7 @@ 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"]: + elif view.action in ["buyers", "create_tickets", "issue_tickets"]: if not request.user.is_authenticated: return False membership = find_membership_helper(request.user, obj.club) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 1dfe0a8c0..75dda1c29 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -2686,7 +2686,7 @@ def create_tickets(self, request, *args, **kwargs): status=status.HTTP_403_FORBIDDEN, ) - # Tickets can't be edited after they've been sold + # Tickets can't be edited after they've been sold or held if ( Ticket.objects.filter(event=event) .filter(Q(owner__isnull=False) | Q(holder__isnull=False)) @@ -2790,6 +2790,159 @@ def create_tickets(self, request, *args, **kwargs): return Response({"detail": "Successfully created tickets"}) + @action(detail=True, methods=["post"]) + @transaction.atomic + @update_holds + def issue_tickets(self, request, *args, **kwargs): + """ + Issue tickets that have already been created to users in bulk. + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + tickets: + type: array + items: + type: object + properties: + username: + type: string + ticket_type: + type: string + + responses: + "200": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + "400": + content: + application/json: + schema: + type: object + properties: + detail: + type: string + errors: + type: array + items: + type: string + --- + """ + event = self.get_object() + + tickets = request.data.get("tickets", []) + + if not tickets: + return Response( + {"detail": "tickets must be specified", "errors": []}, + status=status.HTTP_400_BAD_REQUEST, + ) + + for item in tickets: + if not item.get("username") or not item.get("ticket_type"): + return Response( + { + "detail": "Specify username and ticket type to issue tickets", + "errors": [], + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + usernames = [item.get("username") for item in tickets] + ticket_types = [item.get("ticket_type") for item in tickets] + + # Validate all usernames + invalid_usernames = set(usernames) - set( + get_user_model() + .objects.filter(username__in=usernames) + .values_list("username", flat=True) + ) + if invalid_usernames: + return Response( + { + "detail": "Invalid usernames", + "errors": sorted(list(invalid_usernames)), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate all ticket types + invalid_types = set(ticket_types) - set( + Ticket.objects.filter(event=event).values_list("type", flat=True) + ) + if invalid_types: + return Response( + { + "detail": "Invalid ticket classes", + "errors": sorted(list(invalid_types)), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + tickets = [] + for ticket_type, num_requested in collections.Counter(ticket_types).items(): + available_tickets = Ticket.objects.select_for_update( + skip_locked=True + ).filter( + event=event, type=ticket_type, owner__isnull=True, holder__isnull=True + )[:num_requested] + + if available_tickets.count() < num_requested: + return Response( + { + "detail": ( + f"Not enough tickets available for type: {ticket_type}" + ), + "errors": [], + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + tickets.extend(available_tickets) + + # Assign tickets to users + transaction_records = [] + + for username, ticket_type in zip(usernames, ticket_types): + user = get_user_model().objects.filter(username=username).first() + ticket = next( + ticket + for ticket in tickets + if ticket.type == ticket_type and ticket.owner is None + ) + ticket.owner = user + ticket.holder = None + + transaction_records.append( + TicketTransactionRecord( + ticket=ticket, + total_amount=0.0, + buyer_first_name=user.first_name, + buyer_last_name=user.last_name, + buyer_email=user.email, + ) + ) + + Ticket.objects.bulk_update(tickets, ["owner", "holder"]) + Ticket.objects.update_holds() + + TicketTransactionRecord.objects.bulk_create(transaction_records) + + for ticket in tickets: + ticket.send_confirmation_email() + + return Response( + {"success": True, "detail": f"Issued {len(tickets)} tickets", "errors": []} + ) + @action(detail=True, methods=["post"]) def upload(self, request, *args, **kwargs): """ diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index 875adacc8..d0d2170c8 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -245,6 +245,130 @@ def test_create_ticket_offerings_already_owned_or_held(self): ) self.assertEqual(resp.status_code, 403, resp.content) + def test_issue_tickets(self): + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": self.user1.username, "ticket_type": "normal"}, + {"username": self.user1.username, "ticket_type": "premium"}, + {"username": self.user2.username, "ticket_type": "normal"}, + {"username": self.user2.username, "ticket_type": "premium"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 200, resp.content) + + for item in args["tickets"]: + username, ticket_type = item["username"], item["ticket_type"] + user = get_user_model().objects.get(username=username) + + self.assertEqual( + Ticket.objects.filter(type=ticket_type, owner=user).count(), 1 + ) + self.assertTrue( + TicketTransactionRecord.objects.filter( + ticket__type=ticket_type, + ticket__owner=user, + total_amount=0.0, + ).exists() + ) + + def test_issue_tickets_bad_perms(self): + # user2 is not a superuser or club officer+ + self.client.login(username=self.user2.username, password="test") + args = { + "tickets": [ + {"username": self.user1.username, "ticket_type": "normal"}, + {"username": self.user2.username, "ticket_type": "normal"}, + ] + } + + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 403, resp.content) + + def test_issue_tickets_invalid_username_ticket_type(self): + # All usernames must be valid + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": "invalid_user_1", "ticket_type": "normal"}, + {"username": "invalid_user_2", "ticket_type": "premium"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + data = resp.json() + self.assertEqual(data["errors"], ["invalid_user_1", "invalid_user_2"]) + + # All requested ticket types must be valid + args = { + "tickets": [ + {"username": self.user2.username, "ticket_type": "invalid_type_1"}, + {"username": self.user2.username, "ticket_type": "invalid_type_2"}, + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + data = resp.json() + self.assertEqual(data["errors"], ["invalid_type_1", "invalid_type_2"]) + + def test_issue_tickets_insufficient_quantity(self): + self.client.login(username=self.user1.username, password="test") + args = { + "tickets": [ + {"username": self.user2.username, "ticket_type": "normal"} + for _ in range(100) + ] + } + resp = self.client.post( + reverse( + "club-events-issue-tickets", args=(self.club1.code, self.event1.pk) + ), + args, + format="json", + ) + + self.assertEqual(resp.status_code, 400, resp.content) + self.assertIn( + "Not enough tickets available for type: normal", str(resp.content) + ) + + # No tickets should be transferred + self.assertEqual(Ticket.objects.filter(owner=self.user2).count(), 0) + + # No holds should be given + self.assertEqual( + Ticket.objects.filter(type="normal", holder__isnull=False).count(), 0 + ) + def test_get_tickets_information_no_tickets(self): # Delete all the tickets Ticket.objects.all().delete()