Skip to content

Commit

Permalink
Add support for ticket drop times (#672)
Browse files Browse the repository at this point in the history
* Add ticket drop time to event attributes

* Set ticket drop time at tickets creation

* Add unit tests

* Minor refactor to tests

* Revert unneeded changes to Pipfile.lock

* Allow users to specify drop time + more guards/tests

* Remove dev artifact in tests

* Fix all typos with "transferred"

* Add 403 to response schema

* Only display tickets after they've dropped
  • Loading branch information
aviupadhyayula authored Apr 23, 2024
1 parent 212720b commit 66cceb0
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 8 deletions.
1 change: 1 addition & 0 deletions backend/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ numpy = "*"
inflection = "*"
cybersource-rest-client-python = "*"
pyjwt = "*"
freezegun = "*"

[requires]
python_version = "3.11"
11 changes: 10 additions & 1 deletion backend/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions backend/clubs/migrations/0104_event_ticket_drop_time.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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"])
Expand Down
132 changes: 125 additions & 7 deletions backend/tests/clubs/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down

0 comments on commit 66cceb0

Please sign in to comment.