-
Notifications
You must be signed in to change notification settings - Fork 7
Implement discount (promo) codes #798
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
base: master
Are you sure you want to change the base?
Changes from all commits
6b88837
cf24ef4
6cb2c56
50950f9
efa6e0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # Generated by Django 5.1 on 2025-03-23 22:43 | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("clubs", "0119_registrationqueuesettings"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="ticket", | ||
| name="code_discount", | ||
| field=models.DecimalField(blank=True, decimal_places=2, | ||
| default=0, max_digits=3), | ||
| ), | ||
| migrations.AddField( | ||
| model_name="ticket", | ||
| name="discount_code_applied", | ||
| field=models.BooleanField(default=False), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,7 +46,6 @@ | |
| DurationField, | ||
| ExpressionWrapper, | ||
| F, | ||
| Max, | ||
| Prefetch, | ||
| Q, | ||
| TextField, | ||
|
|
@@ -2539,6 +2538,9 @@ def add_to_cart(self, request, *args, **kwargs): | |
| type: string | ||
| count: | ||
| type: integer | ||
| discount_code: | ||
| type: string | ||
| required: false | ||
| responses: | ||
| "200": | ||
| content: | ||
|
|
@@ -2620,6 +2622,7 @@ def add_to_cart(self, request, *args, **kwargs): | |
| for item in quantities: | ||
| type = item["type"] | ||
| count = item["count"] | ||
| discount_code = item.get("discount_code", None) | ||
|
|
||
| # Count unowned/unheld tickets of requested type | ||
| # We don't need a lock since we aren't changing the holder or owner | ||
|
|
@@ -2643,6 +2646,18 @@ def add_to_cart(self, request, *args, **kwargs): | |
| }, | ||
| status=status.HTTP_403_FORBIDDEN, | ||
| ) | ||
|
|
||
| if discount_code: | ||
| valid_discount_code = tickets.first().discount_code | ||
| if discount_code != valid_discount_code: | ||
| return Response( | ||
| { | ||
| "detail": f"Invalid discount code for {type}", | ||
| "success": False, | ||
| }, | ||
| status=status.HTTP_400_BAD_REQUEST, | ||
| ) | ||
| tickets.update(discount_code_applied=True) | ||
|
Comment on lines
+2650
to
+2660
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will the promo code be applied when the user adds tickets to their cart? I was under the impression that we'd let users add promo codes before checkout (e.g. on the cart page). |
||
| cart.tickets.add(*tickets[:count]) | ||
|
|
||
| cart.save() | ||
|
|
@@ -2783,6 +2798,13 @@ def tickets(self, request, *args, **kwargs): | |
| type: integer | ||
| price: | ||
| type: number | ||
| code_discount: | ||
| type: number | ||
| group_discount: | ||
| type: number | ||
| group_size: | ||
| type: number | ||
| required: false | ||
| available: | ||
| type: array | ||
| items: | ||
|
|
@@ -2794,26 +2816,60 @@ def tickets(self, request, *args, **kwargs): | |
| type: integer | ||
| price: | ||
| type: number | ||
| code_discount: | ||
| type: number | ||
| group_discount: | ||
| type: number | ||
| group_size: | ||
| type: number | ||
| --- | ||
| """ | ||
| event = self.get_object() | ||
| tickets = Ticket.objects.filter(event=event) | ||
| # if for purchasing purposes, non-buyable tickets are unavailable | ||
| # otherwise, all non-owned/held tickets are available | ||
| for_purchasing = self.request.query_params.get("for_purchasing", "true") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: let's do validation here (has to be true or false) and then convert to a boolean. |
||
|
|
||
| # Take price of first ticket of given type for now | ||
| totals = ( | ||
| 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) | ||
| .values("type") | ||
| .annotate(price=Max("price")) | ||
| .annotate(count=Count("type")) | ||
| .order_by("type") | ||
| total_counts = { | ||
| t["type"]: t["count"] | ||
| for t in tickets.values("type").annotate(count=Count("type")) | ||
| } | ||
|
|
||
| available_tickets = tickets.filter( | ||
| owner__isnull=True, | ||
| holder__isnull=True, | ||
| ) | ||
| return Response({"totals": list(totals), "available": list(available)}) | ||
| if for_purchasing == "true": | ||
| available_tickets = available_tickets.filter(buyable=True) | ||
| available_counts = { | ||
| t["type"]: t["count"] | ||
| for t in available_tickets.values("type").annotate(count=Count("type")) | ||
| } | ||
|
|
||
| # Get one representative ticket for each type and serialize it | ||
| totals_list = [] | ||
| available_list = [] | ||
| for ticket_type in tickets.values_list("type", flat=True).distinct(): | ||
| ticket = tickets.filter(type=ticket_type).first() | ||
|
Comment on lines
+2853
to
+2854
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't we just get a ticket of each type in a single query? This looks expensive. |
||
| if ticket: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: use guard clause pattern instead. |
||
| # Serialize the ticket and add counts | ||
| ticket_data = TicketSerializer( | ||
| ticket, context={"request": request} | ||
| ).data | ||
|
|
||
| # Add to totals | ||
| totals_entry = ticket_data.copy() | ||
| totals_entry["count"] = total_counts.get(ticket_type, 0) | ||
| totals_list.append(totals_entry) | ||
|
|
||
| # Add to available if there are any available tickets | ||
| if ticket_type in available_counts: | ||
| available_entry = ticket_data.copy() | ||
| available_entry["count"] = available_counts.get(ticket_type, 0) | ||
| available_list.append(available_entry) | ||
|
|
||
| return Response({"totals": totals_list, "available": available_list}) | ||
|
|
||
| @tickets.mapping.put | ||
| @transaction.atomic | ||
|
|
@@ -2845,6 +2901,10 @@ def create_tickets(self, request, *args, **kwargs): | |
| type: number | ||
| format: float | ||
| required: false | ||
| code_discount: | ||
| type: number | ||
| format: float | ||
| required: false | ||
| transferable: | ||
| type: boolean | ||
| buyable: | ||
|
|
@@ -2929,14 +2989,25 @@ def create_tickets(self, request, *args, **kwargs): | |
| ) | ||
|
|
||
| # Group discounts must be between 0 and 1 | ||
| if item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1: | ||
| if item.get("group_discount", 0) is not None and ( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be |
||
| item.get("group_discount", 0) < 0 or item.get("group_discount", 0) > 1 | ||
| ): | ||
| return Response( | ||
| {"detail": "Group discount must be between 0 and 1"}, | ||
| status=status.HTTP_400_BAD_REQUEST, | ||
| ) | ||
|
|
||
| # Code discounts must be between 0 and 1 | ||
| if item.get("code_discount", 0) is not None and ( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as above. |
||
| item.get("code_discount", 0) < 0 or item.get("code_discount", 0) > 1 | ||
| ): | ||
| return Response( | ||
| {"detail": "Code discount must be between 0 and 1"}, | ||
| status=status.HTTP_400_BAD_REQUEST, | ||
| ) | ||
|
|
||
| # Min group sizes must be greater than 1 | ||
| if item.get("group_size", 2) <= 1: | ||
| if item.get("group_size", 2) is not None and item.get("group_size", 2) <= 1: | ||
| return Response( | ||
| {"detail": "Min group size must be greater than 1"}, | ||
| status=status.HTTP_400_BAD_REQUEST, | ||
|
|
@@ -2961,8 +3032,9 @@ def create_tickets(self, request, *args, **kwargs): | |
| event=event, | ||
| type=item["type"], | ||
| price=item.get("price", 0), | ||
| group_discount=item.get("group_discount", 0), | ||
| group_discount=item.get("group_discount", 0) or 0, # Field not nullable | ||
| group_size=item.get("group_size", None), | ||
| code_discount=item.get("code_discount", 0) or 0, | ||
| transferable=item.get("transferable", True), | ||
| buyable=item.get("buyable", True), | ||
| ) | ||
|
|
@@ -5548,6 +5620,14 @@ def _calculate_cart_total(cart) -> float: | |
| and ticket_type_counts[ticket.type] >= ticket.group_size | ||
| else ticket.price | ||
| ) | ||
| * ( | ||
| 1 | ||
| - ( | ||
| ticket.code_discount | ||
| if ticket.code_discount and ticket.discount_code_applied | ||
| else 0 | ||
| ) | ||
| ) | ||
| for ticket in cart.tickets.all() | ||
| ) | ||
| return cart_total | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: should this be
discount_code?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I think we should delineate between promo codes and group discounts throughout the codebase. Let's refer to these as promo codes instead.