From 2535f38fe4c931b0d1a580fbf6aa15e0f966c8e3 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 9 Sep 2026 09:37:20 +0530 Subject: [PATCH 1/2] feat(dashboard): add a guest registration tile to the guest quick actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Buzz Event doctype has carried `allow_guest_booking` and `guest_verification_method` for a while, but nothing in the dashboard read or wrote them — an organiser had to open Desk to let strangers register themselves. The Registration toggle and the new Guest registration toggle now sit in one container, so the tile markup is lifted out of QuickActionsRail into QuickActionTile and the rail takes a slot for extra tiles. EventTalkActions is unchanged; its slot is empty. The dialog writes through frappe.client.set_value, which the dashboard already uses for events and which the team permission hooks guard, so there is no new write endpoint. Verification methods only work if the site is set up for them, and the failure modes were bad: Email OTP threw on save, and Phone OTP saved fine but left the guest waiting for a code that was never sent. get_verification_methods reports what the site can actually deliver — EmailAccount.find_default_outgoing() for email (what frappe.sendmail itself resolves through, so site_config mail counts), and for phone both an SMS gateway and a Guest role allowed to use it, because send_sms permission-checks its caller. The dialog skeletons the options until that answer lands, disables what the site cannot deliver, and says why in an Alert. A selection the site cannot deliver is dropped to None rather than saved. The controller's own email check now calls the same helper, so it no longer misses mail configured in site_config.json. Known gaps, left for a separate change: buzz/api/booking/guests.py calls the whitelisted send_sms, which raises PermissionError for the Guest role, and validate_guest_verification_config still has no Phone OTP branch. --- buzz/api/booking/guests.py | 15 ++ buzz/api/events/__init__.py | 7 + buzz/api/events/schemas.py | 10 ++ buzz/api/events/services.py | 9 + buzz/api/events/test_events.py | 54 ++++++ buzz/events/doctype/buzz_event/buzz_event.py | 7 +- dashboard/components.d.ts | 2 + .../dashboard/events/EventGuestActions.vue | 35 +++- .../events/GuestRegistrationDialog.vue | 154 ++++++++++++++++++ .../dashboard/events/QuickActionTile.vue | 70 ++++++++ .../dashboard/events/QuickActionsRail.vue | 53 ++---- dashboard/src/data/events.ts | 20 ++- .../src/pages/manage/events/EventGuests.vue | 2 + dashboard/src/types.ts | 8 + 14 files changed, 405 insertions(+), 41 deletions(-) create mode 100644 dashboard/src/components/dashboard/events/GuestRegistrationDialog.vue create mode 100644 dashboard/src/components/dashboard/events/QuickActionTile.vue diff --git a/buzz/api/booking/guests.py b/buzz/api/booking/guests.py index 7827fc81..9d111654 100644 --- a/buzz/api/booking/guests.py +++ b/buzz/api/booking/guests.py @@ -6,11 +6,26 @@ from frappe import _ from frappe.auth import LoginAttemptTracker from frappe.core.doctype.sms_settings.sms_settings import send_sms +from frappe.email.doctype.email_account.email_account import EmailAccount from frappe.utils import validate_email_address, validate_phone_number_with_country_code from buzz.api.booking.exceptions import InvalidOTP, OTPExpired, TooManyOTPAttempts +def email_otp_available() -> bool: + """What frappe.sendmail itself resolves through, so mail set in site_config counts.""" + return bool(EmailAccount.find_default_outgoing()) + + +def phone_otp_available() -> bool: + """A gateway, and a Guest allowed to use it: send_sms permission-checks its caller.""" + if not frappe.db.get_single_value("SMS Settings", "sms_gateway_url"): + return False + + allowed = {row.role for row in frappe.get_single("SMS Settings").get("allowed_roles")} + return "Guest" in allowed + + def send_booking_otp(event: int, identifier: str) -> dict | None: event_doc = frappe.get_cached_doc("Buzz Event", event) diff --git a/buzz/api/events/__init__.py b/buzz/api/events/__init__.py index cdef8957..afe6c88f 100644 --- a/buzz/api/events/__init__.py +++ b/buzz/api/events/__init__.py @@ -12,6 +12,7 @@ RegistrationState, RegistrationTrend, RouteAvailability, + VerificationMethods, ) @@ -55,6 +56,12 @@ def set_registration_state(event: str, closed: bool) -> RegistrationState: return services.set_registration_state(event, closed) +@frappe.whitelist() +def get_verification_methods() -> VerificationMethods: + """Which guest verification methods this site can deliver, for the settings dialog.""" + return services.verification_methods() + + @frappe.whitelist() def get_event_registration_trend(event: str, days: int = services.TREND_DAYS) -> RegistrationTrend: """Registrations per day for an event, for the card above its guest list.""" diff --git a/buzz/api/events/schemas.py b/buzz/api/events/schemas.py index 92c9ef79..c496c0fe 100644 --- a/buzz/api/events/schemas.py +++ b/buzz/api/events/schemas.py @@ -121,6 +121,9 @@ class EventGuestsResponse(APIResponse): registrations_closed: bool # Read access alone is a Viewer or Frontdesk, who cannot change the registration state. can_write: bool = False + # Whether people without an account can register themselves, and how they are verified. + allow_guest_booking: bool = False + guest_verification_method: str = "None" guests: list[EventGuest] ticket_types: list[GuestTicketType] = Field(default_factory=list) has_next_page: bool = False @@ -180,3 +183,10 @@ class RegistrationState(APIResponse): """Whether the event takes registrations, as the server reads it after a change.""" registrations_closed: bool + + +class VerificationMethods(APIResponse): + """Which guest verification methods this site is configured to deliver.""" + + email: bool = False + phone: bool = False diff --git a/buzz/api/events/services.py b/buzz/api/events/services.py index 91009e1b..fc528660 100644 --- a/buzz/api/events/services.py +++ b/buzz/api/events/services.py @@ -4,6 +4,7 @@ from frappe.query_builder.functions import Count, Date from frappe.utils import add_days, get_datetime_in_timezone, get_system_timezone, getdate +from buzz.api.booking.guests import email_otp_available, phone_otp_available from buzz.api.booking.services import are_registrations_closed from buzz.api.events.exceptions import ( CannotCreateEvents, @@ -29,6 +30,7 @@ RegistrationTrend, RouteAvailability, TicketTypeTotal, + VerificationMethods, ) from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, BuzzEvent from buzz.permissions import has_team_access, my_teams @@ -298,6 +300,11 @@ def set_registration_state(event: str, closed: bool) -> RegistrationState: return RegistrationState(registrations_closed=are_registrations_closed(doc)) +def verification_methods() -> VerificationMethods: + """Site configuration, not event data: what a guest OTP could actually be sent over.""" + return VerificationMethods(email=email_otp_available(), phone=phone_otp_available()) + + def ensure_event_team_access(event: str) -> None: """Read access to the event's team is the bar for everything a manage page shows.""" if not frappe.db.exists("Buzz Event", event): @@ -370,6 +377,8 @@ def event_guests( matched=matched, registrations_closed=are_registrations_closed(doc), can_write=has_team_access(doc.team, "write", frappe.session.user), + allow_guest_booking=bool(doc.allow_guest_booking), + guest_verification_method=doc.guest_verification_method or "None", guests=guests, ticket_types=ticket_types_of(event), has_next_page=start + len(guests) < matched, diff --git a/buzz/api/events/test_events.py b/buzz/api/events/test_events.py index 3bce6620..ccf81da8 100644 --- a/buzz/api/events/test_events.py +++ b/buzz/api/events/test_events.py @@ -10,6 +10,7 @@ get_event_guests, get_event_registration_trend, get_my_events, + get_verification_methods, remove_co_host, set_registration_state, ) @@ -528,6 +529,20 @@ def test_counts_and_lists_the_submitted_tickets(self): emails = {guest["attendee_email"] for guest in guests["guests"]} self.assertEqual(emails, {"guest-one@example.com", "guest-two@example.com"}) + def test_carries_the_guest_registration_settings(self): + event = create_event("Guest Setting Event", self.team) + frappe.db.set_value( + "Buzz Event", + event, + {"allow_guest_booking": 1, "guest_verification_method": "Phone OTP"}, + ) + frappe.set_user(self.owner) + + guests = get_event_guests(event) + + self.assertTrue(guests.allow_guest_booking) + self.assertEqual(guests.guest_verification_method, "Phone OTP") + def test_leaves_out_a_ticket_that_was_never_submitted(self): event = create_event("Draft Ticket Event", self.team) create_ticket(event, "draft-guest@example.com") @@ -1060,3 +1075,42 @@ def test_an_ordinary_event_is_linked_to_its_own_registration_page(self): frappe.set_user(self.owner) self.assertEqual(get_event_guests(event).registration_link, "/b/register/hosted-event") + + +class TestVerificationMethods(IntegrationTestCase): + """Site configuration, so every case here writes SMS Settings rather than an event.""" + + def setUp(self): + frappe.set_user("Administrator") + self.addCleanup(frappe.set_user, "Administrator") + # Cleanups run last-registered-first, so the cache is cleared after the rollback: + # the Single is cached per request and would otherwise be read back undone. + self.addCleanup(frappe.clear_document_cache, "SMS Settings", "SMS Settings") + self.addCleanup(frappe.db.rollback) + + def test_phone_needs_a_gateway(self): + # Written through the db: an unconfigured Single cannot pass its own mandatory check. + frappe.db.set_single_value("SMS Settings", "sms_gateway_url", "") + + self.assertFalse(get_verification_methods().phone) + + def test_phone_needs_the_guest_role_to_be_allowed(self): + settings = frappe.get_single("SMS Settings") + settings.sms_gateway_url = "https://sms.example.com/send" + settings.message_parameter = "message" + settings.receiver_parameter = "to" + settings.set("allowed_roles", [{"role": "System Manager"}]) + settings.save() + + self.assertFalse(get_verification_methods().phone) + + settings.append("allowed_roles", {"role": "Guest"}) + settings.save() + + self.assertTrue(get_verification_methods().phone) + + def test_email_follows_the_outgoing_account(self): + # Whatever this site is configured with, the answer is what frappe.sendmail resolves. + from frappe.email.doctype.email_account.email_account import EmailAccount + + self.assertEqual(get_verification_methods().email, bool(EmailAccount.find_default_outgoing())) diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index 86b41409..26154e69 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -214,8 +214,11 @@ def validate_guest_verification_config(self): return if self.guest_verification_method == "Email OTP": - has_email = frappe.db.exists("Email Account", {"default_outgoing": 1, "enable_outgoing": 1}) - if not has_email: + # Imported here rather than at the top: doctype modules load during boot, and + # this pulls frappe.email onto that path for a check only this branch makes. + from buzz.api.booking.guests import email_otp_available + + if not email_otp_available(): frappe.throw( frappe._( "Please configure an outgoing Email Account before enabling Email OTP verification." diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 53312c8d..709827f2 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -65,6 +65,7 @@ declare module 'vue' { FilterBar: typeof import('./src/components/common/filters/FilterBar.vue')['default'] FormFieldSections: typeof import('./src/components/FormFieldSections.vue')['default'] GuestInfoDrawer: typeof import('./src/components/dashboard/events/GuestInfoDrawer.vue')['default'] + GuestRegistrationDialog: typeof import('./src/components/dashboard/events/GuestRegistrationDialog.vue')['default'] ImageCropper: typeof import('./src/components/common/ImageCropper.vue')['default'] ImageCropUploader: typeof import('./src/components/common/ImageCropUploader.vue')['default'] LanguageSwitcher: typeof import('./src/components/LanguageSwitcher.vue')['default'] @@ -85,6 +86,7 @@ declare module 'vue' { ProposalsDialog: typeof import('./src/components/dashboard/proposals/ProposalsDialog.vue')['default'] QRScanner: typeof import('./src/components/QRScanner.vue')['default'] QuickActionsRail: typeof import('./src/components/dashboard/events/QuickActionsRail.vue')['default'] + QuickActionTile: typeof import('./src/components/dashboard/events/QuickActionTile.vue')['default'] RegistrationDialog: typeof import('./src/components/dashboard/events/RegistrationDialog.vue')['default'] RestrictionNotices: typeof import('./src/components/RestrictionNotices.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/dashboard/src/components/dashboard/events/EventGuestActions.vue b/dashboard/src/components/dashboard/events/EventGuestActions.vue index cd5886f2..0c45e5ba 100644 --- a/dashboard/src/components/dashboard/events/EventGuestActions.vue +++ b/dashboard/src/components/dashboard/events/EventGuestActions.vue @@ -2,9 +2,11 @@ import { call, toast } from "frappe-ui" import { computed, ref } from "vue" +import GuestRegistrationDialog from "@/components/dashboard/events/GuestRegistrationDialog.vue" import QuickActionsRail, { type QuickAction, } from "@/components/dashboard/events/QuickActionsRail.vue" +import QuickActionTile from "@/components/dashboard/events/QuickActionTile.vue" import RegistrationDialog from "@/components/dashboard/events/RegistrationDialog.vue" import type { EventGuest, EventGuests } from "@/types" import { downloadCsv } from "@/utils/csv" @@ -15,14 +17,28 @@ const props = defineProps<{ canWrite: boolean title?: string | null registrationLink?: string | null + allowGuestBooking: boolean + guestVerificationMethod: string // What the list is currently showing, so the export is the same list. query: { search: string; ticket_types: string; order: string } }>() const emit = defineEmits<{ changed: [] }>() const dialogOpen = ref(false) +const guestDialogOpen = ref(false) const exporting = ref(false) +const VERIFICATION_LABELS: Record = { + "Email OTP": "Email verification", + "Phone OTP": "Phone verification", +} + +const guestSubtitle = computed(() => { + if (!props.allowGuestBooking) return "Disabled" + const verification = VERIFICATION_LABELS[props.guestVerificationMethod] + return verification ? `Enabled · ${verification}` : "Enabled" +}) + // The list is paged, so the export walks it: a hundred at a time until the server says // there is no next page. async function fetchAll(): Promise { @@ -89,7 +105,16 @@ const actions = computed(() => :can-write="canWrite" :actions="actions" @toggle="dialogOpen = true" - /> + > + + (() => :closed="closed" @changed="emit('changed')" /> + + diff --git a/dashboard/src/components/dashboard/events/GuestRegistrationDialog.vue b/dashboard/src/components/dashboard/events/GuestRegistrationDialog.vue new file mode 100644 index 00000000..16b6aba7 --- /dev/null +++ b/dashboard/src/components/dashboard/events/GuestRegistrationDialog.vue @@ -0,0 +1,154 @@ + + + diff --git a/dashboard/src/components/dashboard/events/QuickActionTile.vue b/dashboard/src/components/dashboard/events/QuickActionTile.vue new file mode 100644 index 00000000..fd5a2826 --- /dev/null +++ b/dashboard/src/components/dashboard/events/QuickActionTile.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/dashboard/src/components/dashboard/events/QuickActionsRail.vue b/dashboard/src/components/dashboard/events/QuickActionsRail.vue index 5ae6427f..bda8edab 100644 --- a/dashboard/src/components/dashboard/events/QuickActionsRail.vue +++ b/dashboard/src/components/dashboard/events/QuickActionsRail.vue @@ -11,6 +11,8 @@ export type QuickAction = {