Skip to content
Draft
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,38 @@
# Generated by Django 5.0.4 on 2025-01-18 16:35

import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("clubs", "0120_ticketsettings_active"),
]

operations = [
migrations.AddField(
model_name="tickettransactionrecord",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="tickettransactionrecord",
name="refunded",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="tickettransactionrecord",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="tickettransactionrecord",
name="buyer_email",
field=models.EmailField(default="contact@pennclubs.com", max_length=254),
preserve_default=False,
),
]
43 changes: 41 additions & 2 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1869,10 +1869,49 @@ class TicketTransactionRecord(models.Model):

reconciliation_id = models.CharField(max_length=100, null=True, blank=True)
total_amount = models.DecimalField(max_digits=5, decimal_places=2)
buyer_phone = PhoneNumberField(null=True, blank=True)
buyer_first_name = models.CharField(max_length=100)
buyer_last_name = models.CharField(max_length=100)
buyer_email = models.EmailField(blank=True, null=True)
buyer_email = models.EmailField()
buyer_phone = PhoneNumberField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
refunded = models.BooleanField(default=False)

def send_confirmation_email(self):
"""
Send confirmation emails to both the buyer and event organizers after a refund
"""
if not self.refunded:
raise ValueError(
"Cannot send refund confirmation for non-refunded transaction."
)

tickets = self.tickets.all()
events = set(ticket.event for ticket in tickets)

club_emails = set()
for event in events:
if event.club.email:
club_emails.add(event.club.email)

officer_emails = Membership.objects.filter(
club=event.club, role__lte=Membership.ROLE_OFFICER
).values_list("person__email", flat=True)
club_emails.update(officer_emails)

context = {
"first_name": self.buyer_first_name,
"event_names": ", ".join(event.name for event in events),
"amount": "{:.2f}".format(self.total_amount),
}

send_mail_helper(
name="ticket_refund",
subject="Your ticket purchase has been refunded",
emails=[self.buyer_email],
bcc=list(club_emails),
context=context,
)


class Ticket(models.Model):
Expand Down
42 changes: 41 additions & 1 deletion backend/clubs/permissions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib.auth import get_user_model
from rest_framework import permissions

from clubs.models import Club, Membership
from clubs.models import Club, Event, Membership


def codes_extract_helper(obj, key):
Expand Down Expand Up @@ -562,3 +562,43 @@ def has_permission(self, request, view):
return True
else:
return request.user.is_authenticated


class TicketTransactionPermission(permissions.BasePermission):
"""
Superusers can see and refund all transactions. Else, event must be specified.
Officers and above can see and refund transactions for specified events.
"""

def has_permission(self, request, view):
if not request.user.is_authenticated:
return False

if request.user.is_superuser:
return True

if request.user.has_perm("clubs.manage_club"):
return True

event_id = request.query_params.get("event_id", None)
if not event_id:
return False

try:
event = Event.objects.get(id=event_id)
membership = find_membership_helper(request.user, event.club)
return membership is not None and membership.role <= Membership.ROLE_OFFICER
except Event.DoesNotExist:
return False

def has_object_permission(self, request, view, obj):
if request.user.is_superuser:
return True

if not request.user.is_authenticated:
return False

membership = find_membership_helper(
request.user, obj.tickets.first().event.club
)
return membership is not None and membership.role <= Membership.ROLE_OFFICER
27 changes: 27 additions & 0 deletions backend/clubs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
TargetYear,
Testimonial,
Ticket,
TicketTransactionRecord,
Year,
)
from clubs.utils import clean
Expand Down Expand Up @@ -1797,6 +1798,32 @@ class Meta:
fields = ("id", "event", "type", "owner", "attended", "price")


class TicketTransactionRecordSerializer(serializers.ModelSerializer):
"""
Serializer for ticket transaction records, including related ticket information
and buyer details.
"""

tickets = TicketSerializer(many=True, read_only=True)

class Meta:
model = TicketTransactionRecord
fields = [
"id",
"reconciliation_id",
"total_amount",
"buyer_first_name",
"buyer_last_name",
"buyer_email",
"buyer_phone",
"tickets",
"refunded",
"created_at",
"updated_at",
]
read_only_fields = fields


class UserUUIDSerializer(serializers.ModelSerializer):
"""
Used to get the uuid of a user (for ICS Calendar export)
Expand Down
4 changes: 4 additions & 0 deletions backend/clubs/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
SubscribeViewSet,
TagViewSet,
TestimonialViewSet,
TicketTransactionRecordViewSet,
TicketViewSet,
UserGroupAPIView,
UserPermissionAPIView,
Expand Down Expand Up @@ -73,6 +74,9 @@
router.register(r"memberships", MembershipViewSet, basename="members")
router.register(r"requests", MembershipRequestViewSet, basename="requests")
router.register(r"tickets", TicketViewSet, basename="tickets")
router.register(
r"transactions", TicketTransactionRecordViewSet, basename="transactions"
)

router.register(r"schools", SchoolViewSet, basename="schools")
router.register(r"majors", MajorViewSet, basename="majors")
Expand Down
105 changes: 103 additions & 2 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
ProfilePermission,
QuestionAnswerPermission,
ReadOnly,
TicketTransactionPermission,
WhartonApplicationPermission,
find_membership_helper,
)
Expand Down Expand Up @@ -193,6 +194,7 @@
TagSerializer,
TestimonialSerializer,
TicketSerializer,
TicketTransactionRecordSerializer,
UserClubVisitSerializer,
UserClubVisitWriteSerializer,
UserMembershipInviteSerializer,
Expand Down Expand Up @@ -5911,9 +5913,8 @@ def _give_tickets(user, order_info, cart, 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"],
buyer_phone=order_info["billTo"]["phoneNumber"],
)
tickets.update(owner=user, holder=None, transaction_record=transaction_record)

Expand All @@ -5937,6 +5938,106 @@ def _place_hold_on_tickets(user, tickets):
tickets.update(holder=user, holding_expiration=holding_expiration)


class TicketTransactionRecordViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing ticket transaction records.

list: Get transaction records for specified event. Superusers can see all records.
retrieve: Get details of a specific transaction record.
refund: Process refund of a transaction. Frees up tickets and sends emails.
"""

queryset = TicketTransactionRecord.objects.all()
permission_classes = [TicketTransactionPermission | IsSuperuser]
serializer_class = TicketTransactionRecordSerializer
http_method_names = ["get"]

def get_queryset(self):
queryset = super().get_queryset()

if (
self.request.user.has_perm("clubs.generate_reports")
or self.request.user.is_superuser
):
return queryset

event_id = self.request.query_params.get("event_id", None)
if not event_id:
return queryset.none()

return queryset.filter(tickets__event_id=event_id)

@action(detail=True, methods=["post"])
def refund(self, request, *args, **kwargs):
"""
Process a refund for a transaction record.

Marks the transaction as refunded, makes tickets available again,
and sends confirmation emails to the buyer and event organizer.
---
requestBody:
content:
application/json:
schema:
type: object
properties: {}
required: []
responses:
"200":
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
detail:
type: string
"400":
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
detail:
type: string
---
"""
transaction = self.get_object()

# Check if already refunded
if transaction.refunded:
return Response(
{
"success": False,
"detail": "This transaction has already been refunded.",
},
status=status.HTTP_400_BAD_REQUEST,
)

# TODO: refund tickets through CyberSource API

with transaction.atomic():
transaction.refunded = True
transaction.save(update_fields=["refunded"])

# Make tickets available again
tickets = transaction.tickets.all()
tickets.update(owner=None, holder=None, transaction_record=None)

# Send confirmation emails
transaction.send_refund_confirmation_emails()

return Response(
{
"success": True,
"detail": "Successfully processed refund and notified all parties.",
}
)


class MemberInviteViewSet(viewsets.ModelViewSet):
"""
update:
Expand Down
26 changes: 26 additions & 0 deletions backend/templates/emails/ticket_refund.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!-- TYPES:
first_name:
type: string
event_names:
type: string
amount:
type: decimal
-->

{% extends 'emails/base.html' %}

{% block content %}
<h2>Ticket Refund Confirmation</h2>

<p style="font-size: 1.2em">
{{ first_name }}, this email confirms that your tickets for <b>{{ event_names }}</b> have been refunded. The refund amount of <b>${{ amount }}</b> will be processed back to your original payment method.
</p>

<p style="font-size: 1.2em">
The refunded tickets are no longer valid for entry to the event(s). If you would like to attend in the future, you will need to purchase new tickets if they are still available.
</p>

<p style="font-size: 1.2em">
If you have any questions about this refund, feel free to respond to this email.
</p>
{% endblock %}