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 free tickets #658

Merged
merged 9 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 91 additions & 41 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4992,6 +4992,8 @@
type: string
success:
type: boolean
sold_free_tickets:
type: boolean
"403":
content:
application/json:
Expand All @@ -5002,6 +5004,8 @@
type: string
success:
type: boolean
sold_free_tickets:
type: boolean
---
"""
cart = get_object_or_404(Cart, owner=self.request.user)
Expand All @@ -5012,6 +5016,7 @@
{
"success": False,
"detail": "No tickets selected for checkout.",
"sold_free_tickets": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
Expand All @@ -5023,24 +5028,44 @@
)

# Assert that the filter succeeded in freezing all the tickets for checkout
if tickets.count() != cart.tickets.count():
if tickets.count() != cart.tickets.all().count():
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved
return Response(
{
"success": False,
"detail": "Cart is stale, invoke /api/tickets/cart to refresh",
"detail": (
"Cart is stale or empty, invoke /api/tickets/cart to refresh"
),
"sold_free_tickets": False,
},
status=status.HTTP_403_FORBIDDEN,
)

cart_total = self._calculate_cart_total(cart)

# If all tickets are free, we can skip the payment process
if not cart_total:
order_info = {
"amountDetails": {"totalAmount": "0.00"},
"billTo": {
"reconciliationId": None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think we need this line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using reconciliationId from order_info in _give_tickets.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yah, good catch. Wasn't clear which line you meant from my end

"firstName": self.request.user.first_name,
"lastName": self.request.user.last_name,
"phoneNumber": None,
"email": self.request.user.email,
},
}

# Place hold on tickets for 10 mins
self.place_hold_on_tickets(tickets)
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved
# Skip payment process and give tickets to user/buyer
self._give_tickets(order_info, cart, None)

return Response(
{
"success": False,
"detail": "Cart total must be nonzero to generate capture context.",
},
status=status.HTTP_400_BAD_REQUEST,
"success": True,
"detail": "Free tickets sold.",
"sold_free_tickets": True,
}
)

capture_context_request = {
Expand Down Expand Up @@ -5087,17 +5112,22 @@
cart.save()

# Place hold on tickets for 10 mins
holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
tickets.update(
holder=self.request.user, holding_expiration=holding_expiration
self.place_hold_on_tickets(tickets)
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved

return Response(
{
"success": True,
"detail": context,
"sold_free_tickets": False,
}
)

return Response({"success": True, "detail": context})
except ApiException as e:
return Response(
{
"success": False,
"detail": f"Unable to generate capture context: {e}",
"sold_free_tickets": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
Expand Down Expand Up @@ -5220,38 +5250,8 @@
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

# At this point, we have validated that the payment was authorized
# Give the tickets to the user
tickets = cart.tickets.select_for_update().filter(holder=self.request.user)

# Archive transaction data for historical purposes.
# We're explicitly using the response data over what's in self.request.user
order_info = transaction_data["orderInformation"]
transaction_records = []
for ticket in tickets:
transaction_records.append(
TicketTransactionRecord(
ticket=ticket,
reconciliation_id=str(reconciliation_id),
total_amount=float(order_info["amountDetails"]["totalAmount"]),
buyer_first_name=order_info["billTo"]["firstName"],
buyer_last_name=order_info["billTo"]["lastName"],
# TODO: investigate why phone numbers don't show in test API
buyer_phone=order_info["billTo"].get("phoneNumber", None),
buyer_email=order_info["billTo"]["email"],
)
)

TicketTransactionRecord.objects.bulk_create(transaction_records)
tickets.update(owner=request.user, holder=None)
cart.tickets.clear()
for ticket in tickets:
ticket.send_confirmation_email()

Ticket.objects.update_holds()

cart.checkout_context = None
cart.save()
self._give_tickets(order_info, cart, reconciliation_id)

return Response(
{
Expand Down Expand Up @@ -5354,6 +5354,56 @@
def get_queryset(self):
return Ticket.objects.filter(owner=self.request.user.id)

def _give_tickets(self, order_info, cart, reconciliation_id):
"""
Helper function that gives user/buyer their held tickets
and archives the transaction data
"""

# At this point, we have validated that the payment was authorized
# Give the tickets to the user
tickets = (
cart.tickets.select_for_update()
.filter(holder=self.request.user)
.prefetch_related("carts")
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved
)
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved

# Archive transaction data for historical purposes.
# We're explicitly using the response data over what's in self.request.user
transaction_records = []

for ticket in tickets:
transaction_records.append(
TicketTransactionRecord(
ticket=ticket,
reconciliation_id=str(reconciliation_id),
total_amount=float(order_info["amountDetails"]["totalAmount"]),
buyer_first_name=order_info["billTo"]["firstName"],
buyer_last_name=order_info["billTo"]["lastName"],
# TODO: investigate why phone numbers don't show in test API
buyer_phone=order_info["billTo"].get("phoneNumber", None),
buyer_email=order_info["billTo"]["email"],
)
)

tickets.update(owner=self.request.user, holder=None)
TicketTransactionRecord.objects.bulk_create(transaction_records)
cart.tickets.clear()
for ticket in tickets:
ticket.send_confirmation_email()

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

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L5393

Added line #L5393 was not covered by tests

Ticket.objects.update_holds()

cart.checkout_context = None
cart.save()

def place_hold_on_tickets(self, tickets):
"""
Helper function that places a 10 minute hold on tickets for a user
"""
holding_expiration = timezone.now() + datetime.timedelta(minutes=10)
tickets.update(holder=self.request.user, holding_expiration=holding_expiration)
aviupadhyayula marked this conversation as resolved.
Show resolved Hide resolved


class MemberInviteViewSet(viewsets.ModelViewSet):
"""
Expand Down
136 changes: 135 additions & 1 deletion backend/tests/clubs/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,36 @@ def test_create_ticket_offerings(self):

self.assertIn(resp.status_code, [200, 201], resp.content)

def test_create_ticket_offerings_free_tickets(self):
self.client.login(username=self.user1.username, password="test")

tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(10)]
Ticket.objects.bulk_create(tickets)

qts = {
"quantities": [
{"type": "_free", "count": 10, "price": 0},
]
}

resp = self.client.put(
reverse("club-events-tickets", args=(self.club1.code, self.event1.pk)),
qts,
format="json",
)

aggregated_tickets = list(
Ticket.objects.filter(event=self.event1, type__contains="_")
.values("type", "price")
.annotate(count=Count("id"))
)
for t1, t2 in zip(qts["quantities"], aggregated_tickets):
self.assertEqual(t1["type"], t2["type"])
self.assertAlmostEqual(t1["price"], float(t2["price"]), 0.00)
self.assertEqual(t1["count"], t2["count"])

self.assertIn(resp.status_code, [200, 201], resp.content)

def test_create_ticket_offerings_bad_perms(self):
# user2 is not a superuser or club officer+
self.client.login(username=self.user2.username, password="test")
Expand Down Expand Up @@ -794,7 +824,7 @@ def test_get_cart_replacement_required_sold_out(self):
to_add = set(map(lambda t: str(t.id), tickets_to_add))
self.assertEqual(len(in_cart & to_add), 0, in_cart | to_add)

def test_initiate_checkout(self):
def test_initiate_checkout_non_free_tickets(self):
self.client.login(username=self.user1.username, password="test")

# Add a few tickets to cart
Expand Down Expand Up @@ -825,6 +855,8 @@ def test_initiate_checkout(self):
fake_cap_context.return_value = cap_context_data, 200, None
resp = self.client.post(reverse("tickets-initiate-checkout"))
self.assertIn(resp.status_code, [200, 201], resp.content)
# No free tickets should be sold
self.assertFalse(resp.data["sold_free_tickets"])

# Capture context should be tied to cart
cart = Cart.objects.filter(owner=self.user1).first()
Expand All @@ -837,6 +869,108 @@ def test_initiate_checkout(self):
self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets)
self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets)

def test_initiate_checkout_free_and_non_free_tickets(self):
Porcupine1 marked this conversation as resolved.
Show resolved Hide resolved
self.client.login(username=self.user1.username, password="test")
Ticket.objects.create(type="free", event=self.event1, price=0.0)

# Add a few tickets to cart
tickets_to_add = {
"quantities": [
{"type": "free", "count": 1},
{"type": "normal", "count": 1},
{"type": "premium", "count": 1},
]
}
resp = self.client.post(
reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
tickets_to_add,
format="json",
)
self.assertIn(resp.status_code, [200, 201], resp.content)

# Initiate checkout
with patch(
".".join(
[
"CyberSource",
"UnifiedCheckoutCaptureContextApi",
"generate_unified_checkout_capture_context_with_http_info",
]
)
) as fake_cap_context:
cap_context_data = "abcde"
fake_cap_context.return_value = cap_context_data, 200, None
resp = self.client.post(reverse("tickets-initiate-checkout"))
self.assertIn(resp.status_code, [200, 201], resp.content)
# Free ticket should be sold with non-free tickets if purchased together
self.assertFalse(resp.data["sold_free_tickets"])

# Capture context should be tied to cart
cart = Cart.objects.filter(owner=self.user1).first()
self.assertIsNotNone(cart.checkout_context)
self.assertEqual(cart.checkout_context, cap_context_data)

# Tickets should be held
held_tickets = Ticket.objects.filter(holder=self.user1)
self.assertEqual(held_tickets.count(), 3, held_tickets)
self.assertEqual(held_tickets.filter(type="free").count(), 1, held_tickets)
self.assertEqual(held_tickets.filter(type="normal").count(), 1, held_tickets)
self.assertEqual(held_tickets.filter(type="premium").count(), 1, held_tickets)

def test_initiate_checkout_only_free_tickets(self):
self.client.login(username=self.user1.username, password="test")

tickets = [Ticket(type="free", event=self.event1, price=0.0) for _ in range(3)]
Ticket.objects.bulk_create(tickets)

# Add a few free tickets to cart
tickets_to_add = {
"quantities": [
{"type": "free", "count": 3},
]
}
resp = self.client.post(
reverse("club-events-add-to-cart", args=(self.club1.code, self.event1.pk)),
tickets_to_add,
format="json",
)
self.assertIn(resp.status_code, [200, 201], resp.content)

# Initiate checkout
with patch(
".".join(
[
"CyberSource",
"UnifiedCheckoutCaptureContextApi",
"generate_unified_checkout_capture_context_with_http_info",
]
)
) as fake_cap_context:
cap_context_data = "abcde"
fake_cap_context.return_value = cap_context_data, 200, None
resp = self.client.post(reverse("tickets-initiate-checkout"))
self.assertIn(resp.status_code, [200, 201], resp.content)
# check that free tickets were sold
self.assertTrue(resp.data["sold_free_tickets"])

# Ownership transferred
owned_tickets = Ticket.objects.filter(owner=self.user1)
self.assertEqual(owned_tickets.count(), 3, owned_tickets)

# Cart empty
user_cart = Cart.objects.get(owner=self.user1)
self.assertEqual(user_cart.tickets.count(), 0, user_cart)

# Tickets held is 0
held_tickets = Ticket.objects.filter(holder=self.user1)
self.assertEqual(held_tickets.count(), 0, held_tickets)

# Transaction record created
record_exists = TicketTransactionRecord.objects.filter(
reconciliation_id="None"
).exists()
self.assertTrue(record_exists)

def test_initiate_concurrent_checkouts(self):
self.client.login(username=self.user1.username, password="test")

Expand Down
Loading