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..9b11d356 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -213,15 +213,24 @@ def validate_guest_verification_config(self): if frappe.in_test or not self.allow_guest_booking: 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: - frappe.throw( - frappe._( - "Please configure an outgoing Email Account before enabling Email OTP verification." - ), - title=frappe._("Email Not Configured"), - ) + # Imported here rather than at the top: doctype modules load during boot, and this + # pulls frappe.email onto that path for a check only these branches make. + from buzz.api.booking.guests import email_otp_available, phone_otp_available + + if self.guest_verification_method == "Email OTP" and not email_otp_available(): + frappe.throw( + _("Please configure an outgoing Email Account before enabling Email OTP verification."), + title=_("Email Not Configured"), + ) + + if self.guest_verification_method == "Phone OTP" and not phone_otp_available(): + frappe.throw( + _( + "Please configure SMS Settings, and allow the Guest role to send SMS, " + "before enabling Phone OTP verification." + ), + title=_("SMS Not Configured"), + ) def after_insert(self): self.create_default_records() diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index 6a39e276..07165b1e 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -1193,3 +1193,43 @@ def test_webinar_template_comes_from_the_events_team(self): webinar = event.create_webinar_on_zoom() self.assertEqual(webinar.template, template) + + +class TestGuestVerificationConfig(FrappeTestCase): + """The method is called directly: it is the only validation under test, and the + `frappe.in_test` early return has to be lifted for any of it to run.""" + + def _event(self, method): + event = frappe.new_doc("Buzz Event") + event.allow_guest_booking = 1 + event.guest_verification_method = method + return event + + def test_email_otp_needs_an_outgoing_account(self): + with ( + patch.object(frappe, "in_test", False), + patch("buzz.api.booking.guests.email_otp_available", return_value=False), + ): + self.assertRaises( + frappe.ValidationError, self._event("Email OTP").validate_guest_verification_config + ) + + def test_phone_otp_needs_sms_a_guest_can_be_sent(self): + with ( + patch.object(frappe, "in_test", False), + patch("buzz.api.booking.guests.phone_otp_available", return_value=False), + ): + self.assertRaises( + frappe.ValidationError, self._event("Phone OTP").validate_guest_verification_config + ) + + def test_a_configured_site_passes(self): + with ( + patch.object(frappe, "in_test", False), + patch("buzz.api.booking.guests.phone_otp_available", return_value=True), + ): + self._event("Phone OTP").validate_guest_verification_config() + + def test_none_needs_nothing_configured(self): + with patch.object(frappe, "in_test", False): + self._event("None").validate_guest_verification_config() 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..2cb80e78 --- /dev/null +++ b/dashboard/src/components/dashboard/events/GuestRegistrationDialog.vue @@ -0,0 +1,161 @@ + + + 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 = {