From d33ffd433dd387fee02094401bffee70ebd6ce9a Mon Sep 17 00:00:00 2001 From: aviupadhyayula Date: Sat, 18 Jan 2025 11:36:42 -0500 Subject: [PATCH 1/5] Modify transaction model attributes --- ...ettransactionrecord_created_at_and_more.py | 38 ++++++++++++++++ backend/clubs/models.py | 43 ++++++++++++++++++- backend/templates/emails/ticket_refund.html | 26 +++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py create mode 100644 backend/templates/emails/ticket_refund.html diff --git a/backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py b/backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py new file mode 100644 index 000000000..918ab84e8 --- /dev/null +++ b/backend/clubs/migrations/0121_tickettransactionrecord_created_at_and_more.py @@ -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, + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 3c21cd8e8..3d3c33e87 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -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): diff --git a/backend/templates/emails/ticket_refund.html b/backend/templates/emails/ticket_refund.html new file mode 100644 index 000000000..09ebe43c9 --- /dev/null +++ b/backend/templates/emails/ticket_refund.html @@ -0,0 +1,26 @@ + + +{% extends 'emails/base.html' %} + +{% block content %} +

Ticket Refund Confirmation

+ +

+ {{ first_name }}, this email confirms that your tickets for {{ event_names }} have been refunded. The refund amount of ${{ amount }} will be processed back to your original payment method. +

+ +

+ 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. +

+ +

+ If you have any questions about this refund, feel free to respond to this email. +

+{% endblock %} From 8bb1c3319771369295568b5e84718f957de8d51e Mon Sep 17 00:00:00 2001 From: aviupadhyayula Date: Sat, 18 Jan 2025 11:37:09 -0500 Subject: [PATCH 2/5] Add permissioning for viewing transactions --- backend/clubs/permissions.py | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 3511ecd24..59fda9db7 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -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): @@ -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 request.user.is_superuser: + return True + + event_id = request.query_params.get("event_id") + if not event_id: + return False + + if not request.user.is_authenticated: + return False + + if request.user.has_perm("clubs.manage_club"): + return True + + 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 From 14e574ef4b344ff4a92500b4dd9ad706e1a4faae Mon Sep 17 00:00:00 2001 From: aviupadhyayula Date: Sat, 18 Jan 2025 11:40:30 -0500 Subject: [PATCH 3/5] Add viewset, refund route, and serializer --- backend/clubs/serializers.py | 27 ++++++++++ backend/clubs/views.py | 98 +++++++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index fc9f1a095..16790b622 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -57,6 +57,7 @@ TargetYear, Testimonial, Ticket, + TicketTransactionRecord, Year, ) from clubs.utils import clean @@ -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) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 6b2d89843..b4cebf8d4 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -142,6 +142,7 @@ ProfilePermission, QuestionAnswerPermission, ReadOnly, + TicketTransactionPermission, WhartonApplicationPermission, find_membership_helper, ) @@ -193,6 +194,7 @@ TagSerializer, TestimonialSerializer, TicketSerializer, + TicketTransactionRecordSerializer, UserClubVisitSerializer, UserClubVisitWriteSerializer, UserMembershipInviteSerializer, @@ -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) @@ -5937,6 +5938,99 @@ 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. + --- + 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: From cb2c5db9bf73a2cce77c2c553b877f12473f6fac Mon Sep 17 00:00:00 2001 From: aviupadhyayula Date: Sat, 18 Jan 2025 11:40:41 -0500 Subject: [PATCH 4/5] Register routes --- backend/clubs/urls.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index 48f2f0572..dc275c9c8 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -45,6 +45,7 @@ SubscribeViewSet, TagViewSet, TestimonialViewSet, + TicketTransactionRecordViewSet, TicketViewSet, UserGroupAPIView, UserPermissionAPIView, @@ -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") From 52ca5c4512027432953d92390c85c6e47a725ef3 Mon Sep 17 00:00:00 2001 From: aviupadhyayula Date: Sat, 18 Jan 2025 11:53:06 -0500 Subject: [PATCH 5/5] Make tests happy --- backend/clubs/permissions.py | 14 +++++++------- backend/clubs/views.py | 7 +++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/clubs/permissions.py b/backend/clubs/permissions.py index 59fda9db7..9640d8b4f 100644 --- a/backend/clubs/permissions.py +++ b/backend/clubs/permissions.py @@ -571,19 +571,19 @@ class TicketTransactionPermission(permissions.BasePermission): """ def has_permission(self, request, view): - if request.user.is_superuser: - return True - - event_id = request.query_params.get("event_id") - if not event_id: - return False - 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) diff --git a/backend/clubs/views.py b/backend/clubs/views.py index b4cebf8d4..3feab2328 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -5975,6 +5975,13 @@ def refund(self, request, *args, **kwargs): 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: