diff --git a/.github/screenshots/approval-guest-pending.png b/.github/screenshots/approval-guest-pending.png new file mode 100644 index 000000000..fda9e152f Binary files /dev/null and b/.github/screenshots/approval-guest-pending.png differ diff --git a/.github/screenshots/approval-off-dialog.png b/.github/screenshots/approval-off-dialog.png new file mode 100644 index 000000000..60dd868df Binary files /dev/null and b/.github/screenshots/approval-off-dialog.png differ diff --git a/.github/screenshots/approval-pending-tab.png b/.github/screenshots/approval-pending-tab.png new file mode 100644 index 000000000..dd0ef973d Binary files /dev/null and b/.github/screenshots/approval-pending-tab.png differ diff --git a/.github/screenshots/approval-request-drawer.png b/.github/screenshots/approval-request-drawer.png new file mode 100644 index 000000000..1b7c768b1 Binary files /dev/null and b/.github/screenshots/approval-request-drawer.png differ diff --git a/.github/screenshots/approval-settings.png b/.github/screenshots/approval-settings.png new file mode 100644 index 000000000..60b62d735 Binary files /dev/null and b/.github/screenshots/approval-settings.png differ diff --git a/buzz/api/booking/approvals.py b/buzz/api/booking/approvals.py new file mode 100644 index 000000000..a6ae92521 --- /dev/null +++ b/buzz/api/booking/approvals.py @@ -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) diff --git a/buzz/api/booking/details.py b/buzz/api/booking/details.py index 2f1859f58..c691e7e5e 100644 --- a/buzz/api/booking/details.py +++ b/buzz/api/booking/details.py @@ -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], @@ -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), diff --git a/buzz/api/booking/event_data.py b/buzz/api/booking/event_data.py index 487d0d5b2..3cbbfca2f 100644 --- a/buzz/api/booking/event_data.py +++ b/buzz/api/booking/event_data.py @@ -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, } diff --git a/buzz/api/booking/exceptions.py b/buzz/api/booking/exceptions.py index 5945c4246..69cef527b 100644 --- a/buzz/api/booking/exceptions.py +++ b/buzz/api/booking/exceptions.py @@ -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): @@ -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.") diff --git a/buzz/api/booking/my_bookings.py b/buzz/api/booking/my_bookings.py index e3fd51852..5797e4799 100644 --- a/buzz/api/booking/my_bookings.py +++ b/buzz/api/booking/my_bookings.py @@ -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, ) diff --git a/buzz/api/booking/schemas.py b/buzz/api/booking/schemas.py index 1a6f47cd5..553d50142 100644 --- a/buzz/api/booking/schemas.py +++ b/buzz/api/booking/schemas.py @@ -45,6 +45,7 @@ class TicketHoldResponse(APIResponse): class FreeBookingResponse(APIResponse): booking_name: str redirect_to: str + approval_pending: bool = False class OfflineBookingResponse(APIResponse): @@ -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): @@ -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] @@ -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 diff --git a/buzz/api/booking/services.py b/buzz/api/booking/services.py index dc1dfb436..84a9d8877 100644 --- a/buzz/api/booking/services.py +++ b/buzz/api/booking/services.py @@ -29,11 +29,13 @@ PaymentLinkResponse, ) from buzz.payments import get_payment_link_for_booking +from buzz.ticketing.approval import notify_hosts_of_request from buzz.ticketing.doctype.buzz_coupon_code.buzz_coupon_code import BuzzCouponCode from buzz.ticketing.doctype.event_ticket_type.event_ticket_type import ( default_currency, start_capacity_transaction, ) +from buzz.ticketing.doctype.ticket_hold.ticket_hold import release_hold from buzz.utils import ZOOM_BACKED_CATEGORIES, build_event_datetimes if TYPE_CHECKING: @@ -270,6 +272,8 @@ def attendee_row(self, attendee: dict) -> dict: def finalize( self, booking: "EventBooking" ) -> FreeBookingResponse | OfflineBookingResponse | PaymentLinkResponse: + if booking.requires_approval: + return self.approval_pending_response(booking) if booking.total_amount == 0: return self.free_booking_response(booking) if self.request.is_offline: @@ -279,9 +283,20 @@ def finalize( def free_booking_response(self, booking: "EventBooking") -> FreeBookingResponse: booking.flags.ignore_permissions = True booking.submit() + return FreeBookingResponse(booking_name=booking.name, redirect_to=success_path(booking.name)) + + def approval_pending_response(self, booking: "EventBooking") -> FreeBookingResponse: + """The booking stays a draft with no tickets held until an organizer decides.""" + release_hold(booking.hold_token) + booking.send_approval_email( + "booking_approval_pending", _("Your request to join {0} is pending").format(self.event.title) + ) + try: + notify_hosts_of_request(booking) + except Exception: + frappe.log_error(title="Could not notify hosts of an approval request") return FreeBookingResponse( - booking_name=booking.name, - redirect_to=f"/booking-success/{booking.name}?token={get_booking_access_token(booking.name)}", + booking_name=booking.name, redirect_to=success_path(booking.name), approval_pending=True ) def offline_booking_response(self, booking: "EventBooking") -> OfflineBookingResponse: @@ -344,16 +359,25 @@ def payment_link_response(self, booking: "EventBooking") -> PaymentLinkResponse: ) -def are_registrations_closed(event_doc) -> bool: - event_timezone = event_doc.time_zone or get_system_timezone() - current_datetime_in_event_timezone = get_datetime_in_timezone(event_timezone).replace(tzinfo=None) +def success_path(booking_name: str) -> str: + return f"/booking-success/{booking_name}?token={get_booking_access_token(booking_name)}" + + +def current_datetime_in_event_timezone(event_doc): + return get_datetime_in_timezone(event_doc.time_zone or get_system_timezone()).replace(tzinfo=None) + +def has_event_ended(event_doc) -> bool: + _event_start_datetime, event_end_datetime = build_event_datetimes(event_doc) + return current_datetime_in_event_timezone(event_doc) > event_end_datetime + + +def are_registrations_closed(event_doc) -> bool: if event_doc.registrations_close_at: - return current_datetime_in_event_timezone > get_datetime(event_doc.registrations_close_at) + return current_datetime_in_event_timezone(event_doc) > get_datetime(event_doc.registrations_close_at) # No explicit cutoff set - registrations close once the event itself has ended. - _event_start_datetime, event_end_datetime = build_event_datetimes(event_doc) - return current_datetime_in_event_timezone > event_end_datetime + return has_event_ended(event_doc) def resolve_attendee_names(attendee: dict) -> tuple[str, str]: diff --git a/buzz/api/booking/test_approvals.py b/buzz/api/booking/test_approvals.py new file mode 100644 index 000000000..b3cded9cc --- /dev/null +++ b/buzz/api/booking/test_approvals.py @@ -0,0 +1,251 @@ +import frappe + +from buzz.api.booking.approvals import ( + approve_bookings, + decline_bookings, + get_affected_requests, + get_pending_bookings, + set_approval, + withdraw_booking, +) +from buzz.api.booking.exceptions import BookingNotPending, CannotWithdrawBooking +from buzz.api.booking.services import get_booking_access_token +from buzz.api.events.exceptions import CannotManageEvent +from buzz.tests.base_test_cases import ApprovalTestCase +from buzz.tests.factories import BuzzEventFactory, EventTicketTypeFactory, UserFactory + + +class TestApprovalEndpoints(ApprovalTestCase): + def setUp(self): + super().setUp() + self.organizer = self.make_organizer() + self.guest_one = UserFactory.create_once("approval-guest-one@example.com").name + self.guest_two = UserFactory.create_once("approval-guest-two@example.com").name + + def event_name(self) -> str: + return str(self.event.name) + + def status_of(self, booking: str) -> tuple: + return frappe.db.get_value("Event Booking", booking, ["docstatus", "status"]) + + def test_pending_list_is_oldest_first_with_the_request_details(self): + first = self.request_to_join(self.guest_one) + second = self.request_to_join(self.guest_two) + + with self.set_user(self.organizer): + payload = get_pending_bookings(self.event_name()).__json__() + + self.assertEqual(payload["total"], 2) + self.assertEqual([row["name"] for row in payload["bookings"]], [first, second]) + row = payload["bookings"][0] + self.assertEqual( + set(row), + { + "name", + "buyer_name", + "buyer_email", + "requested_at", + "previously_declined", + "attendees", + "answers", + }, + ) + self.assertEqual(row["attendees"][0]["ticket_type"], self.approval_ticket_type.title) + self.assertFalse(row["previously_declined"]) + + def test_a_new_request_after_a_decline_is_tagged(self): + declined = self.request_to_join(self.guest_one) + with self.set_user(self.organizer): + decline_bookings(self.event_name(), [declined], None) + self.request_to_join(self.guest_one) + + with self.set_user(self.organizer): + [row] = get_pending_bookings(self.event_name()).bookings + + self.assertTrue(row.previously_declined) + + def test_approve_reports_each_booking(self): + fits = self.request_to_join(self.guest_one) + self.approval_ticket_type.max_tickets_available = 1 + self.approval_ticket_type.save() + second = self.request_to_join(self.guest_two) + + with self.set_user(self.organizer): + result = approve_bookings(self.event_name(), [fits, second]) + + self.assertEqual(result.approved, [fits]) + self.assertEqual([failure.booking for failure in result.failed], [second]) + self.assertEqual(self.status_of(fits), (1, "Confirmed")) + self.assertEqual(self.status_of(second), (0, "Approval Pending")) + + def test_second_approval_of_the_same_request_fails_cleanly(self): + booking = self.request_to_join(self.guest_one) + with self.set_user(self.organizer): + approve_bookings(self.event_name(), [booking]) + result = approve_bookings(self.event_name(), [booking]) + + self.assertEqual([failure.booking for failure in result.failed], [booking]) + + def test_decline_stores_the_message(self): + booking = self.request_to_join(self.guest_one) + + with self.set_user(self.organizer): + result = decline_bookings(self.event_name(), [booking], " Not this time ") + + self.assertEqual(result.declined, [booking]) + self.assertEqual(self.status_of(booking), (2, "Rejected")) + self.assertEqual(frappe.db.get_value("Event Booking", booking, "rejection_reason"), "Not this time") + + def test_a_booking_of_another_event_cannot_be_decided_here(self): + booking = self.request_to_join(self.guest_one) + other_event = str(BuzzEventFactory.create(team=self.event.team).name) + + with self.set_user(self.organizer): + result = approve_bookings(other_event, [booking]) + + self.assertEqual([failure.booking for failure in result.failed], [booking]) + + def test_set_approval_keep_leaves_requests_pending(self): + booking = self.request_to_join(self.guest_one) + with self.set_user(self.organizer): + self.assertEqual( + get_affected_requests(self.event_name(), str(self.approval_ticket_type.name)).count, 1 + ) + result = set_approval(self.event_name(), False, str(self.approval_ticket_type.name)) + + self.assertEqual((result.approved, result.failed, result.declined), (0, 0, 0)) + self.approval_ticket_type.reload() + self.assertEqual(self.approval_ticket_type.requires_approval, 0) + self.assertEqual(self.status_of(booking), (0, "Approval Pending")) + + def test_set_approval_approve_reports_a_partial_result(self): + self.approval_ticket_type.max_tickets_available = 1 + self.approval_ticket_type.save() + self.request_to_join(self.guest_one) + self.request_to_join(self.guest_two) + + with self.set_user(self.organizer): + result = set_approval(self.event_name(), False, str(self.approval_ticket_type.name), "approve") + + self.assertEqual((result.approved, result.failed, result.declined), (1, 1, 0)) + + def test_set_approval_decline_declines_the_released_requests(self): + booking = self.request_to_join(self.guest_one) + + with self.set_user(self.organizer): + result = set_approval(self.event_name(), False, str(self.approval_ticket_type.name), "decline") + + self.assertEqual(result.declined, 1) + self.assertEqual(self.status_of(booking), (2, "Rejected")) + + def test_turning_off_one_ticket_type_leaves_bookings_that_need_another_pending(self): + other = EventTicketTypeFactory.create("requires_approval", event=self.event.name) + mixed = self.request_to_join(self.guest_one, self.approval_ticket_type.name, other.name) + single = self.request_to_join(self.guest_two, self.approval_ticket_type.name) + + with self.set_user(self.organizer): + result = set_approval(self.event_name(), False, str(self.approval_ticket_type.name), "approve") + + self.assertEqual(result.approved, 1) + self.assertEqual(self.status_of(mixed), (0, "Approval Pending")) + self.assertEqual(self.status_of(single), (1, "Confirmed")) + + def test_turning_off_event_wide_approval_keeps_ticket_type_requests_pending(self): + self.set_event({"requires_approval": 1}) + wide = self.request_to_join(self.guest_one, self.free_ticket_type.name) + by_ticket = self.request_to_join(self.guest_two, self.approval_ticket_type.name) + + with self.set_user(self.organizer): + self.assertEqual(get_affected_requests(self.event_name()).count, 1) + set_approval(self.event_name(), False, None, "approve") + + self.assertEqual(self.status_of(wide), (1, "Confirmed")) + self.assertEqual(self.status_of(by_ticket), (0, "Approval Pending")) + self.assertEqual(frappe.db.get_value("Buzz Event", self.event.name, "requires_approval"), 0) + + def test_every_organizer_endpoint_refuses_outsiders_and_guests(self): + calls = [ + lambda: get_pending_bookings(self.event_name()), + lambda: approve_bookings(self.event_name(), []), + lambda: decline_bookings(self.event_name(), [], None), + lambda: get_affected_requests(self.event_name()), + lambda: set_approval(self.event_name(), False), + ] + for user in (self.outsider, "Guest"): + for call in calls: + with self.set_user(user), self.assertRaises(CannotManageEvent): + call() + + def test_set_approval_needs_ticketing_write(self): + frontdesk = self.make_organizer("Frontdesk") + + with self.set_user(frontdesk), self.assertRaises(CannotManageEvent): + set_approval(self.event_name(), False, str(self.approval_ticket_type.name)) + + self.approval_ticket_type.reload() + self.assertEqual(self.approval_ticket_type.requires_approval, 1) + + def test_decline_skips_requests_that_are_no_longer_pending(self): + decided = self.request_to_join(self.guest_one) + waiting = self.request_to_join(self.guest_two) + frappe.get_doc("Event Booking", decided).decline_request() + + with self.set_user(self.organizer): + result = decline_bookings(self.event_name(), [decided, waiting], None) + + self.assertEqual(result.declined, [waiting]) + + def test_pending_list_is_capped_but_reports_the_total(self): + self.request_to_join(self.guest_one) + self.request_to_join(self.guest_two) + + with self.set_user(self.organizer): + payload = get_pending_bookings(self.event_name(), limit=1) + + self.assertEqual((payload.total, len(payload.bookings)), (2, 1)) + + def test_withdrawing_an_unknown_booking_looks_like_a_refusal(self): + with self.set_user("Guest"), self.assertRaises(CannotWithdrawBooking): + withdraw_booking("no-such-booking") + + def test_a_viewer_cannot_decide_requests(self): + viewer = self.make_organizer("Viewer") + + with self.set_user(viewer), self.assertRaises(CannotManageEvent): + approve_bookings(self.event_name(), []) + + +class TestWithdrawBooking(ApprovalTestCase): + def test_the_owner_withdraws_without_an_email(self): + booking = self.request_to_join() + emails = frappe.db.count("Email Queue", {"reference_name": booking}) + + with self.set_user(self.booker): + withdraw_booking(booking) + + self.assertEqual( + frappe.db.get_value("Event Booking", booking, ["docstatus", "status"]), (2, "Withdrawn") + ) + self.assertEqual(frappe.db.count("Email Queue", {"reference_name": booking}), emails) + + def test_a_guest_with_the_access_token_withdraws(self): + booking = self.request_to_join() + + with self.set_user("Guest"): + withdraw_booking(booking, get_booking_access_token(booking)) + + self.assertEqual(frappe.db.get_value("Event Booking", booking, "status"), "Withdrawn") + + def test_a_stranger_is_refused(self): + booking = self.request_to_join() + + for user, token in ((self.outsider, None), ("Guest", "wrong")): + with self.set_user(user), self.assertRaises(CannotWithdrawBooking): + withdraw_booking(booking, token) + + def test_a_decided_request_cannot_be_withdrawn(self): + booking = frappe.get_doc("Event Booking", self.request_to_join()) + booking.decline_request() + + with self.set_user(self.booker), self.assertRaises(BookingNotPending): + withdraw_booking(booking.name) diff --git a/buzz/api/booking/test_booking.py b/buzz/api/booking/test_booking.py index 3b1e220ad..2670afcb9 100644 --- a/buzz/api/booking/test_booking.py +++ b/buzz/api/booking/test_booking.py @@ -63,6 +63,7 @@ "allow_guest_booking", "guest_verification_method", "default_ticket_type", + "requires_approval", } SUMMARY_FIELDS = { @@ -72,6 +73,8 @@ "payment_status", "payment_method", "is_offline", + "requires_approval", + "rejection_reason", "currency", "booked_on", "lines", @@ -182,7 +185,7 @@ def test_unpublished_event_is_not_found(self): class TestProcessBooking(BookingTestCase): def test_free_booking_redirects_to_success(self): payload = process_booking(self.booking_request()).__json__() - self.assertEqual(set(payload), {"booking_name", "redirect_to"}) + self.assertEqual(set(payload), {"booking_name", "redirect_to", "approval_pending"}) self.assertEqual(frappe.db.get_value("Event Booking", payload["booking_name"], "docstatus"), 1) def test_unpublished_event_is_refused(self): diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index cecee1a94..1b1083c50 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -244,6 +244,7 @@ class TicketTypeItem(APIResponse): max_tickets_available: int auto_unpublish_after: date | None is_published: bool + requires_approval: bool tickets_sold: int prices: list[TicketTypePrice] @@ -277,6 +278,8 @@ class EventTicketTypes(APIResponse): has_team_tax_details: bool can_edit_team: bool paid_events_enabled: bool + requires_approval: bool + has_priced_items: bool categories: list[TicketCategoryItem] ticket_types: list[TicketTypeItem] payment_providers: list[PaymentProviderItem] diff --git a/buzz/api/events/ticket_types.py b/buzz/api/events/ticket_types.py index 527986323..2fccb1a21 100644 --- a/buzz/api/events/ticket_types.py +++ b/buzz/api/events/ticket_types.py @@ -16,6 +16,7 @@ from buzz.events.doctype.buzz_team_settings.buzz_team_settings import is_feature_enabled from buzz.payments import get_payment_gateways_for_event from buzz.permissions import can_manage_members, has_section_permission, has_team_access +from buzz.ticketing.approval import has_priced_items from buzz.ticketing.doctype.event_ticket_category.event_ticket_category import get_ordered_categories from buzz.ticketing.doctype.event_ticket_type.event_ticket_type import ( PAID_EVENTS_FLAG, @@ -30,6 +31,7 @@ "max_tickets_available", "auto_unpublish_after", "is_published", + "requires_approval", {"prices": ["currency", "price"]}, ] @@ -61,6 +63,8 @@ def event_ticket_types(event: str) -> EventTicketTypes: **visible_tax_details(doc.team), can_edit_team=can_manage_members(doc.team), paid_events_enabled=is_feature_enabled(doc.team, PAID_EVENTS_FLAG), + requires_approval=bool(doc.requires_approval), + has_priced_items=has_priced_items(event), categories=ticket_categories(event), ticket_types=[ticket_type_item(row, sold, sold_by_currency) for row in rows], payment_providers=payment_providers(event), diff --git a/buzz/api/notifications/services.py b/buzz/api/notifications/services.py index f4d82816b..4818931d3 100644 --- a/buzz/api/notifications/services.py +++ b/buzz/api/notifications/services.py @@ -78,7 +78,7 @@ def __init__(self, logs: list): for log in logs: by_doctype.setdefault(log.document_type, set()).add(log.document_name) self.rows = { - doctype: rows_by_name(doctype, list(names), ["title", "event"]) + doctype: rows_by_name(doctype, list(names), record_fields(doctype)) for doctype, names in by_doctype.items() } events = {row.event for rows in self.rows.values() for row in rows.values() if row.event} @@ -90,6 +90,11 @@ def of(self, log) -> dict: return {"title": record and record.title, "event_title": event and event.title} +def record_fields(doctype: str) -> list[str]: + meta = frappe.get_meta(doctype) + return [field for field in ("title", "event") if meta.has_field(field)] + + def rows_by_name(doctype: str, names: list[str], fields: list[str]) -> dict: if not names: return {} diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 51dd237a4..580279b0d 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -53,6 +53,7 @@ "guest_verification_method", "auto_closures_section", "registrations_close_at", + "requires_approval", "section_break_kwlt", "featured_speakers", "payments_tab", @@ -621,6 +622,13 @@ { "fieldname": "column_break_rgkz", "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Every registration waits for an organizer's approval. Only for events with free tickets.", + "fieldname": "requires_approval", + "fieldtype": "Check", + "label": "Require Approval for All Tickets" } ], "grid_page_length": 50, @@ -698,7 +706,7 @@ "link_fieldname": "event" } ], - "modified": "2026-10-19 11:00:00.000000", + "modified": "2026-10-25 10:00:00.000000", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 504080b0e..786c94205 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -16,6 +16,7 @@ from buzz.events.doctype.buzz_team_settings.buzz_team_settings import get_team_settings from buzz.events.doctype.event_check_in_zone.event_check_in_zone import create_default_zone from buzz.events.doctype.sponsor_enquiry_form.sponsor_enquiry_form import create_for_event +from buzz.ticketing.approval import validate_free_only from buzz.ticketing.doctype.event_ticket_category.event_ticket_category import create_ticket_categories from buzz.utils import get_time_zone_label, only_if_app_installed @@ -90,6 +91,7 @@ class BuzzEvent(Document): proposal: DF.Link | None registration_url: DF.Data | None registrations_close_at: DF.Datetime | None + requires_approval: DF.Check route: DF.Data | None schedule: DF.Table[ScheduleItem] send_booking_confirmation_email: DF.Check @@ -137,6 +139,7 @@ def validate(self): self.validate_schedule() self.validate_route() self.validate_tax_settings() + self.validate_approval_needs_free_tickets() self.validate_guest_verification_config() self.validate_custom_forms() self.clear_unused_location() @@ -256,6 +259,10 @@ def validate_tax_settings(self): if not 0 <= self.tax_percentage <= 100: frappe.throw(_("Tax rate must be between 0 and 100")) + def validate_approval_needs_free_tickets(self): + if self.requires_approval and not self.is_new(): + validate_free_only(self.name, True, False) + def validate_route(self): if self.is_published and not self.route: self.route = self.make_route() diff --git a/buzz/hooks.py b/buzz/hooks.py index 8420a815d..fc5f53be4 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -61,6 +61,7 @@ ], "daily": [ "buzz.tasks.unpublish_ticket_types_after_last_date", + "buzz.tasks.expire_pending_approval_requests", "buzz.telemetry_scan.send_site_profile", ], } diff --git a/buzz/tasks.py b/buzz/tasks.py index bd00d08d6..872795c2c 100644 --- a/buzz/tasks.py +++ b/buzz/tasks.py @@ -1,6 +1,9 @@ import frappe from frappe.utils import today +from buzz.api.booking.services import has_event_ended +from buzz.ticketing.approval import PENDING_APPROVAL + def unpublish_ticket_types_after_last_date(): frappe.db.set_value( @@ -9,3 +12,23 @@ def unpublish_ticket_types_after_last_date(): "is_published", False, ) + + +def expire_request(name: str): + try: + frappe.get_doc("Event Booking", name).discard_as("Expired") + except Exception: + frappe.log_error(title=f"Could not expire approval request {name}") + + +def expire_pending_approval_requests(): + """Requests still waiting once their event has ended can never be approved.""" + pending = frappe.get_all("Event Booking", filters=PENDING_APPROVAL, fields=["name", "event"]) + ended_events = { + event + for event in {row.event for row in pending} + if has_event_ended(frappe.get_cached_doc("Buzz Event", event)) + } + for row in pending: + if row.event in ended_events: + expire_request(row.name) diff --git a/buzz/templates/emails/booking_approval_pending.html b/buzz/templates/emails/booking_approval_pending.html new file mode 100644 index 000000000..d921546d6 --- /dev/null +++ b/buzz/templates/emails/booking_approval_pending.html @@ -0,0 +1,13 @@ +{% extends "buzz/templates/emails/base.html" %} + +{% block preheader %}Your request to join {{ event_title }} is pending.{% endblock %} +{% block title %}Request received{% endblock %} + +{% block content %} +

+ The organizer reviews every request. We will email you once they decide. + No ticket has been issued yet. +

+{% set total_label = "Total" %} +{% include "buzz/templates/emails/_booking_summary.html" %} +{% endblock %} diff --git a/buzz/templates/emails/booking_confirmation.html b/buzz/templates/emails/booking_confirmation.html index 2f1057e43..e09112b85 100644 --- a/buzz/templates/emails/booking_confirmation.html +++ b/buzz/templates/emails/booking_confirmation.html @@ -1,11 +1,11 @@ {% extends "buzz/templates/emails/base.html" %} -{% block preheader %}Your booking for {{ event_title }} is confirmed.{% endblock %} -{% block title %}Booking confirmed{% endblock %} +{% block preheader %}{% if approved %}Your request to join {{ event_title }} was approved.{% else %}Your booking for {{ event_title }} is confirmed.{% endif %}{% endblock %} +{% block title %}{% if approved %}You're in!{% else %}Booking confirmed{% endif %}{% endblock %} {% block content %}

- Thanks for booking.{% if event_doc.send_ticket_email %} Each attendee gets their own ticket{% if not meeting.is_online %} with an entry QR code{% endif %} by email.{% endif %} + {% if approved %}The organizer approved your request.{% else %}Thanks for booking.{% endif %}{% if event_doc.send_ticket_email %} Each attendee gets their own ticket{% if not meeting.is_online %} with an entry QR code{% endif %} by email.{% endif %}

{% set email_type = "booking" %} {% include "buzz/templates/emails/_join_meeting.html" %} diff --git a/buzz/templates/emails/booking_declined.html b/buzz/templates/emails/booking_declined.html new file mode 100644 index 000000000..ead8f4465 --- /dev/null +++ b/buzz/templates/emails/booking_declined.html @@ -0,0 +1,10 @@ +{% extends "buzz/templates/emails/base.html" %} + +{% block preheader %}An update on your request to join {{ event_title }}.{% endblock %} +{% block title %}Update on your request{% endblock %} + +{% block content %} +

+ {% if doc.rejection_reason %}{{ doc.rejection_reason | e }}{% else %}The organizer wasn't able to confirm your spot this time.{% endif %} +

+{% endblock %} diff --git a/buzz/tests/base_test_cases.py b/buzz/tests/base_test_cases.py index c08b6f51c..2ae2e5e81 100644 --- a/buzz/tests/base_test_cases.py +++ b/buzz/tests/base_test_cases.py @@ -3,6 +3,7 @@ import frappe from frappe.tests import IntegrationTestCase +from buzz.api.booking import process_booking from buzz.api.booking.schemas import BookingRequest from buzz.api.booking.services import create_add_on_doc from buzz.payments import handle_refund_notification @@ -10,6 +11,7 @@ BuzzCouponCodeFactory, BuzzEventFactory, BuzzTeamFactory, + BuzzTeamMembershipFactory, EventBookingFactory, EventPaymentFactory, EventSponsorFactory, @@ -21,6 +23,7 @@ TicketAddOnFactory, UserFactory, ) +from buzz.tests.utils import mute_outgoing_email from buzz.ticketing.doctype.event_booking.event_booking import RAZORPAY BOOKER = "booking-owner@example.com" @@ -70,6 +73,35 @@ def booking_request(self, **overrides): return BookingRequest(**values) +class ApprovalTestCase(BookingTestCase): + """A fresh event per test: approval requests are committed by `process_booking`, so they + would otherwise pile up in the pending list of a shared event.""" + + def setUp(self): + self.event = BuzzEventFactory.create() + self.event.reload() + super().setUp() + mute_outgoing_email(self) + self.approval_ticket_type = EventTicketTypeFactory.create("requires_approval", event=self.event.name) + + def make_organizer(self, role: str = "Manager") -> str: + organizer = UserFactory.create_once(f"approval-{role.lower()}@example.com").name + BuzzTeamMembershipFactory.create(team=self.event.team, user=organizer, team_role=role) + return organizer + + def attendees_for(self, *ticket_types) -> list[dict]: + return [ + {"first_name": f"Guest {index}", "email": f"guest{index}@example.com", "ticket_type": str(name)} + for index, name in enumerate(ticket_types) + ] + + def request_to_join(self, user: str | None = None, *ticket_types) -> str: + """The booking a guest gets from the registration form, by default on the approval ticket type.""" + attendees = self.attendees_for(*(ticket_types or (self.approval_ticket_type.name,))) + with self.set_user(user or self.booker): + return process_booking(self.booking_request(attendees=attendees)).booking_name + + class SponsorshipTestCase(IntegrationTestCase): @classmethod def setUpClass(cls): diff --git a/buzz/tests/factories/event_ticket_type_factory.py b/buzz/tests/factories/event_ticket_type_factory.py index 25a1319bb..9d73ecbe8 100644 --- a/buzz/tests/factories/event_ticket_type_factory.py +++ b/buzz/tests/factories/event_ticket_type_factory.py @@ -27,6 +27,10 @@ def default_attributes(self) -> dict[str, Any]: def paid(self) -> dict[str, Any]: return {"prices": [{"currency": "INR", "price": 500}]} + @property + def requires_approval(self) -> dict[str, Any]: + return {"requires_approval": 1} + @property def unpublished(self) -> dict[str, Any]: return {"is_published": 0} diff --git a/buzz/tests/utils.py b/buzz/tests/utils.py index 98ebbf3af..9b175f8d1 100644 --- a/buzz/tests/utils.py +++ b/buzz/tests/utils.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from email import message_from_string +from email import message_from_string, policy from unittest.mock import patch import frappe @@ -91,3 +91,14 @@ def linked_to(doctype: str) -> set[str]: pluck="parent", ) return {parent for parent in parents if not frappe.get_meta(parent).istable} + + +def queued_booking_emails(booking: str) -> list: + """Parsed messages queued against a booking, oldest first.""" + rows = frappe.get_all( + "Email Queue", + filters={"reference_doctype": "Event Booking", "reference_name": booking}, + fields=["message"], + order_by="creation asc", + ) + return [message_from_string(row.message, policy=policy.default) for row in rows] diff --git a/buzz/ticketing/approval.py b/buzz/ticketing/approval.py new file mode 100644 index 000000000..4f794c6e3 --- /dev/null +++ b/buzz/ticketing/approval.py @@ -0,0 +1,77 @@ +import frappe +from frappe import _ +from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification +from frappe.utils import get_url + +from buzz.permissions import WRITE_ROLES +from buzz.subscriptions import NOTIFICATION_TYPE + +PENDING_APPROVAL = {"requires_approval": 1, "docstatus": 0, "status": "Approval Pending"} + + +def approval_ticket_types(event: str) -> set[str]: + names = frappe.get_all( + "Event Ticket Type", filters={"event": event, "requires_approval": 1}, pluck="name" + ) + return {str(name) for name in names} + + +def is_event_wide_approval(event: str) -> bool: + return bool(frappe.db.get_value("Buzz Event", event, "requires_approval")) + + +def booking_needs_approval(event: str, ticket_types) -> bool: + return is_event_wide_approval(event) or bool( + approval_ticket_types(event) & {str(ticket_type) for ticket_type in ticket_types} + ) + + +def has_approval(event: str, except_ticket_type: str | None = None) -> bool: + return is_event_wide_approval(event) or bool(approval_ticket_types(event) - {str(except_ticket_type)}) + + +def has_priced_items(event: str, except_ticket_type: str | None = None) -> bool: + names = frappe.get_all("Event Ticket Type", filters={"event": event}, pluck="name") + names = [name for name in names if str(name) != str(except_ticket_type)] + priced_ticket_type = names and frappe.db.exists( + "Buzz Price", {"parenttype": "Event Ticket Type", "parent": ("in", names), "price": (">", 0)} + ) + return bool(priced_ticket_type or frappe.db.exists("Ticket Add-on", {"event": event, "price": (">", 0)})) + + +def validate_free_only( + event: str, approval_on: bool, priced: bool, except_ticket_type: str | None = None +) -> None: + """`approval_on` and `priced` describe the document being saved; the rest of the event is read from the database.""" + # Imported here: the booking package imports the ticket type controller that calls this. + from buzz.api.booking.exceptions import ApprovalNeedsFreeTickets + + if approval_on and (priced or has_priced_items(event, except_ticket_type)): + ApprovalNeedsFreeTickets.throw() + if priced and has_approval(event, except_ticket_type): + ApprovalNeedsFreeTickets.throw() + + +def notify_hosts_of_request(booking) -> None: + event = frappe.get_cached_doc("Buzz Event", booking.event) + members = frappe.get_all( + "Buzz Team Membership", + filters={"team": event.team, "enabled": 1, "team_role": ("in", sorted(WRITE_ROLES))}, + pluck="user", + ) + emails = frappe.get_all("User", filters={"name": ("in", members)}, pluck="email") if members else [] + if not emails: + return + subject = _("{0} requested to join {1}").format(booking.buyer_name or booking.user, event.title) + enqueue_create_notification( + emails, + { + "type": NOTIFICATION_TYPE, + "subject": subject, + "from_user": booking.user, + "document_type": booking.doctype, + "document_name": booking.name, + "link": get_url(f"/b/manage/events/{event.name}/guests?view=pending"), + "email_content": subject, + }, + ) diff --git a/buzz/ticketing/doctype/event_booking/event_booking.json b/buzz/ticketing/doctype/event_booking/event_booking.json index ed30047aa..e3ecb4bf8 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.json +++ b/buzz/ticketing/doctype/event_booking/event_booking.json @@ -14,6 +14,8 @@ "payment_status", "column_break_status", "status", + "requires_approval", + "rejection_reason", "payment_method_section", "payment_method", "column_break_payment_method", @@ -313,7 +315,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Status", - "options": "Confirmed\nApproval Pending\nApproved\nRejected", + "options": "Confirmed\nApproval Pending\nApproved\nRejected\nWithdrawn\nExpired", "read_only": 1 }, { @@ -343,6 +345,20 @@ "fieldname": "invoice_requested", "fieldtype": "Check", "label": "Invoice Requested" + }, + { + "default": "0", + "fieldname": "requires_approval", + "fieldtype": "Check", + "label": "Requires Approval", + "read_only": 1 + }, + { + "depends_on": "eval:doc.rejection_reason", + "fieldname": "rejection_reason", + "fieldtype": "Small Text", + "label": "Rejection Reason", + "read_only": 1 } ], "grid_page_length": 50, @@ -362,7 +378,7 @@ "link_fieldname": "booking" } ], - "modified": "2026-10-16 13:00:00.000000", + "modified": "2026-10-25 10:00:00.000000", "modified_by": "Administrator", "module": "Ticketing", "name": "Event Booking", diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py index 46a1477f8..9af608bfe 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -8,13 +8,14 @@ from frappe.utils import add_to_date, cstr, flt, now_datetime from buzz import telemetry -from buzz.api.booking.exceptions import RegistrationsClosed +from buzz.api.booking.exceptions import ApprovalRequestPending, RegistrationsClosed from buzz.api.booking.services import OFFLINE_PAYMENT_METHOD, are_registrations_closed from buzz.emails import is_full_document from buzz.events.doctype.buzz_team_settings.buzz_team_settings import get_event_team_settings from buzz.events.online_meeting import OnlineMeeting from buzz.payments import get_controller, mark_payment_as_received from buzz.permissions import has_section_permission +from buzz.ticketing.approval import PENDING_APPROVAL, booking_needs_approval from buzz.ticketing.doctype.buzz_coupon_code.buzz_coupon_code import BuzzCouponCode from buzz.ticketing.doctype.event_booking_refund.event_booking_refund import ( get_committed_refunds, @@ -68,7 +69,9 @@ class EventBooking(Document): payment_status: DF.Literal["Unpaid", "Paid", "Verification Pending"] refund_status: DF.Literal["", "Refund Initiated", "Partially Refunded", "Refunded"] refunded_amount: DF.Currency - status: DF.Literal["Confirmed", "Approval Pending", "Approved", "Rejected"] + rejection_reason: DF.SmallText | None + requires_approval: DF.Check + status: DF.Literal["Confirmed", "Approval Pending", "Approved", "Rejected", "Withdrawn", "Expired"] tax_amount: DF.Currency tax_id: DF.Data | None tax_label: DF.Data | None @@ -80,6 +83,7 @@ class EventBooking(Document): def validate(self): self.validate_event_registration_is_open() + self.validate_no_duplicate_request() self.validate_ticket_availability() self.fetch_amounts_from_ticket_types() self.set_currency() @@ -105,8 +109,31 @@ def validate_event_registration_is_open(self): if are_registrations_closed(event): RegistrationsClosed.throw() + def before_insert(self): + organizer_added = has_section_permission(self.event, "guests", "create", frappe.session.user) + self.requires_approval = int( + not organizer_added and booking_needs_approval(self.event, self.ticket_quantities()) + ) + + @property + def is_awaiting_approval(self) -> bool: + return bool(self.requires_approval) and self.docstatus == 0 and not self.flags.approving + + def validate_no_duplicate_request(self): + if self.is_new() and self.requires_approval: + filters = {**PENDING_APPROVAL, "event": self.event, "user": self.user} + if frappe.db.exists("Event Booking", filters): + ApprovalRequestPending.throw() + def before_submit(self): """Set status before submit based on payment method.""" + # `submit` has already set docstatus to 1 here, so `is_awaiting_approval` can't be used. + if self.requires_approval and not self.flags.approving: + frappe.throw(_("This booking is waiting for approval. Please use the Approve or Reject button.")) + + if self.flags.approving and self.total_amount: + frappe.throw(_("This request now has a price and can't be approved. Decline it instead.")) + # Skip if already approved (submission triggered by approve_booking) if self.status == "Approved": return @@ -167,6 +194,8 @@ def ticket_quantities(self) -> Counter: return Counter(str(attendee.ticket_type) for attendee in self.attendees) def validate_ticket_availability(self): + if self.is_awaiting_approval: + return quantities = self.ticket_quantities() ticket_types = [frappe.get_cached_doc("Event Ticket Type", name) for name in quantities] lock_ticket_types([row.name for row in ticket_types if row.max_tickets_available]) @@ -179,7 +208,7 @@ def validate_ticket_type(self, ticket_type, quantity: int): if self.flags.payment_received: self.log_if_oversold(ticket_type, quantity) return - if not ticket_type.is_published: + if not ticket_type.is_published and not self.flags.approving: frappe.throw(_("{0} tickets no longer available!").format(ticket_type.title)) remaining = ticket_type.get_remaining_tickets(for_update=True, hold_token=self.hold_token) @@ -285,6 +314,10 @@ def capture_confirmed(self): def send_booking_confirmation_email(self): event_doc = frappe.get_cached_doc("Buzz Event", self.event) team_settings = get_event_team_settings(self.event) + approved = bool(self.requires_approval) + subject = _("Your booking for {0} is confirmed ✅").format(event_doc.title) + if approved: + subject = _("You're in: your request to join {0} was approved").format(event_doc.title) # Fallback to the team default if event-level not set self.send_booking_email( template=( @@ -292,9 +325,21 @@ def send_booking_confirmation_email(self): or team_settings.default_booking_confirmation_email_template ), builtin="booking_confirmation", - subject=_("Your booking for {0} is confirmed ✅").format(event_doc.title), + subject=subject, + always=approved, ) + def send_approval_email(self, builtin: str, subject: str) -> None: + """Request updates are sent whatever the event's confirmation email switch says.""" + try: + self.send_booking_email(template=None, builtin=builtin, subject=subject, always=True) + except Exception: + frappe.log_error( + title="Booking approval email failed", + reference_doctype=self.doctype, + reference_name=self.name, + ) + def send_offline_acknowledgement_email(self): """Tell the booker their offline payment is awaiting verification. Sent while the booking is still a draft, so the confirmation above stays the approval's job.""" @@ -307,8 +352,10 @@ def send_offline_acknowledgement_email(self): ), ) - def send_booking_email(self, template: str | None, builtin: str, subject: str) -> None: - recipient = self.get_booking_email_recipient() + def send_booking_email( + self, template: str | None, builtin: str, subject: str, always: bool = False + ) -> None: + recipient = self.get_booking_email_recipient(always) if not recipient: return @@ -333,13 +380,15 @@ def send_booking_email(self, template: str | None, builtin: str, subject: str) - reference_name=self.name, ) - def get_booking_email_recipient(self) -> str | None: + def get_booking_email_recipient(self, always: bool = False) -> str | None: """Who to email about this booking, or None when nobody should be emailed.""" # Never email system/placeholder users — they are not real recipients. if self.user in ("Administrator", "Guest"): return None - if not frappe.get_cached_value("Buzz Event", self.event, "send_booking_confirmation_email"): + if not always and not frappe.get_cached_value( + "Buzz Event", self.event, "send_booking_confirmation_email" + ): return None return frappe.db.get_value("User", self.user, "email") or self.user @@ -351,6 +400,7 @@ def get_booking_email_args(self) -> dict: "event_doc": event_doc, "meeting": OnlineMeeting(event_doc), "event_title": event_doc.title, + "approved": bool(self.requires_approval), "venue": event_doc.get_venue_name(), "attendee_rows": self.get_attendee_email_rows(), "support_email": get_event_team_settings(self.event).support_email, @@ -671,6 +721,10 @@ def get_received_payment(self): def approve_booking(self): """Approve the booking and submit it to generate tickets.""" frappe.only_for("Event Manager") + if self.requires_approval: + self.approve_request() + frappe.msgprint(_("Request has been approved!")) + return self.status = "Approved" self.flags.payment_received = True @@ -685,12 +739,27 @@ def approve_booking(self): def reject_booking(self): """Reject and discard the booking.""" frappe.only_for("Event Manager") + self.decline_request() + frappe.msgprint(_("Booking has been rejected!")) + def approve_request(self) -> None: + self.flags.ignore_permissions = True + self.flags.approving = True + self.submit() + + def decline_request(self, reason: str | None = None) -> None: + self.discard_as("Rejected", reason) + if self.requires_approval: + event_title = frappe.get_cached_value("Buzz Event", self.event, "title") + self.send_approval_email( + "booking_declined", _("Update on your request to join {0}").format(event_title) + ) + + def discard_as(self, status: str, reason: str | None = None) -> None: self.flags.ignore_permissions = True self.discard() - self.db_set("status", "Rejected") + self.db_set({"status": status, "rejection_reason": reason}) release_hold(self.hold_token) - frappe.msgprint(_("Booking has been rejected!")) def apply_coupon_if_applicable(self): self.discount_amount = 0 diff --git a/buzz/ticketing/doctype/event_booking/test_approval_booking.py b/buzz/ticketing/doctype/event_booking/test_approval_booking.py new file mode 100644 index 000000000..785181c3c --- /dev/null +++ b/buzz/ticketing/doctype/event_booking/test_approval_booking.py @@ -0,0 +1,273 @@ +import frappe +from frappe.utils import add_days, today + +from buzz.api.booking import process_booking +from buzz.api.booking.exceptions import ApprovalNeedsFreeTickets, ApprovalRequestPending +from buzz.tasks import expire_pending_approval_requests +from buzz.tests.base_test_cases import ApprovalTestCase +from buzz.tests.factories import ( + EmailTemplateFactory, + EventTicketFactory, + EventTicketTypeFactory, + TicketAddOnFactory, +) +from buzz.tests.utils import queued_booking_emails + + +def html_of(message) -> str: + return message.get_body(("html",)).get_content() + + +class TestApprovalFreeOnlyRule(ApprovalTestCase): + def setUp(self): + super().setUp() + self.set_approval_on(False) + + def set_approval_on(self, enabled: bool): + frappe.db.set_value( + "Event Ticket Type", self.approval_ticket_type.name, "requires_approval", int(enabled) + ) + self.approval_ticket_type.reload() + + def test_approval_cannot_be_turned_on_next_to_a_paid_ticket_type(self): + EventTicketTypeFactory.create("paid", event=self.event.name) + ticket_type = EventTicketTypeFactory.create(event=self.event.name) + ticket_type.requires_approval = 1 + + with self.assertRaises(ApprovalNeedsFreeTickets): + ticket_type.save() + + def test_event_wide_approval_cannot_be_turned_on_next_to_a_paid_ticket_type(self): + EventTicketTypeFactory.create("paid", event=self.event.name) + event = frappe.get_doc("Buzz Event", self.event.name) + event.requires_approval = 1 + + with self.assertRaises(ApprovalNeedsFreeTickets): + event.save() + + def test_approval_cannot_be_turned_on_next_to_a_priced_add_on(self): + TicketAddOnFactory.create(event=self.event.name, price=200) + ticket_type = EventTicketTypeFactory.create(event=self.event.name) + ticket_type.requires_approval = 1 + + with self.assertRaises(ApprovalNeedsFreeTickets): + ticket_type.save() + + def test_a_price_cannot_be_added_to_a_ticket_type_while_approval_is_on(self): + self.set_approval_on(True) + self.approval_ticket_type.prices[0].price = 500 + + with self.assertRaises(ApprovalNeedsFreeTickets): + self.approval_ticket_type.save() + + def test_a_paid_ticket_type_cannot_be_added_while_event_wide_approval_is_on(self): + self.set_event({"requires_approval": 1}) + + with self.assertRaises(ApprovalNeedsFreeTickets): + EventTicketTypeFactory.create("paid", event=self.event.name) + + def test_a_priced_add_on_cannot_be_added_while_a_ticket_type_needs_approval(self): + self.set_approval_on(True) + with self.assertRaises(ApprovalNeedsFreeTickets): + TicketAddOnFactory.create(event=self.event.name, price=200) + + def test_a_free_add_on_is_fine_with_approval_on(self): + self.set_approval_on(True) + TicketAddOnFactory.create(event=self.event.name) + + +class TestApprovalRequest(ApprovalTestCase): + def request(self, *ticket_types) -> "frappe.Document": + return frappe.get_doc("Event Booking", self.request_to_join(None, *ticket_types)) + + def test_a_request_is_a_draft_without_tickets(self): + booking = self.request() + + self.assertEqual( + (booking.docstatus, booking.status, booking.requires_approval), (0, "Approval Pending", 1) + ) + self.assertEqual(frappe.db.count("Event Ticket", {"booking": booking.name}), 0) + self.assertEqual(frappe.db.count("Ticket Hold", {"event": self.event.name}), 0) + + def test_the_response_says_the_request_is_pending(self): + with self.set_user(self.booker): + payload = process_booking( + self.booking_request(attendees=self.attendees_for(self.approval_ticket_type.name)) + ) + + self.assertTrue(payload.approval_pending) + + def test_a_request_is_accepted_even_when_the_ticket_type_is_sold_out(self): + ticket_type = EventTicketTypeFactory.create( + "requires_approval", event=self.event.name, max_tickets_available=1 + ) + EventTicketFactory.create("submitted", event=self.event.name, ticket_type=ticket_type.name) + + self.assertEqual(self.request(ticket_type.name).docstatus, 0) + + def test_the_pending_email_goes_out_with_the_confirmation_email_switched_off(self): + self.set_event({"send_booking_confirmation_email": 0}) + + [message] = queued_booking_emails(self.request().name) + + self.assertIn("is pending", message["Subject"]) + self.assertIn("No ticket has been issued yet", html_of(message)) + + def test_event_wide_approval_overrides_the_ticket_types(self): + self.set_event({"requires_approval": 1}) + + booking = self.request(self.free_ticket_type.name) + + self.assertEqual(booking.requires_approval, 1) + + def test_one_approval_ticket_sends_the_whole_booking_to_pending(self): + booking = self.request(self.free_ticket_type.name, self.approval_ticket_type.name) + + self.assertEqual((booking.docstatus, len(booking.attendees)), (0, 2)) + + def test_a_free_ticket_type_needs_no_approval(self): + booking = self.request(self.free_ticket_type.name) + + self.assertEqual((booking.docstatus, booking.requires_approval), (1, 0)) + + def test_a_booking_added_by_an_organizer_skips_approval(self): + organizer = self.make_organizer() + attendees = self.attendees_for(self.approval_ticket_type.name) + with self.set_user(organizer): + payload = process_booking(self.booking_request(attendees=attendees)) + + self.assertFalse(payload.approval_pending) + self.assertEqual(frappe.db.get_value("Event Booking", payload.booking_name, "docstatus"), 1) + + def test_a_second_pending_request_from_the_same_user_is_refused(self): + self.request() + + with self.assertRaises(ApprovalRequestPending): + self.request() + + def test_hosts_are_notified_of_a_request(self): + organizer = self.make_organizer() + + booking = self.request() + + logs = frappe.get_all( + "Notification Log", + filters={"for_user": organizer, "document_type": "Event Booking", "document_name": booking.name}, + ) + self.assertEqual(len(logs), 1) + + +class TestApprovalDecision(ApprovalTestCase): + def setUp(self): + super().setUp() + self.booking = frappe.get_doc("Event Booking", self.request_to_join()) + + def test_approving_issues_tickets_and_confirms(self): + self.booking.approve_request() + self.booking.reload() + + self.assertEqual((self.booking.docstatus, self.booking.status), (1, "Confirmed")) + self.assertEqual(frappe.db.count("Event Ticket", {"booking": self.booking.name}), 1) + + def test_the_approved_email_goes_out_with_the_confirmation_email_switched_off(self): + self.set_event({"send_booking_confirmation_email": 0}) + + self.booking.approve_request() + + [_pending, approved] = queued_booking_emails(self.booking.name) + self.assertIn("You're in", approved["Subject"]) + self.assertIn("You're in!", html_of(approved)) + + def test_the_approved_email_uses_the_custom_confirmation_template(self): + template = EmailTemplateFactory.create(subject="Custom welcome", response="

Custom body

") + self.set_event( + {"booking_confirmation_email_template": template.name, "send_booking_confirmation_email": 1} + ) + + self.booking.approve_request() + + [_pending, approved] = queued_booking_emails(self.booking.name) + self.assertEqual(approved["Subject"], "Custom welcome") + + def test_approving_a_sold_out_ticket_type_fails_and_keeps_the_request_pending(self): + self.approval_ticket_type.max_tickets_available = 1 + self.approval_ticket_type.save() + EventTicketFactory.create( + "submitted", event=self.event.name, ticket_type=self.approval_ticket_type.name + ) + + with self.assertRaises(frappe.ValidationError): + self.booking.approve_request() + + self.booking.reload() + self.assertEqual((self.booking.docstatus, self.booking.status), (0, "Approval Pending")) + + def test_the_desk_approve_button_goes_through_the_same_path(self): + self.booking.approve_booking() + self.booking.reload() + + self.assertEqual((self.booking.docstatus, self.booking.status), (1, "Confirmed")) + + def test_approving_still_works_after_sales_closed(self): + frappe.db.set_value("Event Ticket Type", self.approval_ticket_type.name, "is_published", 0) + + self.booking.approve_request() + + self.assertEqual(frappe.db.get_value("Event Booking", self.booking.name, "docstatus"), 1) + + def test_a_request_that_now_has_a_price_cannot_be_approved(self): + ticket_type = str(self.approval_ticket_type.name) + filters = {"parenttype": "Event Ticket Type", "parent": ticket_type} + frappe.db.set_value("Buzz Price", filters, "price", 500) + frappe.clear_document_cache("Event Ticket Type", ticket_type) + + with self.assertRaises(frappe.ValidationError): + self.booking.approve_request() + + self.assertEqual(frappe.db.get_value("Event Booking", self.booking.name, "docstatus"), 0) + + def test_a_pending_request_cannot_be_submitted_directly(self): + with self.assertRaises(frappe.ValidationError): + self.booking.submit() + + def test_declining_discards_and_emails_the_message(self): + self.booking.decline_request("We are full") + self.booking.reload() + + self.assertEqual((self.booking.docstatus, self.booking.status), (2, "Rejected")) + self.assertEqual(self.booking.rejection_reason, "We are full") + [_pending, declined] = queued_booking_emails(self.booking.name) + self.assertEqual(declined["Subject"], f"Update on your request to join {self.event.title}") + self.assertIn("We are full", html_of(declined)) + + def test_declining_without_a_message_uses_the_default_text(self): + self.booking.decline_request() + + [_pending, declined] = queued_booking_emails(self.booking.name) + self.assertIn("wasn't able to confirm your spot", html_of(declined)) + + def test_a_declined_guest_can_request_again(self): + self.booking.decline_request() + + self.assertEqual(frappe.get_doc("Event Booking", self.request_to_join()).docstatus, 0) + + +class TestApprovalExpiry(ApprovalTestCase): + def test_requests_pending_after_the_event_ended_expire_without_an_email(self): + booking = self.request_to_join() + emails_before = len(queued_booking_emails(booking)) + self.set_event({"start_date": add_days(today(), -2), "end_date": add_days(today(), -1)}) + + expire_pending_approval_requests() + + self.assertEqual( + frappe.db.get_value("Event Booking", booking, ["docstatus", "status"]), (2, "Expired") + ) + self.assertEqual(len(queued_booking_emails(booking)), emails_before) + + def test_requests_for_a_live_event_are_left_pending(self): + booking = self.request_to_join() + + expire_pending_approval_requests() + + self.assertEqual(frappe.db.get_value("Event Booking", booking, "status"), "Approval Pending") diff --git a/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.json b/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.json index 43cb03672..8835b563c 100644 --- a/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.json +++ b/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.json @@ -15,6 +15,7 @@ "is_published", "auto_unpublish_after", "max_tickets_available", + "requires_approval", "stats_section", "tickets_sold", "column_break_ygut", @@ -100,6 +101,13 @@ { "fieldname": "column_break_ygut", "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Guests send a request that an organizer approves. Only for free tickets.", + "fieldname": "requires_approval", + "fieldtype": "Check", + "label": "Requires Approval" } ], "grid_page_length": 50, @@ -110,7 +118,7 @@ "link_fieldname": "ticket_type" } ], - "modified": "2026-10-16 12:00:00.000000", + "modified": "2026-10-25 10:00:00.000000", "modified_by": "Administrator", "module": "Ticketing", "name": "Event Ticket Type", diff --git a/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.py b/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.py index b5cff4d0d..2b76b07ec 100644 --- a/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.py +++ b/buzz/ticketing/doctype/event_ticket_type/event_ticket_type.py @@ -8,6 +8,7 @@ from buzz.events.doctype.buzz_price.buzz_price import validate_unique_currencies from buzz.events.doctype.buzz_team_settings.buzz_team_settings import is_feature_enabled +from buzz.ticketing.approval import validate_free_only from buzz.ticketing.doctype.ticket_hold.ticket_hold import held_quantities PAID_EVENTS_FLAG = "feature_paid_events" @@ -54,6 +55,7 @@ class EventTicketType(Document): max_tickets_available: DF.Int name: DF.Int | None prices: DF.Table[BuzzPrice] + requires_approval: DF.Check title: DF.Data # end: auto-generated types @@ -67,6 +69,12 @@ def validate(self): self.validate_locked_prices() self.validate_paid_events_enabled() self.validate_category_event() + self.validate_approval_needs_free_tickets() + + def validate_approval_needs_free_tickets(self): + validate_free_only( + self.event, bool(self.requires_approval), not self.is_free, None if self.is_new() else self.name + ) def validate_category_event(self): filters = {"name": self.category, "event": self.event} diff --git a/buzz/ticketing/doctype/ticket_add_on/ticket_add_on.py b/buzz/ticketing/doctype/ticket_add_on/ticket_add_on.py index a0d2cd393..eaa7f374e 100644 --- a/buzz/ticketing/doctype/ticket_add_on/ticket_add_on.py +++ b/buzz/ticketing/doctype/ticket_add_on/ticket_add_on.py @@ -5,10 +5,13 @@ from frappe import _ from frappe.model.document import Document +from buzz.ticketing.approval import validate_free_only + class TicketAddon(Document): def validate(self): self.validate_duplicate_title() + validate_free_only(self.event, False, bool(self.price)) def validate_duplicate_title(self): if frappe.db.exists( diff --git a/dashboard/src/components/BookingApprovalNotice.vue b/dashboard/src/components/BookingApprovalNotice.vue new file mode 100644 index 000000000..a0eafc2e9 --- /dev/null +++ b/dashboard/src/components/BookingApprovalNotice.vue @@ -0,0 +1,92 @@ + + + diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index a3079d19e..c5a85dbf7 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -28,8 +28,8 @@ {{ successMessage }} @@ -92,6 +92,7 @@ :quantities="quantities" :currency="currency" :stepper-size="isMobile ? 'lg' : 'sm'" + :event-requires-approval="!!eventDetails.requires_approval" @change="changeQuantity" /> @@ -278,6 +279,7 @@ import type { FrappeField, OfflineMethod, } from "@/types" +import { needsApproval } from "@/utils/bookingApproval" import { formatWholePriceOrFree } from "@/utils/currency" import { eventPath } from "@/utils/eventUrl" import { siteHeaderHidden } from "@/utils/navbar" @@ -385,7 +387,7 @@ function inCurrency(item: T) { const ticketTypes = computed(() => props.availableTicketTypes.map(inCurrency)) const addOns = computed(() => props.availableAddOns.map(inCurrency)) const ticketTypesMap = computed(() => - Object.fromEntries(ticketTypes.value.map((t) => [String(t.name), t])), + Object.fromEntries(ticketTypes.value.map((ticketType) => [String(ticketType.name), ticketType])), ) const addOnsMap = computed(() => Object.fromEntries(addOns.value.map((a) => [a.name, a]))) @@ -420,7 +422,9 @@ const quantities = computed(() => { // Ticket types with a limit are the only ones a hold reserves. const heldQuantities = computed(() => { const limited = new Set( - props.availableTicketTypes.filter((t) => t.max_tickets_available).map((t) => String(t.name)), + props.availableTicketTypes + .filter((ticketType) => ticketType.max_tickets_available) + .map((ticketType) => String(ticketType.name)), ) return Object.fromEntries(Object.entries(quantities.value).filter(([name]) => limited.has(name))) }) @@ -557,12 +561,25 @@ const showStepError = computed(() => tried.value && stepErrors.value.length > 0) const stepErrorMessage = computed(() => validation.summarize(stepErrors.value)) const nextStep = computed(() => steps.value[current.value + 1]) +const requiresApproval = computed(() => + needsApproval( + !!props.eventDetails.requires_approval, + attendees.value.map((attendee) => String(attendee.ticket_type)), + Object.fromEntries( + ticketTypes.value.map((ticketType) => [ + String(ticketType.name), + ticketType.requires_approval, + ]), + ), + ), +) const totalLabel = computed(() => formatWholePriceOrFree(total.value, currency.value)) const ctaLabel = computed(() => { if (nextStep.value?.id === "summary") return __("Review booking") if (nextStep.value) return __("Continue to {0}", [nextStep.value.label.toLowerCase()]) if (placing.value) return __("Processing...") if (total.value > 0) return __("Proceed to pay {0}", [totalLabel.value]) + if (requiresApproval.value) return __("Request to join") return isZoomEvent.value || props.eventDetails.free_event ? __("Register") : __("Book Tickets") }) const mobileCtaLabel = computed(() => { @@ -679,12 +696,21 @@ function trimmed(values: Record) { ) } +const showsPendingState = computed( + () => !!(completed.value?.pendingVerification || completed.value?.approvalPending), +) + const successTitle = computed(() => { + if (completed.value?.approvalPending) return __("Request sent") if (completed.value?.pendingVerification) return __("Booking received") return isZoomEvent.value ? __("Registration confirmed") : __("You're registered") }) const successMessage = computed(() => { + if (completed.value?.approvalPending) + return __("Pending approval. We'll email {0} once the organizer decides.", [ + billing.value.buyer_email, + ]) if (completed.value?.pendingVerification) return __("Your payment is awaiting verification. We'll email {0} once it's confirmed.", [ billing.value.buyer_email, diff --git a/dashboard/src/components/dashboard/events/DeclineBookingsDialog.vue b/dashboard/src/components/dashboard/events/DeclineBookingsDialog.vue new file mode 100644 index 000000000..b2f2a9df2 --- /dev/null +++ b/dashboard/src/components/dashboard/events/DeclineBookingsDialog.vue @@ -0,0 +1,44 @@ + + +