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
Binary file added .github/screenshots/approval-guest-pending.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/approval-off-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/approval-pending-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/approval-request-drawer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/screenshots/approval-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
221 changes: 221 additions & 0 deletions buzz/api/booking/approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
from typing import Literal

import frappe
from frappe import _

from buzz.api.booking.details import ticket_type_titles
from buzz.api.booking.exceptions import BookingNotPending, CannotWithdrawBooking
from buzz.api.booking.schemas import (
AffectedRequestsResponse,
ApproveBookingsResponse,
DeclineBookingsResponse,
FailedApproval,
PendingAnswer,
PendingAttendee,
PendingBooking,
PendingBookingsResponse,
SetApprovalResponse,
)
from buzz.api.booking.services import verify_booking_access_token
from buzz.api.events.exceptions import CannotManageEvent
from buzz.api.events.services import manageable_event
from buzz.permissions import has_section_permission
from buzz.ticketing.approval import PENDING_APPROVAL, approval_ticket_types
from buzz.ticketing.doctype.event_ticket_type.event_ticket_type import start_capacity_transaction


class ApprovalService:
"""Approval requests of one event, for anyone who can write its guest list."""

def __init__(self, event: str):
self.event = manageable_event(event, "guests")

def pending_filters(self) -> dict:
return {**PENDING_APPROVAL, "event": self.event.name}

def pending_names(self, limit: int | None = None) -> list[str]:
return frappe.get_all(
"Event Booking",
filters=self.pending_filters(),
order_by="creation asc",
pluck="name",
limit=limit,
)

def pending_booking(self, name: str):
booking = frappe.get_doc("Event Booking", name)
if str(booking.event) != str(self.event.name) or not is_pending_request(booking):
BookingNotPending.throw()
return booking

def pending_bookings(self, limit: int = 100) -> PendingBookingsResponse:
bookings = [frappe.get_doc("Event Booking", name) for name in self.pending_names(limit)]
titles = ticket_type_titles(bookings)
declined_users = self.previously_declined_users()
return PendingBookingsResponse(
total=frappe.db.count("Event Booking", self.pending_filters()),
bookings=[
pending_booking_row(booking, titles, booking.user in declined_users) for booking in bookings
],
)

def previously_declined_users(self) -> set[str]:
filters = {"event": self.event.name, "requires_approval": 1, "status": "Rejected"}
return set(frappe.get_all("Event Booking", filters=filters, pluck="user"))

def approve(self, names: list[str]) -> ApproveBookingsResponse:
start_capacity_transaction()
result = ApproveBookingsResponse(approved=[], failed=[])
for name in names:
error = self.try_approve(name)
if error:
result.failed.append(FailedApproval(booking=name, error=error))
else:
result.approved.append(name)
return result

def try_approve(self, name: str) -> str | None:
"""The reason the request could not be approved, or None once it was."""
frappe.db.savepoint("approve_request")
try:
self.pending_booking(name).approve_request()
except frappe.ValidationError as error:
frappe.db.rollback(save_point="approve_request")
frappe.clear_messages()
return str(error)

def decline(self, names: list[str], message: str | None) -> DeclineBookingsResponse:
reason = (message or "").strip() or None
declined = []
for name in names:
try:
self.pending_booking(name).decline_request(reason)
except BookingNotPending:
continue
declined.append(name)
return DeclineBookingsResponse(declined=declined)

def released_requests(self, ticket_type: str | None) -> list[str]:
"""Pending requests that stop needing approval once it is turned off for
`ticket_type`, or for the whole event when that is None."""
if ticket_type is not None and self.event.requires_approval:
return []
still_required = approval_ticket_types(self.event.name) - {str(ticket_type)}
types_by_booking = self.ticket_types_by_pending_booking()
return [
name
for name, types in types_by_booking.items()
if not types & still_required and (ticket_type is None or str(ticket_type) in types)
]

def ticket_types_by_pending_booking(self) -> dict[str, set[str]]:
names = self.pending_names()
rows = frappe.db.get_all(
"Event Booking Attendee", filters={"parent": ("in", names)}, fields=["parent", "ticket_type"]
)
types = {name: set() for name in names}
for row in rows:
types[row.parent].add(str(row.ticket_type))
return types

def set_approval(
self, enabled: bool, ticket_type: str | None, pending_action: Literal["keep", "approve", "decline"]
) -> SetApprovalResponse:
if not has_section_permission(self.event.name, "ticketing", "write", frappe.session.user):
CannotManageEvent.throw()
setting = self.approval_setting(ticket_type)
released = [] if enabled else self.released_requests(ticket_type)
setting.requires_approval = int(enabled)
setting.flags.ignore_permissions = True
setting.save()
return self.apply_pending_action(released, pending_action)

def approval_setting(self, ticket_type: str | None):
"""The document holding the switch: the event, or one of its ticket types."""
if not ticket_type:
return self.event
ticket_type_doc = frappe.get_doc("Event Ticket Type", ticket_type)
if str(ticket_type_doc.event) != str(self.event.name):
frappe.throw(_("This ticket type belongs to another event"))
return ticket_type_doc

def apply_pending_action(self, released: list[str], action: str) -> SetApprovalResponse:
result = SetApprovalResponse(approved=0, failed=0, declined=0)
if not released:
return result
if action == "approve":
approval = self.approve(released)
result.approved, result.failed = len(approval.approved), len(approval.failed)
elif action == "decline":
result.declined = len(self.decline(released, None).declined)
return result


def pending_booking_row(booking, titles: dict[str, str], previously_declined: bool) -> PendingBooking:
return PendingBooking(
name=booking.name,
buyer_name=booking.buyer_name,
buyer_email=booking.buyer_email,
requested_at=booking.creation,
previously_declined=previously_declined,
attendees=[
PendingAttendee(
full_name=attendee.full_name
or " ".join(part for part in (attendee.first_name, attendee.last_name) if part),
email=attendee.email,
ticket_type=titles.get(str(attendee.ticket_type), str(attendee.ticket_type)),
)
for attendee in booking.attendees
],
answers=[PendingAnswer(label=field.label, value=field.value) for field in booking.additional_fields],
)


def is_pending_request(booking) -> bool:
return booking.docstatus == 0 and bool(booking.requires_approval) and booking.status == "Approval Pending"


def withdraw_request(name: str, token: str | None) -> None:
owner = frappe.db.get_value("Event Booking", name, "user")
if not owner or (owner != frappe.session.user and not verify_booking_access_token(name, token)):
CannotWithdrawBooking.throw()
booking = frappe.get_doc("Event Booking", name)
if not is_pending_request(booking):
BookingNotPending.throw()
booking.discard_as("Withdrawn")


@frappe.whitelist()
def get_pending_bookings(event: str, limit: int = 100) -> PendingBookingsResponse:
return ApprovalService(event).pending_bookings(limit)


@frappe.whitelist(methods=["POST"])
def approve_bookings(event: str, bookings: list[str]) -> ApproveBookingsResponse:
return ApprovalService(event).approve(bookings)


@frappe.whitelist(methods=["POST"])
def decline_bookings(event: str, bookings: list[str], message: str | None = None) -> DeclineBookingsResponse:
return ApprovalService(event).decline(bookings, message)


@frappe.whitelist()
def get_affected_requests(event: str, ticket_type: str | None = None) -> AffectedRequestsResponse:
return AffectedRequestsResponse(count=len(ApprovalService(event).released_requests(ticket_type)))


@frappe.whitelist(methods=["POST"])
def set_approval(
event: str,
enabled: bool,
ticket_type: str | None = None,
pending_action: Literal["keep", "approve", "decline"] = "keep",
) -> SetApprovalResponse:
return ApprovalService(event).set_approval(enabled, ticket_type, pending_action)


# nosemgrep: guest-whitelisted-method
@frappe.whitelist(allow_guest=True, methods=["POST"])
def withdraw_booking(booking: str, token: str | None = None) -> None:
withdraw_request(booking, token)
4 changes: 4 additions & 0 deletions buzz/api/booking/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ def build_booking_confirmation(booking_id: str, token: str | None) -> BookingCon
coupon_code=coupon_code_of(booking_doc),
payment_method=payment_method(booking_doc),
booked_on=booking_doc.creation,
requires_approval=bool(booking_doc.requires_approval),
rejection_reason=booking_doc.rejection_reason,
),
# Guests read this with a token and no session, so the private QR file is inlined
tickets=[ConfirmationTicket(**{**ticket, "qr_code": qr_data_uri(ticket.name)}) for ticket in tickets],
Expand Down Expand Up @@ -255,6 +257,8 @@ def summarize_booking(booking, titles: dict[str, str], add_ons: dict[str, list])
payment_status=booking.payment_status,
payment_method=booking.offline_payment_method or booking.payment_method,
is_offline=booking.payment_method == OFFLINE_PAYMENT_METHOD,
requires_approval=bool(booking.requires_approval),
rejection_reason=booking.rejection_reason,
currency=booking.currency,
booked_on=booking.creation,
lines=build_lines(booking, titles, add_ons),
Expand Down
1 change: 1 addition & 0 deletions buzz/api/booking/event_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def get_event_details(event_doc: "BuzzEvent"):
"allow_guest_booking": event_doc.allow_guest_booking,
"guest_verification_method": event_doc.guest_verification_method,
"default_ticket_type": event_doc.default_ticket_type,
"requires_approval": event_doc.requires_approval,
}


Expand Down
22 changes: 21 additions & 1 deletion buzz/api/booking/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from frappe import _lt

from buzz.api.exceptions import BuzzAPIError, Conflict
from buzz.api.exceptions import BuzzAPIError, Conflict, NotPermitted


class TooManyOTPAttempts(BuzzAPIError):
Expand Down Expand Up @@ -55,3 +55,23 @@ class InvoiceDetailsMissing(BuzzAPIError):
class InvalidCoupon(BuzzAPIError):
title = _lt("Invalid Code")
message = _lt("Invalid or expired code")


class ApprovalNeedsFreeTickets(BuzzAPIError):
title = _lt("Approval Needs Free Tickets")
message = _lt("Approval only works with free tickets. Turn off approval first.")


class ApprovalRequestPending(Conflict):
title = _lt("Request Pending")
message = _lt("You already have a request pending for this event")


class BookingNotPending(Conflict):
title = _lt("Request Not Pending")
message = _lt("This request is no longer waiting for approval.")


class CannotWithdrawBooking(NotPermitted):
title = _lt("Not Permitted")
message = _lt("You cannot withdraw this request.")
5 changes: 3 additions & 2 deletions buzz/api/booking/my_bookings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@


def my_bookings() -> list[MyBooking]:
"""The session user's submitted and cancelled bookings, newest first."""
"""The session user's submitted and cancelled bookings and approval requests, newest first."""
rows = frappe.get_list(
"Event Booking",
fields=MY_BOOKING_FIELDS,
filters={"user": frappe.session.user, "docstatus": ["!=", 0]},
filters={"user": frappe.session.user},
or_filters=[["docstatus", "!=", 0], ["requires_approval", "=", 1]],
order_by="creation desc",
limit=MY_BOOKINGS_LIMIT,
)
Expand Down
55 changes: 55 additions & 0 deletions buzz/api/booking/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class TicketHoldResponse(APIResponse):
class FreeBookingResponse(APIResponse):
booking_name: str
redirect_to: str
approval_pending: bool = False


class OfflineBookingResponse(APIResponse):
Expand Down Expand Up @@ -103,6 +104,8 @@ class ConfirmationBooking(APIResponse):
coupon_code: str | None
payment_method: str | None
booked_on: datetime
requires_approval: bool
rejection_reason: str | None


class ConfirmationTicket(APIResponse):
Expand Down Expand Up @@ -175,6 +178,8 @@ class BookingSummary(APIResponse):
payment_status: str
payment_method: str | None
is_offline: bool
requires_approval: bool
rejection_reason: str | None
currency: str
booked_on: datetime
lines: list[BookingLine]
Expand All @@ -200,3 +205,53 @@ class MyBooking(APIResponse):
status: str | None
# Only counted by the list; each row is a plain ticket_type pair.
attendees: list


class PendingAttendee(APIResponse):
full_name: str
email: str | None
ticket_type: str


class PendingAnswer(APIResponse):
label: str | None
value: str | None


class PendingBooking(APIResponse):
name: str
buyer_name: str | None
buyer_email: str | None
requested_at: datetime
previously_declined: bool
attendees: list[PendingAttendee]
answers: list[PendingAnswer]


class PendingBookingsResponse(APIResponse):
total: int
bookings: list[PendingBooking]


class FailedApproval(APIResponse):
booking: str
error: str


class ApproveBookingsResponse(APIResponse):
approved: list[str]
failed: list[FailedApproval]


class DeclineBookingsResponse(APIResponse):
declined: list[str]


class AffectedRequestsResponse(APIResponse):
count: int


class SetApprovalResponse(APIResponse):
approved: int
failed: int
declined: int
Loading
Loading