Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ticket drop times #672

Merged
merged 10 commits into from
Apr 23, 2024
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 = "*"
aviupadhyayula marked this conversation as resolved.
Show resolved Hide resolved

[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 @@ -2395,6 +2395,13 @@
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 @@ -2570,6 +2577,9 @@
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 @@ -2618,6 +2628,10 @@
order_limit:
type: int
required: false
drop_time:
type: string
format: date-time
aviupadhyayula marked this conversation as resolved.
Show resolved Hide resolved
required: false
responses:
"200":
content:
Expand All @@ -2635,10 +2649,39 @@
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 @@ -2707,6 +2750,25 @@
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(

Check warning on line 2758 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2757-L2758

Added lines #L2757 - L2758 were not covered by tests
{"detail": f"Invalid drop time: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

if drop_time < timezone.now():
return Response(

Check warning on line 2764 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2764

Added line #L2764 was not covered by tests
{"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 @@ -728,7 +846,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 @@ -776,7 +894,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 @@ -810,7 +928,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 @@ -853,7 +971,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
Loading