diff --git a/backend/clubs/migrations/0118_remove_event_ticket_drop_time_and_more.py b/backend/clubs/migrations/0118_remove_event_ticket_drop_time_and_more.py new file mode 100644 index 000000000..457f2973e --- /dev/null +++ b/backend/clubs/migrations/0118_remove_event_ticket_drop_time_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.4 on 2025-01-02 07:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0117_clubapprovalresponsetemplate"), + ] + + operations = [ + migrations.RemoveField(model_name="event", name="ticket_drop_time",), + migrations.RemoveField(model_name="event", name="ticket_order_limit",), + migrations.CreateModel( + name="TicketSettings", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("order_limit", models.IntegerField(blank=True, null=True)), + ("drop_time", models.DateTimeField(blank=True, null=True)), + ("fee_charged_to_buyer", models.BooleanField(default=False)), + ( + "event", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="ticket_settings", + to="clubs.event", + ), + ), + ], + ), + ] diff --git a/backend/clubs/migrations/0119_alter_ticketsettings_order_limit.py b/backend/clubs/migrations/0119_alter_ticketsettings_order_limit.py new file mode 100644 index 000000000..8d2da40a6 --- /dev/null +++ b/backend/clubs/migrations/0119_alter_ticketsettings_order_limit.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2025-01-04 01:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0118_remove_event_ticket_drop_time_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="ticketsettings", + name="order_limit", + field=models.IntegerField(blank=True, default=10, null=True), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 22105c7a0..dc4432a26 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -940,8 +940,6 @@ class Event(models.Model): parent_recurring_event = models.ForeignKey( 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 @@ -969,6 +967,10 @@ class Event(models.Model): def create_thumbnail(self, request=None): return create_thumbnail_helper(self, request, 400) + @property + def has_tickets(self): + return self.tickets.exists() + def __str__(self): return self.name @@ -1821,6 +1823,23 @@ class Cart(models.Model): checkout_context = models.CharField(max_length=8297, blank=True, null=True) +class TicketSettings(models.Model): + """ + Configuration settings for events that have tickets. + Only created when an event has associated tickets created. + """ + + event = models.OneToOneField( + Event, on_delete=models.CASCADE, related_name="ticket_settings" + ) + order_limit = models.IntegerField(null=True, blank=True, default=10) + drop_time = models.DateTimeField(null=True, blank=True) + fee_charged_to_buyer = models.BooleanField(default=False) + + def __str__(self): + return f"Ticket settings for {self.event.name}" + + class TicketQuerySet(models.query.QuerySet): def delete(self): if self.filter(transaction_record__isnull=False).exists(): diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 6d03c9607..7a1cde9ad 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -117,6 +117,7 @@ Tag, Testimonial, Ticket, + TicketSettings, TicketTransactionRecord, TicketTransferRecord, Year, @@ -2464,6 +2465,14 @@ def add_to_cart(self, request, *args, **kwargs): --- """ event = self.get_object() + + # Check if event has any tickets + if not event.has_tickets: + return Response( + {"detail": "This event does not have any tickets", "success": False}, + status=status.HTTP_403_FORBIDDEN, + ) + cart, _ = Cart.objects.get_or_create(owner=self.request.user) # Check if the event has already ended @@ -2474,7 +2483,10 @@ def add_to_cart(self, request, *args, **kwargs): ) # Cannot add tickets that haven't dropped yet - if event.ticket_drop_time and timezone.now() < event.ticket_drop_time: + if ( + event.ticket_settings.drop_time + and timezone.now() < event.ticket_settings.drop_time + ): return Response( {"detail": "Ticket drop time has not yet elapsed", "success": False}, status=status.HTTP_403_FORBIDDEN, @@ -2490,11 +2502,14 @@ def add_to_cart(self, request, *args, **kwargs): num_requested = sum(item["count"] for item in quantities) num_carted = cart.tickets.filter(event=event).count() - if num_requested + num_carted > event.ticket_order_limit: + if ( + event.ticket_settings.order_limit + and num_requested + num_carted > event.ticket_settings.order_limit + ): return Response( { "detail": f"Order exceeds the maximum ticket limit of " - f"{event.ticket_order_limit}.", + f"{event.ticket_settings.order_limit}.", "success": False, }, status=status.HTTP_403_FORBIDDEN, @@ -2680,20 +2695,22 @@ 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: + if not event.has_tickets or ( + event.ticket_settings.drop_time + and timezone.now() < event.ticket_settings.drop_time + ): return Response({"totals": [], "available": []}) # Take price of first ticket of given type for now totals = ( - tickets.values("type") + event.tickets.values("type") .annotate(price=Max("price")) .annotate(count=Count("type")) .order_by("type") ) available = ( - tickets.filter(owner__isnull=True, holder__isnull=True, buyable=True) + event.tickets.filter(owner__isnull=True, holder__isnull=True, buyable=True) .values("type") .annotate(price=Max("price")) .annotate(count=Count("type")) @@ -2705,7 +2722,11 @@ def tickets(self, request, *args, **kwargs): @transaction.atomic def create_tickets(self, request, *args, **kwargs): """ - Create ticket offerings for event + Create or update ticket offerings for an event. + + This endpoint allows configuring ticket types, quantities, prices, and settings. + Tickets cannot be modified after they have been dropped or sold. + --- requestBody: content: @@ -2717,6 +2738,11 @@ def create_tickets(self, request, *args, **kwargs): type: array items: type: object + required: + - type + - count + - price + - transferable properties: type: type: string @@ -2725,26 +2751,24 @@ def create_tickets(self, request, *args, **kwargs): price: type: number group_size: - type: number - required: false + type: integer group_discount: type: number format: float - required: false transferable: type: boolean buyable: type: boolean - required: false order_limit: - type: int - required: false + type: integer drop_time: type: string format: date-time - required: false + fee_charged_to_buyer: + type: boolean responses: "200": + description: Tickets created successfully content: application/json: schema: @@ -2753,6 +2777,7 @@ def create_tickets(self, request, *args, **kwargs): detail: type: string "400": + description: Invalid request parameters content: application/json: schema: @@ -2761,6 +2786,7 @@ def create_tickets(self, request, *args, **kwargs): detail: type: string "403": + description: Tickets cannot be modified content: application/json: schema: @@ -2772,27 +2798,59 @@ def create_tickets(self, request, *args, **kwargs): """ 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: + # Tickets can't be edited after they've been sold or checked out + if event.tickets.filter( + Q(owner__isnull=False) | Q(holder__isnull=False) + ).exists(): return Response( - {"detail": "Tickets cannot be edited after they have dropped"}, + { + "detail": "Tickets cannot be edited after they have been " + "sold or checked out" + }, status=status.HTTP_403_FORBIDDEN, ) - # Tickets can't be edited after they've been sold or held + ticket_settings, _ = TicketSettings.objects.get_or_create(event=event) + + # Tickets can't be edited after they've dropped if ( - Ticket.objects.filter(event=event) - .filter(Q(owner__isnull=False) | Q(holder__isnull=False)) - .exists() + event.ticket_settings.drop_time + and timezone.now() > event.ticket_settings.drop_time ): return Response( - { - "detail": "Tickets cannot be edited after they have been " - "sold or checked out" - }, + {"detail": "Tickets cannot be edited after they have dropped"}, status=status.HTTP_403_FORBIDDEN, ) + order_limit = request.data.get("order_limit", None) + if order_limit is not None: + ticket_settings.order_limit = order_limit + ticket_settings.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, + ) + + ticket_settings.drop_time = drop_time + ticket_settings.save() + + fee_charged_to_buyer = request.data.get("fee_charged_to_buyer", None) + if fee_charged_to_buyer is not None: + ticket_settings.fee_charged_to_buyer = fee_charged_to_buyer + ticket_settings.save() + quantities = request.data.get("quantities", []) if not quantities: return Response( @@ -2855,35 +2913,11 @@ def create_tickets(self, request, *args, **kwargs): for item in quantities for _ in range(item["count"]) ] - Ticket.objects.bulk_create(tickets) - order_limit = request.data.get("order_limit", None) - if order_limit is not None: - 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() - cache.delete(f"clubs:{event.club.id}-authed") cache.delete(f"clubs:{event.club.id}-anon") + return Response({"detail": "Successfully created tickets"}) @action(detail=True, methods=["post"]) diff --git a/backend/tests/clubs/test_ticketing.py b/backend/tests/clubs/test_ticketing.py index 8f047b9d8..ac6def249 100644 --- a/backend/tests/clubs/test_ticketing.py +++ b/backend/tests/clubs/test_ticketing.py @@ -20,6 +20,7 @@ Event, Membership, Ticket, + TicketSettings, TicketTransactionRecord, TicketTransferRecord, ) @@ -59,6 +60,10 @@ def commonSetUp(self): end_time=timezone.now() + timezone.timedelta(days=3), ) + self.ticket_settings = TicketSettings.objects.create( + event=self.event1, + ) + self.ticket_totals = [ {"type": "normal", "count": 20, "price": 15.0}, {"type": "premium", "count": 10, "price": 30.0}, @@ -212,14 +217,14 @@ def test_create_ticket_offerings_delay_drop(self): format="json", ) - self.event1.refresh_from_db() + self.ticket_settings.refresh_from_db() # Drop time should be set - self.assertIsNotNone(self.event1.ticket_drop_time) + self.assertIsNotNone(self.ticket_settings.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) + diff = abs(self.ticket_settings.drop_time - expected_drop_time) self.assertTrue(diff < timezone.timedelta(minutes=5)) # Move Django's internal clock 13 hours forward @@ -277,6 +282,35 @@ def test_create_ticket_offerings_already_owned_or_held(self): ) self.assertEqual(resp.status_code, 403, resp.content) + def test_create_tickets_with_settings(self): + self.client.login(username=self.user1.username, password="test") + + drop_time = timezone.now() + timedelta(days=1) + args = { + "quantities": [ + {"type": "_normal", "count": 5, "price": 10}, + {"type": "_premium", "count": 3, "price": 20}, + ], + "order_limit": 2, + "drop_time": drop_time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "fee_charged_to_buyer": True, + } + 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) + + self.event1.refresh_from_db() + self.assertEqual(self.event1.ticket_settings.order_limit, 2) + self.assertAlmostEqual( + self.event1.ticket_settings.drop_time.timestamp(), + drop_time.timestamp(), + delta=1.0, # allow 1 second difference to avoid flaky tests + ) + self.assertTrue(self.event1.ticket_settings.fee_charged_to_buyer) + def test_issue_tickets(self): self.client.login(username=self.user1.username, password="test") args = { @@ -469,8 +503,8 @@ def test_get_tickets_information(self): ) def test_get_tickets_before_drop_time(self): - self.event1.ticket_drop_time = timezone.now() + timedelta(days=1) - self.event1.save() + self.ticket_settings.drop_time = timezone.now() + timedelta(days=1) + self.ticket_settings.save() self.client.login(username=self.user1.username, password="test") resp = self.client.get( @@ -626,8 +660,8 @@ 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() + self.ticket_settings.drop_time = timezone.now() + timedelta(hours=12) + self.ticket_settings.save() tickets_to_add = { "quantities": [