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

Ticketing Cart Pre-Checkout #670

Merged
merged 32 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
865212e
Cart skeleton
julianweng Apr 13, 2024
216125d
Basic UI to payment.
julianweng Apr 13, 2024
ad82226
More appropriately-sized shopping cart icon
julianweng Apr 13, 2024
cf29bd5
Merge branch 'ticketing' into ticketing-cart-checkout
julianweng Apr 15, 2024
67e9c66
Merge branch 'ticketing' into ticketing-cart-checkout
julianweng Apr 16, 2024
c6addda
Payment integration 1st step.
julianweng Apr 16, 2024
e5788e6
Merge branch 'ticketing' into ticketing-cart-checkout
esinx Apr 16, 2024
46776b9
fix(move)
esinx Apr 17, 2024
b5c1ead
Merge branch 'ticketing' into ticketing-cart-checkout
esinx Apr 17, 2024
9b00056
fix(TicketCard): extract and abstract
esinx Apr 17, 2024
a13f8c2
checkout flow ui
esinx Apr 17, 2024
0b746b2
wip(payment)
esinx Apr 17, 2024
0067c4f
Merge branch 'ticketing' of github.com:pennlabs/penn-clubs into ticke…
joyliu-q Apr 20, 2024
1900c8e
:tada: Add ability to remove tickets from cart
joyliu-q Apr 20, 2024
1f937e0
:bug: Better backend error and remove cart logic
joyliu-q Apr 21, 2024
dd18ef8
:tada: Modify sold_out to return event type and count
joyliu-q Apr 21, 2024
06fdcb4
:tada: Add cart empty view and edit mode
joyliu-q Apr 21, 2024
bc0fb02
:art: Add empty view
joyliu-q Apr 21, 2024
c727fe9
:art: Grafik design is my passion
joyliu-q Apr 21, 2024
983ada0
Merge branch 'ticketing' of github.com:pennlabs/penn-clubs into ticke…
joyliu-q Apr 21, 2024
fd3a6bb
:tada: Modify edit success and display toast, correct e2e behavior
joyliu-q Apr 21, 2024
d880347
:bug: Change color to make edit mode more obvious
joyliu-q Apr 21, 2024
c8c3af1
:tada: Toast for sold out tickets
joyliu-q Apr 21, 2024
a9ccf55
Add frontend auth check to cart page and fix sold_out toast functiona…
julianweng Apr 22, 2024
bd18a03
Fix backend tests for group discounts, new cart API, and more (#675)
aviupadhyayula Apr 22, 2024
f36587e
Improve invalid ticket replacement
aviupadhyayula Apr 22, 2024
e7e4f36
:tada: Default to 1 ticket when buying smh
joyliu-q Apr 23, 2024
09a775b
Merge branch 'ticketing-cart-checkout' of github.com:pennlabs/penn-cl…
joyliu-q Apr 23, 2024
b9fc51a
:tada: Add a bunch of style and features
joyliu-q Apr 23, 2024
add9473
:art: Kinda responsive
joyliu-q Apr 23, 2024
6b1998c
Merge brr
joyliu-q Apr 23, 2024
0d78694
:art: Initiate checkout only on button click
joyliu-q Apr 23, 2024
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
129 changes: 97 additions & 32 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2402,7 +2402,7 @@ def add_to_cart(self, request, *args, **kwargs):
quantities = request.data.get("quantities")
if not quantities:
return Response(
{"detail": "Quantities must be specified"},
{"detail": "Quantities must be specified", "success": False},
status=status.HTTP_400_BAD_REQUEST,
)

Expand All @@ -2413,7 +2413,8 @@ def add_to_cart(self, request, *args, **kwargs):
return Response(
{
"detail": f"Order exceeds the maximum ticket limit of "
f"{event.ticket_order_limit}."
f"{event.ticket_order_limit}.",
"success": False,
},
status=status.HTTP_403_FORBIDDEN,
)
Expand All @@ -2440,7 +2441,9 @@ def add_to_cart(self, request, *args, **kwargs):
cart.tickets.add(*tickets[:count])

cart.save()
return Response({"detail": "Successfully added to cart"})
return Response(
{"detail": f"Successfully added {count} to cart", "success": True}
)

@action(detail=True, methods=["post"])
@transaction.atomic
Expand Down Expand Up @@ -2473,13 +2476,26 @@ def remove_from_cart(self, request, *args, **kwargs):
properties:
detail:
type: string
success:
type: boolean
---
"""
event = self.get_object()
quantities = request.data.get("quantities")
if not quantities:
return Response(
{"detail": "Quantities must be specified"},
{
"detail": "Quantities must be specified",
"success": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
if not all(isinstance(item, dict) for item in quantities):
return Response(
{
"detail": "Quantities must be a list of dictionaries",
"success": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
cart = get_object_or_404(Cart, owner=self.request.user)
Expand All @@ -2494,7 +2510,9 @@ def remove_from_cart(self, request, *args, **kwargs):
cart.tickets.remove(*tickets_to_remove[:count])

cart.save()
return Response({"detail": "Successfully removed from cart"})
return Response(
{"detail": f"Successfully removed {count} from cart", "success": True}
)

@action(detail=True, methods=["get"])
def buyers(self, request, *args, **kwargs):
Expand Down Expand Up @@ -2621,6 +2639,7 @@ def create_tickets(self, request, *args, **kwargs):
required: false
group_discount:
type: number
format: float
required: false
transferable:
type: boolean
Expand Down Expand Up @@ -4830,6 +4849,30 @@ class TicketViewSet(viewsets.ModelViewSet):
http_method_names = ["get", "post"]
lookup_field = "id"

@staticmethod
def _calculate_cart_total(cart) -> float:
"""
Calculate the total price of all tickets in a cart, applying discounts
where appropriate. Does not validate that the cart is valid.

:param cart: Cart object
:return: Total price of all tickets in the cart
"""
ticket_type_counts = {
item["type"]: item["count"]
for item in cart.tickets.values("type").annotate(count=Count("type"))
}
cart_total = sum(
(
ticket.price * (1 - ticket.group_discount)
if ticket.group_size
and ticket_type_counts[ticket.type] >= ticket.group_size
else ticket.price
)
for ticket in cart.tickets.all()
)
return cart_total

@transaction.atomic
@update_holds
@action(detail=False, methods=["get"])
Expand All @@ -4850,7 +4893,21 @@ def cart(self, request, *args, **kwargs):
allOf:
- $ref: "#/components/schemas/Ticket"
sold_out:
type: integer
type: array
items:
type: object
properties:
event:
type: object
properties:
id:
type: integer
name:
type: string
type:
type: string
count:
type: integer
---
"""

Expand All @@ -4868,27 +4925,47 @@ def cart(self, request, *args, **kwargs):
return Response(
{
"tickets": TicketSerializer(cart.tickets.all(), many=True).data,
"sold_out": 0,
"sold_out": [],
},
)

sold_out_count = 0
sold_out_tickets, replacement_tickets = [], []

replacement_tickets = []
tickets_in_cart = cart.tickets.values_list("id", flat=True)
event_dict = {
event["id"]: event["name"]
for event in Event.objects.filter(
id__in=tickets_to_replace.values_list("event", flat=True).distinct()
).values("id", "name")
}

# Process each ticket type and event
for ticket_class in tickets_to_replace.values("type", "event").annotate(
count=Count("id")
):
# We don't need to lock since we aren't updating holder/owner
tickets = Ticket.objects.filter(
event=ticket_class["event"],
type=ticket_class["type"],
owner__isnull=True,
holder__isnull=True,
).exclude(id__in=tickets_in_cart)[: ticket_class["count"]]
available_tickets = (
Ticket.objects.filter(
event=ticket_class["event"],
type=ticket_class["type"],
owner__isnull=True,
holder__isnull=True,
)
.exclude(id__in=tickets_in_cart)
.select_related("event")[: ticket_class["count"]]
)

sold_out_count += ticket_class["count"] - tickets.count()
replacement_tickets.extend(list(tickets))
shortage = ticket_class["count"] - available_tickets.count()
if shortage > 0:
sold_out_tickets.append(
{
**ticket_class,
"event": {
"id": ticket_class["event"],
"name": event_dict[ticket_class["event"]],
},
"count": shortage,
}
)

cart.tickets.remove(*tickets_to_replace)
if replacement_tickets:
Expand All @@ -4898,7 +4975,7 @@ def cart(self, request, *args, **kwargs):
return Response(
{
"tickets": TicketSerializer(cart.tickets.all(), many=True).data,
"sold_out": sold_out_count,
"sold_out": sold_out_tickets,
},
)

Expand Down Expand Up @@ -4969,19 +5046,7 @@ def initiate_checkout(self, request, *args, **kwargs):
status=status.HTTP_403_FORBIDDEN,
)

# Calculate cart total, applying group discounts where appropriate
ticket_type_counts = {
item["type"]: item["count"]
for item in cart.tickets.values("type").annotate(count=Count("type"))
}

cart_total = sum(
ticket.price * (1 - ticket.group_discount)
if ticket.group_size
and ticket_type_counts[ticket.type] >= ticket.group_size
else ticket.price
for ticket in tickets
)
cart_total = self._calculate_cart_total(cart)

if not cart_total:
return Response(
Expand Down
76 changes: 69 additions & 7 deletions backend/tests/clubs/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,56 @@ def test_get_cart(self):
self.assertEqual(len(data["tickets"]), 5, data)
for t1, t2 in zip(data["tickets"], tickets_to_add):
self.assertEqual(t1["id"], str(t2.id))
self.assertEqual(data["sold_out"], 0, data)
self.assertEqual(len(data["sold_out"]), 0, data)

def test_calculate_cart_total(self):
# Add a few tickets to cart
cart, _ = Cart.objects.get_or_create(owner=self.user1)
tickets_to_add = self.tickets1[:5]
for ticket in tickets_to_add:
cart.tickets.add(ticket)
cart.save()

expected_total = sum(t.price for t in tickets_to_add)

from clubs.views import TicketViewSet

actual_total = TicketViewSet._calculate_cart_total(cart)
self.assertEqual(actual_total, expected_total)

def test_calculate_cart_total_with_group_discount(self):
# Create tickets with group discount
tickets = [
Ticket.objects.create(
type="group",
event=self.event1,
price=10.0,
group_size=2,
group_discount=0.2,
)
for _ in range(10)
]

cart, _ = Cart.objects.get_or_create(owner=self.user1)
from clubs.views import TicketViewSet

# Add 1 ticket, shouldn't activate group discount
cart.tickets.add(tickets[0])
cart.save()

total = TicketViewSet._calculate_cart_total(cart)
self.assertEqual(total, 10.0) # 1 * price=10 = 10

# Add 4 more tickets, enough to activate group discount
tickets_to_add = tickets[1:5]
for ticket in tickets_to_add:
cart.tickets.add(ticket)
cart.save()

self.assertEqual(cart.tickets.count(), 5)

total = TicketViewSet._calculate_cart_total(cart)
self.assertEqual(total, 40.0) # 5 * price=10 * (1 - group_discount=0.2) = 40

def test_get_cart_replacement_required(self):
self.client.login(username=self.user1.username, password="test")
Expand All @@ -673,7 +722,7 @@ def test_get_cart_replacement_required(self):

# The cart still has 5 tickets: just replaced with available ones
self.assertEqual(len(data["tickets"]), 5, data)
self.assertEqual(data["sold_out"], 0, data)
self.assertEqual(len(data["sold_out"]), 0, data)

in_cart = set(map(lambda t: t["id"], data["tickets"]))
to_add = set(map(lambda t: str(t.id), tickets_to_add))
Expand Down Expand Up @@ -704,13 +753,24 @@ def test_get_cart_replacement_required_sold_out(self):
# The cart now has 3 tickets
self.assertEqual(len(data["tickets"]), 3, data)

# 2 tickets have been sold out
self.assertEqual(data["sold_out"], 2, data)
# Only 1 type of ticket should be sold out
self.assertEqual(len(data["sold_out"]), 1, data)

in_cart = set(map(lambda t: t["id"], data["tickets"]))
to_add = set(map(lambda t: str(t.id), tickets_to_add))
# 2 normal tickets should be sold out
expected_sold_out = {
"type": self.tickets1[0].type,
"event": {
"id": self.tickets1[0].event.id,
"name": self.tickets1[0].event.name,
},
"count": 2,
}
for key, val in expected_sold_out.items():
self.assertEqual(data["sold_out"][0][key], val, data)

# 0 tickets are the same (we sell all but last 3)
in_cart = set(map(lambda t: t["id"], data["tickets"]))
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):
Expand Down Expand Up @@ -908,6 +968,7 @@ def test_complete_checkout(self):
held_tickets = Ticket.objects.filter(holder=self.user1)
self.assertEqual(held_tickets.count(), 2, held_tickets)

# Complete checkout
resp = self.client.post(
reverse("tickets-complete-checkout"),
{"transient_token": "abcdefg"},
Expand All @@ -929,9 +990,10 @@ def test_complete_checkout(self):
self.assertEqual(held_tickets.count(), 0, held_tickets)

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

def test_complete_checkout_stale_cart(self):
self.client.login(username=self.user1.username, password="test")
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/ClubEditPage/EventsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,13 @@ export default function EventsCard({ club }: EventsCardProps): ReactElement {
tableFields={eventTableFields}
noun="Event"
currentTitle={(obj) => (obj != null ? obj.name : 'Deleted Event')}
onChange={(obj) => {
onEditPressed={() => {
eventDetailsRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}}
onChange={(obj) => {
setDeviceContents(obj)
}}
/>
Expand Down
6 changes: 4 additions & 2 deletions frontend/components/ClubPage/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from 'react'
import styled from 'styled-components'

import { mediaMaxWidth, mediaMinWidth, SM } from '~/constants'

import { BORDER, MEDIUM_GRAY, WHITE } from '../../constants/colors'
import { CLUB_APPLY_ROUTE, CLUB_EDIT_ROUTE } from '../../constants/routes'
import {
Expand Down Expand Up @@ -383,13 +385,13 @@ export const QuestionFollowUpAction = ({
}

export const DesktopActions = styled(Actions)`
@media (max-width: 768px) {
${mediaMaxWidth(SM)} {
display: none !important;
}
`

export const MobileActions = styled(Actions)`
@media (min-width: 769px) {
${mediaMinWidth(SM)} {
display: none !important;
}
`
Loading
Loading