Skip to content

Commit

Permalink
Add option to pass transaction fees onto buyers (#762)
Browse files Browse the repository at this point in the history
* Add TicketSettings model

* Update views to use ticket settings

* Update tests

* Update tests for codecov

* Minor tweaks to tests

* Revert to default order limit
  • Loading branch information
aviupadhyayula authored Jan 4, 2025
1 parent 56b9394 commit f41a07f
Show file tree
Hide file tree
Showing 5 changed files with 206 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 5.0.4 on 2025-01-02 07:07

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("clubs", "0117_clubapprovalresponsetemplate"),
]

operations = [
migrations.RemoveField(model_name="event", name="ticket_drop_time",),
migrations.RemoveField(model_name="event", name="ticket_order_limit",),
migrations.CreateModel(
name="TicketSettings",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("order_limit", models.IntegerField(blank=True, null=True)),
("drop_time", models.DateTimeField(blank=True, null=True)),
("fee_charged_to_buyer", models.BooleanField(default=False)),
(
"event",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ticket_settings",
to="clubs.event",
),
),
],
),
]
18 changes: 18 additions & 0 deletions backend/clubs/migrations/0119_alter_ticketsettings_order_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2025-01-04 01:23

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("clubs", "0118_remove_event_ticket_drop_time_and_more"),
]

operations = [
migrations.AlterField(
model_name="ticketsettings",
name="order_limit",
field=models.IntegerField(blank=True, default=10, null=True),
),
]
23 changes: 21 additions & 2 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -940,8 +940,6 @@ class Event(models.Model):
parent_recurring_event = models.ForeignKey(
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 Expand Up @@ -969,6 +967,10 @@ class Event(models.Model):
def create_thumbnail(self, request=None):
return create_thumbnail_helper(self, request, 400)

@property
def has_tickets(self):
return self.tickets.exists()

def __str__(self):
return self.name

Expand Down Expand Up @@ -1821,6 +1823,23 @@ class Cart(models.Model):
checkout_context = models.CharField(max_length=8297, blank=True, null=True)


class TicketSettings(models.Model):
"""
Configuration settings for events that have tickets.
Only created when an event has associated tickets created.
"""

event = models.OneToOneField(
Event, on_delete=models.CASCADE, related_name="ticket_settings"
)
order_limit = models.IntegerField(null=True, blank=True, default=10)
drop_time = models.DateTimeField(null=True, blank=True)
fee_charged_to_buyer = models.BooleanField(default=False)

def __str__(self):
return f"Ticket settings for {self.event.name}"

Check warning on line 1840 in backend/clubs/models.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/models.py#L1840

Added line #L1840 was not covered by tests


class TicketQuerySet(models.query.QuerySet):
def delete(self):
if self.filter(transaction_record__isnull=False).exists():
Expand Down
136 changes: 85 additions & 51 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
Tag,
Testimonial,
Ticket,
TicketSettings,
TicketTransactionRecord,
TicketTransferRecord,
Year,
Expand Down Expand Up @@ -2464,6 +2465,14 @@ def add_to_cart(self, request, *args, **kwargs):
---
"""
event = self.get_object()

# Check if event has any tickets
if not event.has_tickets:
return Response(

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

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2471

Added line #L2471 was not covered by tests
{"detail": "This event does not have any tickets", "success": False},
status=status.HTTP_403_FORBIDDEN,
)

cart, _ = Cart.objects.get_or_create(owner=self.request.user)

# Check if the event has already ended
Expand All @@ -2474,7 +2483,10 @@ def add_to_cart(self, request, *args, **kwargs):
)

# Cannot add tickets that haven't dropped yet
if event.ticket_drop_time and timezone.now() < event.ticket_drop_time:
if (
event.ticket_settings.drop_time
and timezone.now() < event.ticket_settings.drop_time
):
return Response(
{"detail": "Ticket drop time has not yet elapsed", "success": False},
status=status.HTTP_403_FORBIDDEN,
Expand All @@ -2490,11 +2502,14 @@ def add_to_cart(self, request, *args, **kwargs):
num_requested = sum(item["count"] for item in quantities)
num_carted = cart.tickets.filter(event=event).count()

if num_requested + num_carted > event.ticket_order_limit:
if (
event.ticket_settings.order_limit
and num_requested + num_carted > event.ticket_settings.order_limit
):
return Response(
{
"detail": f"Order exceeds the maximum ticket limit of "
f"{event.ticket_order_limit}.",
f"{event.ticket_settings.order_limit}.",
"success": False,
},
status=status.HTTP_403_FORBIDDEN,
Expand Down Expand Up @@ -2680,20 +2695,22 @@ def tickets(self, request, *args, **kwargs):
---
"""
event = self.get_object()
tickets = Ticket.objects.filter(event=event)

if event.ticket_drop_time and timezone.now() < event.ticket_drop_time:
if not event.has_tickets or (
event.ticket_settings.drop_time
and timezone.now() < event.ticket_settings.drop_time
):
return Response({"totals": [], "available": []})

# Take price of first ticket of given type for now
totals = (
tickets.values("type")
event.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)
event.tickets.filter(owner__isnull=True, holder__isnull=True, buyable=True)
.values("type")
.annotate(price=Max("price"))
.annotate(count=Count("type"))
Expand All @@ -2705,7 +2722,11 @@ def tickets(self, request, *args, **kwargs):
@transaction.atomic
def create_tickets(self, request, *args, **kwargs):
"""
Create ticket offerings for event
Create or update ticket offerings for an event.
This endpoint allows configuring ticket types, quantities, prices, and settings.
Tickets cannot be modified after they have been dropped or sold.
---
requestBody:
content:
Expand All @@ -2717,6 +2738,11 @@ def create_tickets(self, request, *args, **kwargs):
type: array
items:
type: object
required:
- type
- count
- price
- transferable
properties:
type:
type: string
Expand All @@ -2725,26 +2751,24 @@ def create_tickets(self, request, *args, **kwargs):
price:
type: number
group_size:
type: number
required: false
type: integer
group_discount:
type: number
format: float
required: false
transferable:
type: boolean
buyable:
type: boolean
required: false
order_limit:
type: int
required: false
type: integer
drop_time:
type: string
format: date-time
required: false
fee_charged_to_buyer:
type: boolean
responses:
"200":
description: Tickets created successfully
content:
application/json:
schema:
Expand All @@ -2753,6 +2777,7 @@ def create_tickets(self, request, *args, **kwargs):
detail:
type: string
"400":
description: Invalid request parameters
content:
application/json:
schema:
Expand All @@ -2761,6 +2786,7 @@ def create_tickets(self, request, *args, **kwargs):
detail:
type: string
"403":
description: Tickets cannot be modified
content:
application/json:
schema:
Expand All @@ -2772,27 +2798,59 @@ def create_tickets(self, request, *args, **kwargs):
"""
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:
# Tickets can't be edited after they've been sold or checked out
if event.tickets.filter(
Q(owner__isnull=False) | Q(holder__isnull=False)
).exists():
return Response(
{"detail": "Tickets cannot be edited after they have dropped"},
{
"detail": "Tickets cannot be edited after they have been "
"sold or checked out"
},
status=status.HTTP_403_FORBIDDEN,
)

# Tickets can't be edited after they've been sold or held
ticket_settings, _ = TicketSettings.objects.get_or_create(event=event)

# Tickets can't be edited after they've dropped
if (
Ticket.objects.filter(event=event)
.filter(Q(owner__isnull=False) | Q(holder__isnull=False))
.exists()
event.ticket_settings.drop_time
and timezone.now() > event.ticket_settings.drop_time
):
return Response(
{
"detail": "Tickets cannot be edited after they have been "
"sold or checked out"
},
{"detail": "Tickets cannot be edited after they have dropped"},
status=status.HTTP_403_FORBIDDEN,
)

order_limit = request.data.get("order_limit", None)
if order_limit is not None:
ticket_settings.order_limit = order_limit
ticket_settings.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 2835 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2834-L2835

Added lines #L2834 - L2835 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 2841 in backend/clubs/views.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/views.py#L2841

Added line #L2841 was not covered by tests
{"detail": "Specified drop time has already elapsed"},
status=status.HTTP_400_BAD_REQUEST,
)

ticket_settings.drop_time = drop_time
ticket_settings.save()

fee_charged_to_buyer = request.data.get("fee_charged_to_buyer", None)
if fee_charged_to_buyer is not None:
ticket_settings.fee_charged_to_buyer = fee_charged_to_buyer
ticket_settings.save()

quantities = request.data.get("quantities", [])
if not quantities:
return Response(
Expand Down Expand Up @@ -2855,35 +2913,11 @@ def create_tickets(self, request, *args, **kwargs):
for item in quantities
for _ in range(item["count"])
]

Ticket.objects.bulk_create(tickets)

order_limit = request.data.get("order_limit", None)
if order_limit is not None:
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(
{"detail": f"Invalid drop time: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

if drop_time < timezone.now():
return Response(
{"detail": "Specified drop time has already elapsed"},
status=status.HTTP_400_BAD_REQUEST,
)

event.ticket_drop_time = drop_time
event.save()

cache.delete(f"clubs:{event.club.id}-authed")
cache.delete(f"clubs:{event.club.id}-anon")

return Response({"detail": "Successfully created tickets"})

@action(detail=True, methods=["post"])
Expand Down
Loading

0 comments on commit f41a07f

Please sign in to comment.