Skip to content

Commit

Permalink
Add route for admins to issue tickets to users (#679)
Browse files Browse the repository at this point in the history
* Add issues_ticket route

* Improve efficiency

* Add tests for `issue_tickets`

* Minor refactor to tests

* Create transaction records after issuing

* Make unit test more exhaustive

* Return errors as array in response

* Lock issue_tickets behind perms

* Revert "Lock issue_tickets behind perms"

This reverts commit 47e63b0.

* Remove unnecessary holds

* Change input schema naming

* Auth lock issue_tickets route

* Add test for perms
  • Loading branch information
aviupadhyayula committed Apr 25, 2024
1 parent 71e1fbc commit da6abe0
Show file tree
Hide file tree
Showing 3 changed files with 279 additions and 2 deletions.
2 changes: 1 addition & 1 deletion backend/clubs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def has_object_permission(self, request, view, obj):

if not old_type == FAIR_TYPE and new_type == FAIR_TYPE:
return False
elif view.action in ["buyers", "create_tickets"]:
elif view.action in ["buyers", "create_tickets", "issue_tickets"]:
if not request.user.is_authenticated:
return False

Check warning on line 228 in backend/clubs/permissions.py

View check run for this annotation

Codecov / codecov/patch

backend/clubs/permissions.py#L228

Added line #L228 was not covered by tests
membership = find_membership_helper(request.user, obj.club)
Expand Down
155 changes: 154 additions & 1 deletion backend/clubs/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2686,7 +2686,7 @@ def create_tickets(self, request, *args, **kwargs):
status=status.HTTP_403_FORBIDDEN,
)

# Tickets can't be edited after they've been sold
# Tickets can't be edited after they've been sold or held
if (
Ticket.objects.filter(event=event)
.filter(Q(owner__isnull=False) | Q(holder__isnull=False))
Expand Down Expand Up @@ -2790,6 +2790,159 @@ def create_tickets(self, request, *args, **kwargs):

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

@action(detail=True, methods=["post"])
@transaction.atomic
@update_holds
def issue_tickets(self, request, *args, **kwargs):
"""
Issue tickets that have already been created to users in bulk.
---
requestBody:
content:
application/json:
schema:
type: object
properties:
tickets:
type: array
items:
type: object
properties:
username:
type: string
ticket_type:
type: string
responses:
"200":
content:
application/json:
schema:
type: object
properties:
detail:
type: string
"400":
content:
application/json:
schema:
type: object
properties:
detail:
type: string
errors:
type: array
items:
type: string
---
"""
event = self.get_object()

tickets = request.data.get("tickets", [])

if not tickets:
return Response(
{"detail": "tickets must be specified", "errors": []},
status=status.HTTP_400_BAD_REQUEST,
)

for item in tickets:
if not item.get("username") or not item.get("ticket_type"):
return Response(
{
"detail": "Specify username and ticket type to issue tickets",
"errors": [],
},
status=status.HTTP_400_BAD_REQUEST,
)

usernames = [item.get("username") for item in tickets]
ticket_types = [item.get("ticket_type") for item in tickets]

# Validate all usernames
invalid_usernames = set(usernames) - set(
get_user_model()
.objects.filter(username__in=usernames)
.values_list("username", flat=True)
)
if invalid_usernames:
return Response(
{
"detail": "Invalid usernames",
"errors": sorted(list(invalid_usernames)),
},
status=status.HTTP_400_BAD_REQUEST,
)

# Validate all ticket types
invalid_types = set(ticket_types) - set(
Ticket.objects.filter(event=event).values_list("type", flat=True)
)
if invalid_types:
return Response(
{
"detail": "Invalid ticket classes",
"errors": sorted(list(invalid_types)),
},
status=status.HTTP_400_BAD_REQUEST,
)

tickets = []
for ticket_type, num_requested in collections.Counter(ticket_types).items():
available_tickets = Ticket.objects.select_for_update(
skip_locked=True
).filter(
event=event, type=ticket_type, owner__isnull=True, holder__isnull=True
)[:num_requested]

if available_tickets.count() < num_requested:
return Response(
{
"detail": (
f"Not enough tickets available for type: {ticket_type}"
),
"errors": [],
},
status=status.HTTP_400_BAD_REQUEST,
)

tickets.extend(available_tickets)

# Assign tickets to users
transaction_records = []

for username, ticket_type in zip(usernames, ticket_types):
user = get_user_model().objects.filter(username=username).first()
ticket = next(
ticket
for ticket in tickets
if ticket.type == ticket_type and ticket.owner is None
)
ticket.owner = user
ticket.holder = None

transaction_records.append(
TicketTransactionRecord(
ticket=ticket,
total_amount=0.0,
buyer_first_name=user.first_name,
buyer_last_name=user.last_name,
buyer_email=user.email,
)
)

Ticket.objects.bulk_update(tickets, ["owner", "holder"])
Ticket.objects.update_holds()

TicketTransactionRecord.objects.bulk_create(transaction_records)

for ticket in tickets:
ticket.send_confirmation_email()

return Response(
{"success": True, "detail": f"Issued {len(tickets)} tickets", "errors": []}
)

@action(detail=True, methods=["post"])
def upload(self, request, *args, **kwargs):
"""
Expand Down
124 changes: 124 additions & 0 deletions backend/tests/clubs/test_ticketing.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,130 @@ def test_create_ticket_offerings_already_owned_or_held(self):
)
self.assertEqual(resp.status_code, 403, resp.content)

def test_issue_tickets(self):
self.client.login(username=self.user1.username, password="test")
args = {
"tickets": [
{"username": self.user1.username, "ticket_type": "normal"},
{"username": self.user1.username, "ticket_type": "premium"},
{"username": self.user2.username, "ticket_type": "normal"},
{"username": self.user2.username, "ticket_type": "premium"},
]
}
resp = self.client.post(
reverse(
"club-events-issue-tickets", args=(self.club1.code, self.event1.pk)
),
args,
format="json",
)

self.assertEqual(resp.status_code, 200, resp.content)

for item in args["tickets"]:
username, ticket_type = item["username"], item["ticket_type"]
user = get_user_model().objects.get(username=username)

self.assertEqual(
Ticket.objects.filter(type=ticket_type, owner=user).count(), 1
)
self.assertTrue(
TicketTransactionRecord.objects.filter(
ticket__type=ticket_type,
ticket__owner=user,
total_amount=0.0,
).exists()
)

def test_issue_tickets_bad_perms(self):
# user2 is not a superuser or club officer+
self.client.login(username=self.user2.username, password="test")
args = {
"tickets": [
{"username": self.user1.username, "ticket_type": "normal"},
{"username": self.user2.username, "ticket_type": "normal"},
]
}

resp = self.client.post(
reverse(
"club-events-issue-tickets", args=(self.club1.code, self.event1.pk)
),
args,
format="json",
)

self.assertEqual(resp.status_code, 403, resp.content)

def test_issue_tickets_invalid_username_ticket_type(self):
# All usernames must be valid
self.client.login(username=self.user1.username, password="test")
args = {
"tickets": [
{"username": "invalid_user_1", "ticket_type": "normal"},
{"username": "invalid_user_2", "ticket_type": "premium"},
]
}
resp = self.client.post(
reverse(
"club-events-issue-tickets", args=(self.club1.code, self.event1.pk)
),
args,
format="json",
)

self.assertEqual(resp.status_code, 400, resp.content)
data = resp.json()
self.assertEqual(data["errors"], ["invalid_user_1", "invalid_user_2"])

# All requested ticket types must be valid
args = {
"tickets": [
{"username": self.user2.username, "ticket_type": "invalid_type_1"},
{"username": self.user2.username, "ticket_type": "invalid_type_2"},
]
}
resp = self.client.post(
reverse(
"club-events-issue-tickets", args=(self.club1.code, self.event1.pk)
),
args,
format="json",
)

self.assertEqual(resp.status_code, 400, resp.content)
data = resp.json()
self.assertEqual(data["errors"], ["invalid_type_1", "invalid_type_2"])

def test_issue_tickets_insufficient_quantity(self):
self.client.login(username=self.user1.username, password="test")
args = {
"tickets": [
{"username": self.user2.username, "ticket_type": "normal"}
for _ in range(100)
]
}
resp = self.client.post(
reverse(
"club-events-issue-tickets", args=(self.club1.code, self.event1.pk)
),
args,
format="json",
)

self.assertEqual(resp.status_code, 400, resp.content)
self.assertIn(
"Not enough tickets available for type: normal", str(resp.content)
)

# No tickets should be transferred
self.assertEqual(Ticket.objects.filter(owner=self.user2).count(), 0)

# No holds should be given
self.assertEqual(
Ticket.objects.filter(type="normal", holder__isnull=False).count(), 0
)

def test_get_tickets_information_no_tickets(self):
# Delete all the tickets
Ticket.objects.all().delete()
Expand Down

0 comments on commit da6abe0

Please sign in to comment.