Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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),
),
]
20 changes: 20 additions & 0 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import hashlib
import os
import re
import uuid
Expand Down Expand Up @@ -1896,6 +1897,14 @@ class TicketTransactionRecord(models.Model):
buyer_email = models.EmailField(blank=True, null=True)


def get_discount_code(event, ticket):
# get private key as hash of event id, ticket class, and secret
private_key = hashlib.sha256(
f"{event.id}-{ticket.type}-{settings.SECRET_KEY}".encode()
).hexdigest()
return private_key[:8].upper()


class Ticket(models.Model):
"""
Represents an instance of a ticket for an event
Expand Down Expand Up @@ -1930,6 +1939,13 @@ class Ticket(models.Model):
blank=True,
)
group_size = models.PositiveIntegerField(null=True, blank=True)
code_discount = models.DecimalField(
Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Member

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.

max_digits=3,
decimal_places=2,
default=0,
blank=True,
)
discount_code_applied = models.BooleanField(default=False)
transferable = models.BooleanField(default=True)
attended = models.BooleanField(default=False)
# TODO: change to enum between All, Club, None
Expand Down Expand Up @@ -1993,6 +2009,10 @@ def send_confirmation_email(self):
},
)

@property
def discount_code(self):
return get_discount_code(self.event, self)


class TicketTransferRecord(models.Model):
"""
Expand Down
31 changes: 30 additions & 1 deletion backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1804,13 +1804,42 @@ class TicketSerializer(serializers.ModelSerializer):

owner = serializers.SerializerMethodField("get_owner_name")
event = EventSerializer()
discount_code = serializers.SerializerMethodField()

def get_owner_name(self, obj):
return obj.owner.get_full_name() if obj.owner else "None"

def get_discount_code(self, obj):
# only event officers should be able to see discount codes
if (
"request" in self.context
and self.context["request"].user.is_authenticated
and (
membership := Membership.objects.filter(
person=self.context["request"].user, club=obj.event.club
).first()
)
):
if membership.role <= Membership.ROLE_OFFICER:
return obj.discount_code
return None

class Meta:
model = Ticket
fields = ("id", "event", "type", "owner", "attended", "price")
fields = (
"id",
"event",
"type",
"owner",
"attended",
"price",
"buyable",
"code_discount",
"group_discount",
"group_size",
"discount_code",
"discount_code_applied",
)


class UserUUIDSerializer(serializers.ModelSerializer):
Expand Down
114 changes: 97 additions & 17 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
DurationField,
ExpressionWrapper,
F,
Max,
Prefetch,
Q,
TextField,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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()
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Shouldn't this be if item.get("group_discount", 0" is None or ? We want to check that if the group_discount is specified, it's non-null.

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 (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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,
Expand All @@ -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),
)
Expand Down Expand Up @@ -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
Expand Down
Loading