Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions buzz/api/booking/guests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions buzz/api/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
RegistrationState,
RegistrationTrend,
RouteAvailability,
VerificationMethods,
)


Expand Down Expand Up @@ -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."""
Expand Down
10 changes: 10 additions & 0 deletions buzz/api/events/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
9 changes: 9 additions & 0 deletions buzz/api/events/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions buzz/api/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
get_event_guests,
get_event_registration_trend,
get_my_events,
get_verification_methods,
remove_co_host,
set_registration_state,
)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()))
27 changes: 18 additions & 9 deletions buzz/events/doctype/buzz_event/buzz_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions buzz/events/doctype/buzz_event/test_buzz_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 2 additions & 0 deletions dashboard/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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']
Expand Down
35 changes: 34 additions & 1 deletion dashboard/src/components/dashboard/events/EventGuestActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<string, string> = {
"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<EventGuest[]> {
Expand Down Expand Up @@ -89,12 +105,29 @@ const actions = computed<QuickAction[]>(() =>
:can-write="canWrite"
:actions="actions"
@toggle="dialogOpen = true"
/>
>
<QuickActionTile
icon="lucide-hat-glasses"
title="Guest registration"
:subtitle="guestSubtitle"
:tone="allowGuestBooking ? 'violet' : 'gray'"
:disabled="!canWrite"
@click="guestDialogOpen = true"
/>
</QuickActionsRail>

<RegistrationDialog
v-model="dialogOpen"
:event="event"
:closed="closed"
@changed="emit('changed')"
/>

<GuestRegistrationDialog
v-model="guestDialogOpen"
:event="event"
:enabled="allowGuestBooking"
:method="guestVerificationMethod"
@changed="emit('changed')"
/>
</template>
Loading
Loading