Skip to content
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

Add ability to transfer ownership of tickets #653

Merged
merged 14 commits into from
Apr 23, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
class Migration(migrations.Migration):

dependencies = [
("clubs", "0103_ticket_group_discount_ticket_group_size"),
("clubs", "0104_cart_checkout_context"),
]

operations = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Generated by Django 5.0.4 on 2024-04-21 21:38

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


class Migration(migrations.Migration):
dependencies = [
("clubs", "0105_event_ticket_drop_time"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.RemoveField(
model_name="ticket",
name="transaction_record",
),
migrations.AddField(
model_name="ticket",
name="transferable",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="tickettransactionrecord",
name="ticket",
field=models.ForeignKey(
default="",
on_delete=django.db.models.deletion.PROTECT,
related_name="transaction_records",
to="clubs.ticket",
),
preserve_default=False,
),
migrations.CreateModel(
name="TicketTransferRecord",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"receiver",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="received_transfers",
to=settings.AUTH_USER_MODEL,
),
),
(
"sender",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="sent_transfers",
to=settings.AUTH_USER_MODEL,
),
),
(
"ticket",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="transfer_records",
to="clubs.ticket",
),
),
],
),
]
81 changes: 61 additions & 20 deletions backend/clubs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1810,19 +1810,6 @@ def update_holds(self):
self.bulk_update(expired_tickets, ["holder"])


class TicketTransactionRecord(models.Model):
"""
Represents an instance of a transaction record for an ticket, used for bookkeeping
"""

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)


class Ticket(models.Model):
"""
Represents an instance of a ticket for an event
Expand Down Expand Up @@ -1857,13 +1844,7 @@ class Ticket(models.Model):
blank=True,
)
group_size = models.PositiveIntegerField(null=True, blank=True)
transaction_record = models.ForeignKey(
TicketTransactionRecord,
related_name="tickets",
on_delete=models.SET_NULL,
blank=True,
null=True,
)
transferable = models.BooleanField(default=True)
objects = TicketManager()

def get_qr(self):
Expand Down Expand Up @@ -1906,6 +1887,66 @@ def send_confirmation_email(self):
)


class TicketTransactionRecord(models.Model):
"""
Represents an instance of a transaction record for an ticket, used for bookkeeping
"""

ticket = models.ForeignKey(
Ticket, related_name="transaction_records", on_delete=models.PROTECT
)
rm03 marked this conversation as resolved.
Show resolved Hide resolved
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)


class TicketTransferRecord(models.Model):
"""
Represents a transfer of ticket ownership, used for bookkeeping
"""

ticket = models.ForeignKey(
Ticket, related_name="transfer_records", on_delete=models.PROTECT
rm03 marked this conversation as resolved.
Show resolved Hide resolved
)
sender = models.ForeignKey(
get_user_model(),
related_name="sent_transfers",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
receiver = models.ForeignKey(
get_user_model(),
related_name="received_transfers",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
rm03 marked this conversation as resolved.
Show resolved Hide resolved
created_at = models.DateTimeField(auto_now_add=True)

def send_confirmation_email(self):
"""
Send confirmation email to the sender and recipient of the transfer.
"""
context = {
"sender_first_name": self.sender.first_name,
"receiver_first_name": self.receiver.first_name,
"receiver_username": self.receiver.username,
"event_name": self.ticket.event.name,
"type": self.ticket.event.type,
}

send_mail_helper(
name="ticket_transfer",
subject=f"Ticket transfer confirmation for {self.ticket.event.name}",
emails=[self.sender.email, self.receiver.email],
context=context,
)


@receiver(models.signals.pre_delete, sender=Asset)
def asset_delete_cleanup(sender, instance, **kwargs):
if instance.file:
Expand Down
118 changes: 98 additions & 20 deletions backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
Testimonial,
Ticket,
TicketTransactionRecord,
TicketTransferRecord,
Year,
ZoomMeetingVisit,
get_mail_type_annotation,
Expand Down Expand Up @@ -2621,6 +2622,8 @@ def create_tickets(self, request, *args, **kwargs):
group_discount:
type: number
required: false
transferable:
aviupadhyayula marked this conversation as resolved.
Show resolved Hide resolved
type: boolean
order_limit:
type: int
required: false
Expand Down Expand Up @@ -2734,6 +2737,7 @@ def create_tickets(self, request, *args, **kwargs):
price=item.get("price", 0),
group_discount=item.get("group_discount", 0),
group_size=item.get("group_size", None),
transferable=item.get("transferable", True),
)
for item in quantities
for _ in range(item["count"])
Expand Down Expand Up @@ -2765,7 +2769,7 @@ def create_tickets(self, request, *args, **kwargs):
event.ticket_drop_time = drop_time
event.save()

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

@action(detail=True, methods=["post"])
def upload(self, request, *args, **kwargs):
Expand Down Expand Up @@ -4816,6 +4820,9 @@ class TicketViewSet(viewsets.ModelViewSet):

qr:
Get a ticket's QR code

transfer:
Transfer a ticket to another user
"""

permission_classes = [IsAuthenticated]
Expand Down Expand Up @@ -5162,28 +5169,29 @@ def complete_checkout(self, request, *args, **kwargs):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

# At this point, we have validated that the payment was authorized
# Give the tickets to the user
tickets = cart.tickets.select_for_update().filter(holder=self.request.user)

# Archive transaction data for historical purposes.
# We're explicitly using the response data over what's in self.request.user
orderInfo = transaction_data["orderInformation"]
transaction_record = TicketTransactionRecord.objects.create(
reconciliation_id=str(reconciliation_id),
total_amount=float(orderInfo["amountDetails"]["totalAmount"]),
buyer_first_name=orderInfo["billTo"]["firstName"],
buyer_last_name=orderInfo["billTo"]["lastName"],
buyer_phone=orderInfo["billTo"]["phoneNumber"],
buyer_email=orderInfo["billTo"]["email"],
)
order_info = transaction_data["orderInformation"]
transaction_records = []
for ticket in tickets:
transaction_records.append(
TicketTransactionRecord(
ticket=ticket,
reconciliation_id=str(reconciliation_id),
total_amount=float(order_info["amountDetails"]["totalAmount"]),
buyer_first_name=order_info["billTo"]["firstName"],
buyer_last_name=order_info["billTo"]["lastName"],
buyer_phone=order_info["billTo"]["phoneNumber"],
buyer_email=order_info["billTo"]["email"],
)
)

# At this point, we have validated that the payment was authorized
# Give the tickets to the user
tickets = (
cart.tickets.select_for_update()
.filter(holder=self.request.user)
.prefetch_related("carts")
)
tickets.update(
owner=request.user, holder=None, transaction_record=transaction_record
)
TicketTransactionRecord.objects.bulk_create(transaction_records)
tickets.update(owner=request.user, holder=None)
cart.tickets.clear()
for ticket in tickets:
ticket.send_confirmation_email()
Expand Down Expand Up @@ -5221,6 +5229,76 @@ def qr(self, request, *args, **kwargs):
qr_image.save(response, "PNG")
return response

@action(detail=True, methods=["post"])
rm03 marked this conversation as resolved.
Show resolved Hide resolved
@transaction.atomic
def transfer(self, request, *args, **kwargs):
"""
Transfer a ticket to another user
---
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
required:
- username
responses:
"200":
content:
application/json:
schema:
type: object
properties:
detail:
type: string
"403":
content:
application/json:
schema:
type: object
properties:
detail:
type: string
"404":
content:
application/json:
schema:
type: object
properties:
detail:
type: string
---
"""
receiver = get_object_or_404(
get_user_model(), username=request.data.get("username")
)

# checking whether the request's user owns the ticket is handled by the queryset
ticket = self.get_object()
rm03 marked this conversation as resolved.
Show resolved Hide resolved
if not ticket.transferable:
return Response(
{"detail": "The ticket is non-transferable"},
status=status.HTTP_403_FORBIDDEN,
)

if self.request.user == receiver:
return Response(
{"detail": "You cannot transfer a ticket to yourself"},
status=status.HTTP_403_FORBIDDEN,
)

ticket.owner = receiver
rm03 marked this conversation as resolved.
Show resolved Hide resolved
ticket.save()
TicketTransferRecord.objects.create(
ticket=ticket, sender=self.request.user, receiver=receiver
).send_confirmation_email()
Copy link
Member

Choose a reason for hiding this comment

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

At this point, you'll probably need to invalidate the sender's QR, currently built using

url = f"https://{settings.DOMAIN}/api/tickets/{self.id}"

This URL was put in as a placeholder and is not actually suitable. We want a URL that is unique to the user/locked behind auth, so that others can't access it. We could use a hash of ticket.UUID + ticket.owner, since locking behind auth means locking behind Penn access. If we change the way we do the URL, we would need to write another endpoint that validates it.

@julianweng could we co-ordinate with Anthony on the quickest way to get ticket QR code validation into iOS? Or do we want to use a different interface?

Copy link
Member Author

@rm03 rm03 Apr 21, 2024

Choose a reason for hiding this comment

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

After some discussion I think it would be better to put this in a separate PR. I've made the updates locally but more changes will likely be required until we finalize details about QR code scanning.

ticket.send_confirmation_email() # send event details to recipient

return Response({"detail": "Successfully transferred ownership of ticket"})

def get_queryset(self):
return Ticket.objects.filter(owner=self.request.user.id)

Expand Down
2 changes: 1 addition & 1 deletion backend/templates/emails/ticket_confirmation.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ <h2>Thanks for using Penn Clubs!</h2>

<img id="now" style="max-width: 60%; width: auto;" alt="" src="data:image/png;base64,{{ qr}}" />

<p style="font-size: 1.2em"> Note: all tickets issued by us are <b>non-refundable</b> and <b>non-transferable</b>. </p>
<p style="font-size: 1.2em"> Note: all tickets issued by us are <b>non-refundable</b>. </p>
rm03 marked this conversation as resolved.
Show resolved Hide resolved



Expand Down
27 changes: 27 additions & 0 deletions backend/templates/emails/ticket_transfer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!-- TYPES:
sender_first_name:
type: string
receiver_first_name:
type: string
receiver_username:
type: string
event_name:
type: string
type:
type: string
-->

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

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

<p style="font-size: 1.2em">
<b>{{ sender_first_name }}</b>, this is confirmation that you have transferred a ticket to <b>{{ receiver_first_name }}</b> (<b>{{ receiver_username }}</b>) for <b>{{ event_name }}</b> with ticket type <b>{{ type }}</b>.
</p>


<p style="font-size: 1.2em">
If you believe that this was sent in error or have any questions, feel free to respond to this email.
</p>
{% endblock %}
Loading
Loading