diff --git a/backend/Pipfile b/backend/Pipfile index c54f42e0b..5031e8a53 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -56,6 +56,7 @@ numpy = "*" inflection = "*" cybersource-rest-client-python = "*" pyjwt = "*" +freezegun = "*" [requires] python_version = "3.11" diff --git a/backend/Pipfile.lock b/backend/Pipfile.lock index 9bac992aa..e319b9e1c 100644 --- a/backend/Pipfile.lock +++ b/backend/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "745dba9015c559c2b445cbcdd35bee72beea730749e1838627c860946f971af9" + "sha256": "af92de209d2c1cbc993a93920faad16210c881c09fb904ed2495b65a42bf9516" }, "pipfile-spec": 6, "requires": { @@ -517,6 +517,15 @@ "markers": "python_version >= '3.8'", "version": "==3.13.4" }, + "freezegun": { + "hashes": [ + "sha256:10939b0ba0ff5adaecf3b06a5c2f73071d9678e507c5eaedb23c761d56ac774b", + "sha256:55e0fc3c84ebf0a96a5aa23ff8b53d70246479e9a68863f1fcac5a3e52f19dd6" + ], + "index": "pypi", + "markers": "python_version >= '3.7'", + "version": "==1.4.0" + }, "gunicorn": { "hashes": [ "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0", diff --git a/backend/clubs/migrations/0104_event_ticket_drop_time.py b/backend/clubs/migrations/0104_event_ticket_drop_time.py new file mode 100644 index 000000000..a7120acc5 --- /dev/null +++ b/backend/clubs/migrations/0104_event_ticket_drop_time.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-04-21 04:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0103_ticket_group_discount_ticket_group_size"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="ticket_drop_time", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 7fde4eb41..1105e5cf2 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -925,6 +925,7 @@ class Event(models.Model): RecurringEvent, on_delete=models.CASCADE, blank=True, null=True ) ticket_order_limit = models.IntegerField(default=10) + ticket_drop_time = models.DateTimeField(null=True, blank=True) OTHER = 0 RECRUITMENT = 1 diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 869ece0fb..63175bee5 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -2391,6 +2391,13 @@ def add_to_cart(self, request, *args, **kwargs): event = self.get_object() cart, _ = Cart.objects.get_or_create(owner=self.request.user) + # Cannot add tickets that haven't dropped yet + if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: + return Response( + {"detail": "Ticket drop time has not yet elapsed"}, + status=status.HTTP_403_FORBIDDEN, + ) + quantities = request.data.get("quantities") if not quantities: return Response( @@ -2566,6 +2573,9 @@ def tickets(self, request, *args, **kwargs): event = self.get_object() tickets = Ticket.objects.filter(event=event) + if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: + return Response({"totals": [], "available": []}) + # Take price of first ticket of given type for now totals = ( tickets.values("type") @@ -2614,6 +2624,10 @@ def create_tickets(self, request, *args, **kwargs): order_limit: type: int required: false + drop_time: + type: string + format: date-time + required: false responses: "200": content: @@ -2631,10 +2645,39 @@ def create_tickets(self, request, *args, **kwargs): properties: detail: type: string + "403": + content: + application/json: + schema: + type: object + properties: + detail: + type: string --- """ event = self.get_object() + # Tickets can't be edited after they've dropped + if event.ticket_drop_time and timezone.now() > event.ticket_drop_time: + return Response( + {"detail": "Tickets cannot be edited after they have dropped"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Tickets can't be edited after they've been sold + if ( + Ticket.objects.filter(event=event) + .filter(Q(owner__isnull=False) | Q(holder__isnull=False)) + .exists() + ): + return Response( + { + "detail": "Tickets cannot be edited after they have been " + "sold or checked out" + }, + status=status.HTTP_403_FORBIDDEN, + ) + quantities = request.data.get("quantities", []) if not quantities: return Response( @@ -2703,6 +2746,25 @@ def create_tickets(self, request, *args, **kwargs): event.ticket_order_limit = order_limit event.save() + drop_time = request.data.get("drop_time", None) + if drop_time is not None: + try: + drop_time = datetime.datetime.strptime(drop_time, "%Y-%m-%dT%H:%M:%S%z") + except ValueError as e: + return Response( + {"detail": f"Invalid drop time: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if drop_time < timezone.now(): + return Response( + {"detail": "Specified drop time has already elapsed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + event.ticket_drop_time = drop_time + event.save() + return Response({"detail": "success"}) @action(detail=True, methods=["post"]) diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index c91266de9..568ec903c 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -4,10 +4,9 @@ from datetime import timedelta from unittest.mock import patch +import freezegun from django.contrib.auth import get_user_model -from django.db.models import ( - Count, -) +from django.db.models import Count from django.test import TestCase from django.urls import reverse from django.utils import timezone @@ -155,6 +154,89 @@ def test_create_ticket_offerings_bad_data(self): self.assertIn(resp.status_code, [400], resp.content) self.assertEqual(Ticket.objects.filter(type__contains="_").count(), 0, data) + def test_create_ticket_offerings_delay_drop(self): + self.client.login(username=self.user1.username, password="test") + + args = { + "quantities": [ + {"type": "_normal", "count": 20, "price": 10}, + {"type": "_premium", "count": 10, "price": 20}, + ], + "drop_time": (timezone.now() + timezone.timedelta(hours=12)).strftime( + "%Y-%m-%dT%H:%M:%S%z" + ), + } + _ = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + + self.event1.refresh_from_db() + + # Drop time should be set + self.assertIsNotNone(self.event1.ticket_drop_time) + + # Drop time should be 12 hours from initial ticket creation + expected_drop_time = timezone.now() + timezone.timedelta(hours=12) + diff = abs(self.event1.ticket_drop_time - expected_drop_time) + self.assertTrue(diff < timezone.timedelta(minutes=5)) + + # Move Django's internal clock 13 hours forward + with freezegun.freeze_time(timezone.now() + timezone.timedelta(hours=13)): + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + + # Tickets shouldn't be editable after drop time has elapsed + self.assertEqual(resp.status_code, 403, resp.content) + + def test_create_ticket_offerings_already_owned_or_held(self): + self.client.login(username=self.user1.username, password="test") + + # Create ticket offerings + args = { + "quantities": [ + {"type": "_normal", "count": 5, "price": 10}, + {"type": "_premium", "count": 3, "price": 20}, + ], + } + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertIn(resp.status_code, [200, 201], resp.content) + + # Simulate checkout by applying holds + for ticket in Ticket.objects.filter(type="_normal"): + ticket.holder = self.user1 + ticket.save() + + # Recreating tickets should fail + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + + # Simulate purchase by transferring ownership + for ticket in Ticket.objects.filter(type="_normal", holder=self.user1): + ticket.owner = self.user1 + ticket.holder = None + ticket.save() + + # Recreating tickets should fail + resp = self.client.put( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + args, + format="json", + ) + self.assertEqual(resp.status_code, 403, resp.content) + def test_get_tickets_information_no_tickets(self): # Delete all the tickets Ticket.objects.all().delete() @@ -186,6 +268,21 @@ def test_get_tickets_information(self): data["available"], ) + def test_get_tickets_before_drop_time(self): + self.event1.ticket_drop_time = timezone.now() + timedelta(days=1) + self.event1.save() + + self.client.login(username=self.user1.username, password="test") + resp = self.client.get( + reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)), + ) + self.assertEqual(resp.status_code, 200, resp.content) + data = resp.json() + + # Tickets shouldn't be available before the drop time + self.assertEqual(data["totals"], []) + self.assertEqual(data["available"], []) + def test_get_tickets_buyers(self): self.client.login(username=self.user1.username, password="test") @@ -304,6 +401,27 @@ def test_add_to_cart_tickets_unavailable(self): "Not enough tickets of type normal left!", resp.data["detail"], resp.data ) + def test_add_to_cart_before_ticket_drop(self): + self.client.login(username=self.user1.username, password="test") + + # Set drop time + self.event1.ticket_drop_time = timezone.now() + timedelta(hours=12) + self.event1.save() + + tickets_to_add = { + "quantities": [ + {"type": "normal", "count": 2}, + ] + } + resp = self.client.post( + reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)), + tickets_to_add, + format="json", + ) + + # Tickets should not be added to cart before drop time + self.assertEqual(resp.status_code, 403, resp.content) + def test_remove_from_cart(self): self.client.login(username=self.user1.username, password="test") @@ -790,7 +908,7 @@ def test_complete_checkout(self): self.assertIn(resp.status_code, [200, 201], resp.content) self.assertIn("Payment successful", resp.data["detail"], resp.data) - # Ownership transfered + # Ownership transferred owned_tickets = Ticket.objects.filter(owner=self.user1) self.assertEqual(owned_tickets.count(), 2, owned_tickets) @@ -838,7 +956,7 @@ def test_complete_checkout_stale_cart(self): self.assertEqual(resp.status_code, 403, resp.content) self.assertIn("Cart is stale", resp.data["detail"], resp.content) - # Ownership not transfered + # Ownership not transferred owned_tickets = Ticket.objects.filter(owner=self.user1) self.assertEqual(owned_tickets.count(), 0, owned_tickets) @@ -872,7 +990,7 @@ def test_complete_checkout_validate_token_fails(self): self.assertEqual(resp.status_code, 500, resp.content) self.assertIn("Validation failed", resp.data["detail"], resp.content) - # Ownership not transfered + # Ownership not transferred owned_tickets = Ticket.objects.filter(owner=self.user1) self.assertEqual(owned_tickets.count(), 0, owned_tickets) @@ -915,7 +1033,7 @@ def test_complete_checkout_cybersource_fails(self): self.assertIn("Transaction failed", resp.data["detail"], resp.content) self.assertIn("HTTP status 400", resp.data["detail"], resp.content) - # Ownership not transfered + # Ownership not transferred owned_tickets = Ticket.objects.filter(owner=self.user1) self.assertEqual(owned_tickets.count(), 0, owned_tickets)