diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index df0c8fdc..68f94c08 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -75,26 +75,26 @@
## Backend Flows
### Booking + Payment
-1. Dashboard calls `buzz.api.get_event_booking_data` to load ticket types, add-ons, custom fields, and payment gateways.
-2. `buzz.api.process_booking` creates an `Event Booking` with attendees, add-ons, custom fields, and UTM parameters.
+1. Dashboard calls `buzz.api.booking.get_event_booking_data` to load ticket types, add-ons, custom fields, and payment gateways.
+2. `buzz.api.booking.process_booking` creates an `Event Booking` with attendees, add-ons, custom fields, and UTM parameters.
3. If total is 0, booking is auto-submitted; otherwise `Event Payment` is created and a payment URL is returned.
4. On payment authorization, `Event Booking.on_payment_authorized` marks the payment received and submits the booking.
5. Booking submission creates `Event Ticket` records and triggers QR + email flow.
### Ticket Lifecycle
- Ticket creation generates QR code file and email (with print format attachment).
-- Transfers are handled via `buzz.api.transfer_ticket` with window checks from `Buzz Settings`.
-- Add-on preference changes use `buzz.api.change_add_on_preference` with window checks.
-- Cancellation requests are created via `buzz.api.create_cancellation_request` and are accepted/rejected in Desk.
+- Transfers are handled via `buzz.api.tickets.transfer_ticket` with window checks from `Buzz Settings`.
+- Add-on preference changes use `buzz.api.tickets.change_add_on_preference` with window checks.
+- Cancellation requests are created via `buzz.api.tickets.create_cancellation_request` and are accepted/rejected in Desk.
### Sponsorships
- Web form submits `Sponsorship Enquiry`.
- Approval moves status to `Payment Pending` and triggers email notification.
-- Payment link is generated via `buzz.api.create_sponsorship_payment_link`.
+- Payment link is generated via `buzz.api.sponsorships.create_sponsorship_payment_link`.
- Payment authorization creates `Event Sponsor` and marks enquiry as paid.
### Check-in
-- Dashboard scanner validates tickets with `buzz.api.validate_ticket_for_checkin`.
+- Dashboard scanner validates tickets with `buzz.api.checkin.validate_ticket_for_checkin`.
- Successful check-in creates `Event Check In` and returns event/ticket context.
## API Surface (Whitelisted)
@@ -130,7 +130,7 @@
- Public-like flows: booking (`/register/:eventRoute`) and check-in (`/check-in`).
- Account area under `/account`:
- bookings list/details, tickets list/details, sponsorships list/details.
-- Guard: `router.beforeEach` checks `buzz.api.get_user_info` and redirects to `/login` if unauthenticated.
+- Guard: `router.beforeEach` checks `buzz.api.account.get_user_info` and redirects to `/login` if unauthenticated.
### Data Access Pattern
- Uses `frappe-ui` `createResource` and `createListResource` for API calls.
@@ -202,30 +202,30 @@ erDiagram
## Frontend Page -> API Map
- `dashboard/src/pages/BookTickets.vue`
- - `buzz.api.get_event_booking_data` to load event booking config.
- - `buzz.api.process_booking` via `dashboard/src/components/BookingForm.vue`.
+ - `buzz.api.booking.get_event_booking_data` to load event booking config.
+ - `buzz.api.booking.process_booking` via `dashboard/src/components/BookingForm.vue`.
- `dashboard/src/pages/BookingDetails.vue`
- - `buzz.api.get_booking_details` for booking + ticket data.
- - `buzz.api.create_cancellation_request` via `dashboard/src/components/CancellationRequestDialog.vue`.
- - `buzz.api.transfer_ticket` via `dashboard/src/components/TicketsSection.vue`.
+ - `buzz.api.booking.get_booking_details` for booking + ticket data.
+ - `buzz.api.tickets.create_cancellation_request` via `dashboard/src/components/CancellationRequestDialog.vue`.
+ - `buzz.api.tickets.transfer_ticket` via `dashboard/src/components/TicketsSection.vue`.
- `dashboard/src/pages/TicketDetails.vue`
- - `buzz.api.get_ticket_details`.
- - `buzz.api.change_add_on_preference` via `dashboard/src/components/AddOnPreferenceDialog.vue`.
- - `buzz.api.transfer_ticket` via `dashboard/src/components/TicketTransferDialog.vue`.
+ - `buzz.api.tickets.get_ticket_details`.
+ - `buzz.api.tickets.change_add_on_preference` via `dashboard/src/components/AddOnPreferenceDialog.vue`.
+ - `buzz.api.tickets.transfer_ticket` via `dashboard/src/components/TicketTransferDialog.vue`.
- `dashboard/src/pages/SponsorshipsList.vue`
- - `buzz.api.get_user_sponsorship_inquiries`.
+ - `buzz.api.sponsorships.get_user_sponsorship_inquiries`.
- `dashboard/src/pages/SponsorshipDetails.vue`
- - `buzz.api.get_sponsorship_details`.
- - `buzz.api.withdraw_sponsorship_enquiry`.
- - `buzz.api.get_event_payment_gateways` and `buzz.api.create_sponsorship_payment_link` via `dashboard/src/components/SponsorshipPaymentDialog.vue`.
+ - `buzz.api.sponsorships.get_sponsorship_details`.
+ - `buzz.api.sponsorships.withdraw_sponsorship_enquiry`.
+ - `buzz.api.payments.get_event_payment_gateways` and `buzz.api.sponsorships.create_sponsorship_payment_link` via `dashboard/src/components/SponsorshipPaymentDialog.vue`.
- `dashboard/src/pages/CheckInScanner.vue`
- - `buzz.api.validate_ticket_for_checkin` and `buzz.api.checkin_ticket` via `dashboard/src/composables/useTicketValidation.js`.
+ - `buzz.api.checkin.validate_ticket_for_checkin` and `buzz.api.checkin.checkin_ticket` via `dashboard/src/composables/useTicketValidation.js`.
- `dashboard/src/data/user.js`
- - `buzz.api.get_user_info` for session user details and roles.
+ - `buzz.api.account.get_user_info` for session user details and roles.
- `dashboard/src/composables/useLanguage.js`
- - `buzz.api.get_enabled_languages`, `buzz.api.update_user_language`.
+ - `buzz.api.account.get_enabled_languages`, `buzz.api.account.update_user_language`.
- `dashboard/src/translation.js`
- - `buzz.api.get_translations`.
+ - `buzz.api.account.get_translations`.
## Feature Development Checklist (Common Changes)
- Tickets / booking changes
@@ -242,7 +242,7 @@ erDiagram
- Update `Sponsorship Enquiry` for status transitions and `SponsorshipDetails.vue` for UI state.
- Verify sponsor creation in `on_payment_authorized`.
- Check-in changes
- - Update `buzz.api.validate_ticket_for_checkin` and `CheckInScanner.vue`/`useTicketValidation.js`.
+ - Update `buzz.api.checkin.validate_ticket_for_checkin` and `CheckInScanner.vue`/`useTicketValidation.js`.
- Confirm role gating for `Frontdesk Manager` remains consistent.
- Event publishing or public pages
- Review `Buzz Event` route generation and `Additional Event Page` route uniqueness.
diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py
index b5048227..e69de29b 100644
--- a/buzz/api/__init__.py
+++ b/buzz/api/__init__.py
@@ -1,1364 +0,0 @@
-import hashlib
-import hmac
-import os
-from base64 import b32encode
-
-import frappe
-import pyotp
-from frappe import _
-from frappe.auth import LoginAttemptTracker
-from frappe.core.doctype.sms_settings.sms_settings import send_sms
-from frappe.rate_limiter import rate_limit
-from frappe.translate import get_all_translations
-from frappe.utils import (
- days_diff,
- format_date,
- format_time,
- get_datetime,
- get_datetime_in_timezone,
- get_system_timezone,
- now_datetime,
- today,
- validate_email_address,
-)
-from frappe.utils.password import get_encryption_key
-
-from buzz.payments import (
- get_payment_gateways_for_event,
- get_payment_link_for_booking,
- get_payment_link_for_sponsorship,
-)
-from buzz.utils import ZOOM_BACKED_CATEGORIES, build_event_datetimes, is_app_installed
-
-OFFLINE_PAYMENT_METHOD = "Offline"
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-@rate_limit(key="identifier", limit=5, seconds=3600)
-def send_guest_booking_otp(event: int, identifier: str) -> dict:
- event_doc = frappe.get_cached_doc("Buzz Event", event)
-
- if not event_doc.allow_guest_booking:
- frappe.throw(_("Guest booking is not enabled for this event"))
-
- if event_doc.guest_verification_method == "None":
- frappe.throw(_("OTP verification is not enabled for this event"))
-
- channel = "phone" if event_doc.guest_verification_method == "Phone OTP" else "email"
-
- identifier = identifier.strip()
- if not identifier:
- frappe.throw(_("Email or phone is required"))
-
- if channel == "email":
- identifier = identifier.lower()
- validate_email_address(identifier, throw=True)
-
- otp_secret = b32encode(os.urandom(10)).decode("utf-8")
- otp_code = pyotp.HOTP(otp_secret).at(0)
- cache_key = f"guest_booking_otp:{channel}:{identifier}"
-
- if frappe.in_test:
- frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
- return {"otp": otp_code}
-
- try:
- if channel == "email":
- frappe.sendmail(
- recipients=[identifier],
- subject=_("Your Booking Verification Code"),
- message=_(
- "Your verification code is: {0}
This code expires in 10 minutes."
- ).format(otp_code),
- now=True,
- )
- else:
- send_sms(
- receiver_list=[identifier],
- msg=_("Your booking verification code is: {0}. It expires in 10 minutes.").format(otp_code),
- )
- except Exception:
- frappe.throw(_("Failed to send verification code. Please try again."))
-
- frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
-
-
-def verify_guest_otp(channel: str, identifier: str, otp: str):
- cache_key = f"guest_booking_otp:{channel}:{identifier}"
- tracker = LoginAttemptTracker(
- key=f"guest_otp:{channel}:{identifier}",
- max_consecutive_login_attempts=5,
- lock_interval=600,
- )
-
- if not tracker.is_user_allowed():
- frappe.throw(_("Too many failed attempts. Please try again later."))
-
- otp_secret = frappe.cache.get_value(cache_key)
- if not otp_secret:
- frappe.throw(_("Verification code expired. Please request a new one."))
-
- if not pyotp.HOTP(otp_secret).verify(otp.strip(), 0):
- tracker.add_failure_attempt()
- frappe.throw(_("Invalid verification code"))
-
- frappe.cache.delete_value(cache_key)
- tracker.add_success_attempt()
-
-
-def get_or_create_guest_user(email: str, full_name: str) -> str:
- email = email.lower().strip()
-
- validate_email_address(email, throw=True)
- if frappe.db.exists("User", email):
- return email
-
- name_parts = full_name.strip().split(" ", 1)
- first_name = name_parts[0] if name_parts else "Guest"
- last_name = name_parts[1] if len(name_parts) > 1 else ""
-
- user = frappe.get_doc(
- {
- "doctype": "User",
- "email": email,
- "first_name": first_name,
- "last_name": last_name,
- "enabled": 1,
- "user_type": "Website User",
- "send_welcome_email": 0,
- }
- )
- user.insert(ignore_permissions=True)
-
- return email
-
-
-@frappe.whitelist()
-def get_event_payment_gateways(event: str) -> list[str]:
- return get_payment_gateways_for_event(event)
-
-
-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)
-
- if event_doc.registrations_close_at:
- return current_datetime_in_event_timezone > get_datetime(event_doc.registrations_close_at)
-
- # No explicit cutoff set - registrations close once the event itself has ended.
- _, event_end_datetime = build_event_datetimes(event_doc)
- return current_datetime_in_event_timezone > event_end_datetime
-
-
-def is_ticket_transfer_allowed(event_id: str | int) -> bool:
- try:
- event = frappe.get_cached_doc("Buzz Event", event_id)
- settings = frappe.get_single("Buzz Settings")
- transfer_cutoff_days = settings.get("allow_transfer_ticket_before_event_start_days", 7)
-
- if not event.start_date:
- return False
-
- return days_diff(event.start_date, today()) >= transfer_cutoff_days
- except Exception as e:
- frappe.log_error(f"Error checking ticket transfer eligibility: {e!s}")
- return False
-
-
-def is_add_on_change_allowed(event_id: str | int) -> bool:
- try:
- event = frappe.get_cached_doc("Buzz Event", event_id)
- settings = frappe.get_cached_doc("Buzz Settings")
- add_on_change_cutoff_days = settings.get("allow_add_ons_change_before_event_start_days", 7)
-
- if not event.start_date:
- return False
-
- return days_diff(event.start_date, today()) >= add_on_change_cutoff_days
- except Exception as e:
- frappe.log_error(f"Error checking add-on change eligibility: {e!s}")
- return False
-
-
-@frappe.whitelist()
-def can_transfer_ticket(event_id: str | int) -> dict:
- return {"can_transfer": is_ticket_transfer_allowed(event_id), "event_id": event_id}
-
-
-@frappe.whitelist()
-def can_change_add_ons(event_id: str | int) -> dict:
- return {"can_change_add_ons": is_add_on_change_allowed(event_id), "event_id": event_id}
-
-
-def is_cancellation_request_allowed(event_id: str | int) -> bool:
- try:
- event = frappe.get_cached_doc("Buzz Event", event_id)
- settings = frappe.get_cached_doc("Buzz Settings")
- cancellation_cutoff_days = settings.get(
- "allow_ticket_cancellation_request_before_event_start_days", 7
- )
-
- if not event.start_date:
- return False
-
- return days_diff(event.start_date, today()) >= cancellation_cutoff_days
- except Exception as e:
- frappe.log_error(f"Error checking cancellation request eligibility: {e!s}")
- return False
-
-
-@frappe.whitelist()
-def can_request_cancellation(event_id: str | int) -> dict:
- return {"can_request_cancellation": is_cancellation_request_allowed(event_id), "event_id": event_id}
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def get_event_booking_data(event_route: str) -> dict:
- data = frappe._dict()
- event_doc = frappe.get_cached_doc("Buzz Event", {"route": event_route})
-
- if not event_doc.is_published:
- frappe.throw(_("Event not found"), frappe.DoesNotExistError)
-
- data.registrations_closed = are_registrations_closed(event_doc)
-
- is_guest = frappe.session.user == "Guest"
- if is_guest:
- data.event_details = {
- "name": event_doc.name,
- "title": event_doc.title,
- "route": event_doc.route,
- "start_date": event_doc.start_date,
- "end_date": event_doc.end_date,
- "start_time": event_doc.start_time,
- "end_time": event_doc.end_time,
- "time_zone": event_doc.time_zone,
- "time_zone_label": event_doc.time_zone_label,
- "venue": event_doc.venue,
- "medium": event_doc.medium,
- "category": event_doc.category,
- "banner_image": event_doc.banner_image,
- "short_description": event_doc.short_description,
- "free_event": event_doc.free_event,
- "send_ticket_email": event_doc.send_ticket_email,
- "allow_guest_booking": event_doc.allow_guest_booking,
- "guest_verification_method": event_doc.guest_verification_method,
- "default_ticket_type": event_doc.default_ticket_type,
- }
- else:
- data.event_details = event_doc
-
- available_ticket_types = []
- published_ticket_types = frappe.db.get_all(
- "Event Ticket Type", filters={"is_published": True, "event": event_doc.name}, pluck="name"
- )
- for ticket_type in published_ticket_types:
- tt = frappe.get_cached_doc("Event Ticket Type", ticket_type)
- if tt.are_tickets_available(1):
- available_ticket_types.append(tt)
- data.available_ticket_types = available_ticket_types
-
- add_ons = frappe.db.get_all(
- "Ticket Add-on", filters={"event": event_doc.name, "enabled": 1}, fields=["*"], order_by="title"
- )
-
- for add_on in add_ons:
- if add_on.user_selects_option:
- add_on.options = add_on.options.split("\n")
-
- data.available_add_ons = add_ons
-
- data.tax_settings = {
- "apply_tax": event_doc.apply_tax,
- "tax_inclusive": event_doc.tax_inclusive,
- "tax_label": event_doc.tax_label or "Tax",
- "tax_percentage": event_doc.tax_percentage or 0,
- }
-
- custom_fields = frappe.db.get_all(
- "Buzz Custom Field",
- filters={"event": event_doc.name, "enabled": 1},
- fields=["*"],
- order_by="order",
- )
- data.custom_fields = custom_fields
-
- payment_gateways = get_payment_gateways_for_event(event_doc.name)
-
- offline_methods_raw = frappe.get_all(
- "Offline Payment Method",
- filters={"event": event_doc.name, "enabled": 1},
- fields=["name", "title", "description", "collect_payment_proof"],
- order_by="creation",
- )
-
- offline_methods = []
- for method in offline_methods_raw:
- method_custom_fields = frappe.get_all(
- "Buzz Custom Field",
- filters={
- "event": event_doc.name,
- "enabled": 1,
- "applied_to": "Offline Payment Form",
- "offline_payment_method": method.name,
- },
- fields=["*"],
- order_by="order",
- )
- offline_methods.append(
- {
- "name": method.name,
- "title": method.title,
- "description": method.description,
- "collect_payment_proof": method.collect_payment_proof,
- "custom_fields": method_custom_fields,
- }
- )
- payment_gateways.append(method.title)
-
- data.payment_gateways = payment_gateways
- data.offline_payment_enabled = len(offline_methods) > 0
- data.offline_methods = offline_methods
-
- return data
-
-
-def validate_custom_fields(custom_fields_data: dict, phone_field_map: dict) -> None:
- for field_name, field_value in custom_fields_data.items():
- if field_value and field_name in phone_field_map:
- frappe.utils.validate_phone_number_with_country_code(
- str(field_value), phone_field_map[field_name]
- )
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def process_booking(
- attendees: list[dict],
- event: str,
- coupon_code: str | None = None,
- booking_custom_fields: dict | None = None,
- payment_gateway: str | None = None,
- utm_parameters: list[dict] | None = None,
- guest_email: str | None = None,
- guest_full_name: str | None = None,
- otp: str | None = None,
- guest_phone: str | None = None,
- payment_proof: str | None = None,
- is_offline: bool = False,
- offline_payment_method: str | None = None,
- invoice_requested: bool = False,
- tax_id: str | None = None,
- billing_address: str | None = None,
-) -> dict:
- event_doc = frappe.get_cached_doc("Buzz Event", event)
- if not event_doc.is_published:
- frappe.throw(_("Event is not live"))
-
- if are_registrations_closed(event_doc):
- frappe.throw(_("Registrations for this event are closed"))
-
- is_guest = frappe.session.user == "Guest"
-
- if is_guest:
- if not event_doc.allow_guest_booking:
- frappe.throw(_("Please log in to access this feature"), frappe.AuthenticationError)
-
- if not guest_email:
- frappe.throw(_("Email is required for guest booking"))
-
- validate_email_address(guest_email, throw=True)
- email = guest_email.lower().strip()
-
- if event_doc.guest_verification_method == "Email OTP":
- if not otp:
- frappe.throw(_("Verification code is required"))
- verify_guest_otp("email", email, otp)
-
- elif event_doc.guest_verification_method == "Phone OTP":
- if not otp:
- frappe.throw(_("Verification code is required"))
- if not guest_phone:
- frappe.throw(_("Phone number is required"))
- verify_guest_otp("phone", guest_phone.strip(), otp)
-
- first_name = (attendees[0].get("first_name") or "").strip()
- last_name = (attendees[0].get("last_name") or "").strip()
- full_name = (guest_full_name or "").strip() or f"{first_name} {last_name}".strip()
- if not full_name:
- frappe.throw(_("Full name is required for guest booking"))
- booking_user = get_or_create_guest_user(guest_email, full_name)
- else:
- booking_user = frappe.session.user
-
- booking = frappe.new_doc("Event Booking")
- booking.event = event
- booking.coupon_code = coupon_code
- booking.user = booking_user
-
- if event_doc.apply_tax and invoice_requested:
- booking.invoice_requested = 1
- booking.tax_id = tax_id
- booking.billing_address = billing_address
-
- if utm_parameters:
- for utm_param in utm_parameters:
- booking.append(
- "utm_parameters",
- {
- "utm_name": utm_param.get("utm_name"),
- "value": utm_param.get("value"),
- },
- )
-
- if booking_custom_fields:
- booking_custom_field_defs = frappe.db.get_all(
- "Buzz Custom Field",
- filters={"event": event, "enabled": 1, "applied_to": "Booking"},
- fields=["fieldname", "label", "fieldtype"],
- )
- custom_field_map = {cf["fieldname"]: cf for cf in booking_custom_field_defs}
-
- for field_name, field_value in booking_custom_fields.items():
- if field_value and field_name in custom_field_map:
- field_def = custom_field_map[field_name]
- booking.append(
- "additional_fields",
- {
- "fieldname": field_name,
- "value": str(field_value),
- "label": field_def["label"],
- "fieldtype": field_def["fieldtype"],
- },
- )
-
- phone_fields = frappe.db.get_all(
- "Buzz Custom Field",
- filters={"event": event, "enabled": 1, "fieldtype": "Phone"},
- fields=["fieldname", "label"],
- )
- phone_map = {cf["fieldname"]: cf["label"] for cf in phone_fields}
-
- if event_doc.category in ZOOM_BACKED_CATEGORIES:
- for attendee in attendees:
- if not (attendee.get("last_name") or "").strip():
- frappe.throw(_("Last name is required for all attendees in Zoom events"))
-
- for attendee in attendees:
- first_name = (attendee.get("first_name") or "").strip()
- last_name = (attendee.get("last_name") or "").strip()
-
- if not first_name and attendee.get("full_name"):
- name_parts = attendee["full_name"].strip().split(" ", 1)
- first_name = name_parts[0]
- last_name = last_name or (name_parts[1] if len(name_parts) > 1 else "")
-
- attendee_full_name = f"{first_name} {last_name}".strip()
-
- add_ons = attendee.get("add_ons", None)
- if add_ons:
- add_ons = create_add_on_doc(
- attendee_name=attendee_full_name,
- add_ons=add_ons,
- )
-
- custom_fields = attendee.get("custom_fields", {})
- if custom_fields:
- validate_custom_fields(custom_fields, phone_map)
- attendee_row = {
- "first_name": first_name,
- "last_name": last_name,
- "email": attendee.get("email"),
- "ticket_type": attendee.get("ticket_type"),
- "add_ons": add_ons.name if add_ons else None,
- "custom_fields": custom_fields if custom_fields else None,
- }
-
- booking.append("attendees", attendee_row)
-
- booking.insert(ignore_permissions=True)
- frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit
-
- if booking.total_amount == 0:
- booking.flags.ignore_permissions = True
- booking.submit()
- return {
- "booking_name": booking.name,
- "redirect_to": f"/booking-success/{booking.name}?token={get_booking_access_token(booking.name)}",
- }
-
- if is_offline:
- method_filters = {"event": event, "enabled": 1}
- if offline_payment_method:
- method_filters["name"] = offline_payment_method
- method_doc = frappe.db.get_value(
- "Offline Payment Method", method_filters, ["name", "title"], as_dict=True
- )
- if not method_doc:
- frappe.throw(_("Offline payment is not enabled for this event"))
-
- booking.payment_method = OFFLINE_PAYMENT_METHOD
- booking.offline_payment_method = method_doc.title
-
- booking.status = "Approval Pending"
- booking.payment_status = "Verification Pending"
- booking.flags.ignore_permissions = True
- booking.save()
-
- if payment_proof:
- try:
- file_doc = frappe.get_doc(
- {
- "doctype": "File",
- "file_url": payment_proof,
- "attached_to_doctype": "Event Booking",
- "attached_to_name": booking.name,
- "is_private": 1,
- }
- )
- file_doc.insert(ignore_permissions=True)
- except Exception as e:
- frappe.log_error(f"Failed to attach payment proof: {e}")
-
- return {"booking_name": booking.name, "offline_payment": True}
-
- return {
- "payment_link": get_payment_link_for_booking(
- booking.name,
- redirect_to=f"/b/booking-success/{booking.name}?token={get_booking_access_token(booking.name)}",
- payment_gateway=payment_gateway,
- )
- }
-
-
-def get_booking_access_token(booking_name: str) -> str:
- """HMAC of the booking name signed with the site encryption_key.
-
- Lets a guest (whose browser session stays "Guest") open their own booking
- confirmation without logging in, while keeping sequential booking names
- unguessable (no IDOR)."""
- key = get_encryption_key().encode()
- return hmac.new(key, booking_name.encode(), hashlib.sha256).hexdigest()
-
-
-def verify_booking_access_token(booking_name: str, token: str | None) -> bool:
- return bool(token) and hmac.compare_digest(get_booking_access_token(booking_name), token)
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def get_booking_confirmation(booking_id: str, token: str | None = None) -> dict:
- """Read-only booking confirmation for the post-payment success page.
-
- Authorized by a valid access token (guest flow) OR read permission on the
- booking (logged-in owner). Returns a minimal payload — no transfer/cancel data.
- """
- # nosemgrep: frappe-semgrep-rules.rules.unchecked-frappe-permission-call -- return value checked below
- authorized = verify_booking_access_token(booking_id, token) or frappe.has_permission(
- "Event Booking", "read", doc=booking_id
- )
- if not authorized:
- frappe.throw(_("You are not allowed to view this booking."), frappe.PermissionError)
-
- booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
- event_doc = frappe.get_cached_doc("Buzz Event", booking_doc.event)
-
- tickets = frappe.db.get_all(
- "Event Ticket",
- filters={"booking": booking_id},
- fields=[
- "name",
- "attendee_name",
- "attendee_email",
- "ticket_type.title as ticket_type",
- "qr_code",
- ],
- )
-
- venue = None
- if event_doc.venue:
- venue_doc = frappe.get_cached_doc("Event Venue", event_doc.venue)
- venue = {"name": venue_doc.name, "address": venue_doc.get("address")}
-
- return frappe._dict(
- {
- "event": {
- "title": event_doc.title,
- "route": event_doc.route,
- "start_date": event_doc.start_date,
- "end_date": event_doc.end_date,
- "start_time": event_doc.start_time,
- "end_time": event_doc.end_time,
- "short_description": event_doc.get("short_description"),
- "free_event": event_doc.get("free_event"),
- },
- "venue": venue,
- "booking": {
- "name": booking_doc.name,
- "total_amount": booking_doc.total_amount,
- "net_amount": booking_doc.net_amount,
- "currency": booking_doc.currency,
- "payment_status": booking_doc.payment_status,
- "status": booking_doc.status,
- "tax_amount": booking_doc.tax_amount,
- "tax_label": booking_doc.tax_label,
- "tax_percentage": booking_doc.tax_percentage,
- "discount_amount": booking_doc.discount_amount,
- "coupon_code": booking_doc.coupon_code,
- },
- "tickets": tickets,
- }
- )
-
-
-def create_add_on_doc(attendee_name: str, add_ons: list[dict]):
- for add_on in add_ons:
- add_on["currency"] = frappe.db.get_value("Ticket Add-on", add_on["add_on"], "currency")
-
- return frappe.get_doc(
- {"doctype": "Attendee Ticket Add-on", "add_ons": add_ons, "attendee_name": attendee_name}
- ).insert(ignore_permissions=True)
-
-
-@frappe.whitelist()
-def transfer_ticket(ticket_id: str, new_first_name: str, new_last_name: str, new_email: str):
- if not frappe.db.exists("Event Ticket", ticket_id):
- frappe.throw(frappe._("Ticket not found."))
-
- ticket = frappe.get_doc("Event Ticket", ticket_id)
- booking_user = frappe.db.get_value("Event Booking", ticket.booking, "user")
-
- if (
- ticket.attendee_email != frappe.session.user
- and booking_user != frappe.session.user
- and not frappe.has_permission("Event Ticket", "write", ticket)
- ):
- frappe.throw(frappe._("Not permitted to transfer this ticket."))
-
- if not is_ticket_transfer_allowed(ticket.event):
- frappe.throw(frappe._("Ticket transfer is not allowed at this time. The transfer window has closed."))
-
- old_name = ticket.attendee_name
- old_email = ticket.attendee_email
- new_name = f"{new_first_name} {new_last_name}".strip()
-
- ticket.first_name = new_first_name
- ticket.last_name = new_last_name
- ticket.attendee_email = new_email
- ticket.save(ignore_permissions=True)
-
- send_ticket_transfer_emails(ticket.name, old_name, old_email, new_name, new_email)
-
-
-def send_ticket_transfer_emails(ticket_id: str, old_name: str, old_email: str, new_name: str, new_email: str):
- try:
- ticket = frappe.get_doc("Event Ticket", ticket_id)
- event = frappe.get_doc("Buzz Event", ticket.event)
- booking = frappe.get_doc("Event Booking", ticket.booking)
-
- old_attendee_subject = f"Your ticket for {event.title} has been transferred"
- old_attendee_message = f"""
-
Dear {old_name},
-
- This is to inform you that your ticket for {event.title} has been transferred to {new_name} ({new_email}).
-
- Event Details:
-
- - Event: {event.title}
- - Date: {format_date(event.start_date)}
- - Ticket Type: {ticket.ticket_type}
- - Booking ID: {booking.name}
-
-
- If you have any questions about this transfer, please contact us.
-
- Best regards,
- {event.title} Team
- """
-
- frappe.sendmail(
- recipients=[old_email], subject=old_attendee_subject, message=old_attendee_message, delayed=False
- )
-
- new_attendee_subject = f"Welcome! Your ticket for {event.title}"
- new_attendee_message = f"""
- Dear {new_name},
-
- Great news! A ticket for {event.title} has been transferred to you.
-
- Event Details:
-
- - Event: {event.title}
- - Date: {format_date(event.start_date)}
- - Time: {format_time(event.start_time) if event.start_time else "TBA"}
- - Venue: {event.venue or "TBA"}
- - Ticket Type: {ticket.ticket_type}
- - Booking ID: {booking.name}
-
-
- Your Ticket Details:
-
- - Ticket ID: {ticket.name}
- - Attendee Name: {new_name}
- - Email: {new_email}
-
-
- Please save this email for your records. You may need to present this ticket information at the event entrance.
-
- We look forward to seeing you at the event!
-
- Best regards,
- {event.title} Team
- """
-
- frappe.sendmail(
- recipients=[new_email], subject=new_attendee_subject, message=new_attendee_message, delayed=False
- )
-
- except Exception as e:
- frappe.log_error(f"Failed to send ticket transfer emails for ticket {ticket_id}: {e!s}")
-
-
-@frappe.whitelist()
-def get_booking_details(booking_id: str) -> dict:
- details = frappe._dict()
- booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
- details.doc = booking_doc
-
- tickets = frappe.db.get_all(
- "Event Ticket",
- filters={"booking": booking_id},
- fields=[
- "name",
- "attendee_name",
- "attendee_email",
- "ticket_type.title as ticket_type",
- "qr_code",
- "event",
- "docstatus",
- ],
- )
-
- add_ons = frappe.db.get_all(
- "Ticket Add-on Value",
- filters={"parent": ("in", [ticket.name for ticket in tickets])},
- fields=[
- "parent",
- "name",
- "add_on",
- "value",
- "add_on.title as add_on_title",
- "add_on.user_selects_option as user_selects_option",
- ],
- )
-
- event_add_ons = frappe.db.get_all(
- "Ticket Add-on",
- filters={"event": booking_doc.event, "user_selects_option": True},
- fields=["name", "title", "user_selects_option", "options"],
- )
-
- add_on_options_map = {}
- for event_add_on in event_add_ons:
- if event_add_on.user_selects_option:
- add_on_options_map[event_add_on.name] = (
- event_add_on.options.split("\n") if event_add_on.options else []
- )
-
- for ticket in tickets:
- ticket.add_ons = []
- for add_on in add_ons:
- if add_on.parent == ticket.name:
- add_on_data = {
- "id": add_on.name,
- "name": add_on.add_on,
- "title": add_on.add_on_title,
- "value": add_on.value,
- "user_selects_option": add_on.user_selects_option,
- "options": add_on_options_map.get(add_on.add_on, []),
- }
- ticket.add_ons.append(add_on_data)
- ticket.add_ons = sorted(ticket.add_ons, key=lambda x: x["title"])
-
- details.tickets = tickets
- details.event = frappe.get_cached_doc("Buzz Event", booking_doc.event)
-
- if details.event.venue:
- details.venue = frappe.get_cached_doc("Event Venue", details.event.venue)
-
- details.can_transfer_ticket = can_transfer_ticket(details.event.name)
- details.can_change_add_ons = can_change_add_ons(details.event.name)
- details.can_request_cancellation = can_request_cancellation(details.event.name)
-
- existing_cancellation = frappe.db.get_value(
- "Ticket Cancellation Request",
- {"booking": booking_id},
- ["name", "cancel_full_booking", "creation", "status", "docstatus"],
- as_dict=True,
- )
- details.cancellation_request = existing_cancellation
-
- details.cancellation_requested_tickets = []
-
- if existing_cancellation and existing_cancellation.docstatus == 0:
- if existing_cancellation.cancel_full_booking:
- details.cancellation_requested_tickets = [ticket.name for ticket in tickets]
- else:
- requested_tickets = frappe.db.get_all(
- "Ticket Cancellation Item", filters={"parent": existing_cancellation.name}, fields=["ticket"]
- )
- details.cancellation_requested_tickets = [item.ticket for item in requested_tickets]
-
- details.cancelled_tickets = [ticket.name for ticket in tickets if ticket.docstatus == 2]
-
- return details
-
-
-@frappe.whitelist()
-def change_add_on_preference(add_on_id: str, new_value: str):
- if not frappe.db.exists("Ticket Add-on Value", add_on_id):
- frappe.throw(frappe._("Add-on value not found."))
-
- add_on_value = frappe.get_cached_doc("Ticket Add-on Value", add_on_id)
- ticket = frappe.get_cached_doc("Event Ticket", add_on_value.parent)
-
- if not is_add_on_change_allowed(ticket.event):
- frappe.throw(
- frappe._(
- "Add-on changes are not allowed at this time. The change window has closed as the event is approaching."
- )
- )
-
- frappe.db.set_value(
- "Ticket Add-on Value",
- add_on_id,
- "value",
- new_value,
- )
-
-
-@frappe.whitelist()
-def get_sponsorship_details(enquiry_id: str) -> dict:
- enquiry = frappe.get_doc("Sponsorship Enquiry", enquiry_id)
-
- if enquiry.owner != frappe.session.user and not frappe.has_permission(
- "Sponsorship Enquiry", "read", enquiry
- ):
- frappe.throw(frappe._("Not permitted to view this sponsorship enquiry"))
-
- tier_title = ""
- if enquiry.tier:
- tier_title = frappe.db.get_value("Sponsorship Tier", enquiry.tier, "title") or enquiry.tier
-
- event_details = {}
- if enquiry.event:
- event = frappe.get_cached_doc("Buzz Event", enquiry.event)
- event_details = {
- "title": event.title,
- "short_description": getattr(event, "short_description", ""),
- "about": getattr(event, "about", ""),
- "start_date": event.start_date,
- "end_date": getattr(event, "end_date", ""),
- "venue": getattr(event, "venue", ""),
- "route": getattr(event, "route", ""),
- }
-
- sponsor_details = None
- sponsors = frappe.db.get_all(
- "Event Sponsor",
- filters={"enquiry": enquiry_id},
- fields=["name", "company_name", "company_logo", "creation", "event", "tier"],
- limit=1,
- )
-
- if sponsors:
- sponsor_details = sponsors[0]
- if sponsor_details.get("tier"):
- sponsor_tier_title = frappe.db.get_value("Sponsorship Tier", sponsor_details["tier"], "title")
- sponsor_details["tier_title"] = sponsor_tier_title or sponsor_details["tier"]
-
- return {
- "enquiry": {
- "name": enquiry.name,
- "company_name": enquiry.company_name,
- "company_logo": enquiry.company_logo,
- "event": enquiry.event,
- "tier": enquiry.tier,
- "tier_title": tier_title,
- "status": enquiry.status,
- "creation": enquiry.creation,
- "owner": enquiry.owner,
- },
- "event_details": event_details,
- "sponsor_details": sponsor_details,
- "has_sponsor": bool(sponsor_details),
- }
-
-
-@frappe.whitelist()
-def get_user_sponsorship_inquiries() -> list:
- inquiries = frappe.db.get_all(
- "Sponsorship Enquiry",
- filters={"owner": frappe.session.user},
- fields=["name", "company_name", "event", "tier", "status", "creation"],
- order_by="creation desc",
- )
-
- for inquiry in inquiries:
- if inquiry.event:
- event_title = frappe.db.get_value("Buzz Event", inquiry.event, "title")
- inquiry["event_title"] = event_title
-
- if inquiry.tier:
- tier_title = frappe.db.get_value("Sponsorship Tier", inquiry.tier, "title")
- inquiry["tier_title"] = tier_title or inquiry.tier
- else:
- inquiry["tier_title"] = ""
-
- inquiry_names = [inquiry.name for inquiry in inquiries]
- if inquiry_names:
- sponsors = frappe.db.get_all(
- "Event Sponsor",
- filters={"enquiry": ["in", inquiry_names]},
- fields=["enquiry"],
- )
- sponsored_inquiries = {sponsor.enquiry for sponsor in sponsors}
-
- for inquiry in inquiries:
- inquiry["has_sponsor"] = inquiry.name in sponsored_inquiries
- else:
- for inquiry in inquiries:
- inquiry["has_sponsor"] = False
-
- return inquiries
-
-
-@frappe.whitelist()
-def create_sponsorship_payment_link(enquiry_id: str, tier_id: str, payment_gateway: str | None = None) -> str:
- enquiry = frappe.get_doc("Sponsorship Enquiry", enquiry_id)
- if enquiry.owner != frappe.session.user:
- frappe.throw(frappe._("Not permitted to create payment for this enquiry"))
-
- redirect_url = f"/b/account/sponsorships/{enquiry_id}?success=true"
- return get_payment_link_for_sponsorship(
- enquiry_id, tier_id, redirect_url, payment_gateway=payment_gateway
- )
-
-
-@frappe.whitelist()
-def withdraw_sponsorship_enquiry(enquiry_id: str):
- enquiry = frappe.get_cached_doc("Sponsorship Enquiry", enquiry_id)
- if enquiry.owner != frappe.session.user:
- frappe.throw(frappe._("Not permitted to withdraw this enquiry"))
-
- if enquiry.status == "Paid":
- frappe.throw(frappe._("Cannot withdraw a paid sponsorship enquiry"))
-
- if enquiry.status == "Withdrawn":
- frappe.throw(frappe._("This sponsorship enquiry has already been withdrawn"))
-
- enquiry.status = "Withdrawn"
- enquiry.save(ignore_permissions=True)
-
-
-@frappe.whitelist()
-def get_ticket_details(ticket_id: str) -> dict:
- details = frappe._dict()
- ticket_doc = frappe.get_cached_doc("Event Ticket", ticket_id)
-
- if frappe.session.user != "Administrator":
- if ticket_doc.attendee_email != frappe.session.user:
- frappe.throw(frappe._("Not permitted to view this ticket"))
-
- details.doc = ticket_doc
-
- add_ons = frappe.db.get_all(
- "Ticket Add-on Value",
- filters={"parent": ticket_id},
- fields=[
- "name",
- "add_on",
- "add_on.title as add_on_title",
- "value",
- "price",
- "currency",
- "add_on.user_selects_option as user_selects_option",
- ],
- )
-
- event_add_ons = frappe.db.get_all(
- "Ticket Add-on",
- filters={"event": ticket_doc.event, "user_selects_option": True},
- fields=["name", "title", "user_selects_option", "options"],
- )
-
- add_on_options_map = {}
- for event_add_on in event_add_ons:
- if event_add_on.user_selects_option:
- add_on_options_map[event_add_on.name] = (
- event_add_on.options.split("\n") if event_add_on.options else []
- )
-
- enhanced_add_ons = []
- for add_on in add_ons:
- add_on_data = {
- "id": add_on.name,
- "name": add_on.add_on,
- "title": add_on.add_on_title,
- "value": add_on.value,
- "price": add_on.price,
- "currency": add_on.currency,
- "user_selects_option": add_on.user_selects_option,
- "options": add_on_options_map.get(add_on.add_on, []),
- }
- enhanced_add_ons.append(add_on_data)
-
- details.add_ons = enhanced_add_ons
- details.event = frappe.get_cached_doc("Buzz Event", ticket_doc.event)
-
- booking_doc = None
- if ticket_doc.booking:
- booking_doc = frappe.get_cached_doc("Event Booking", ticket_doc.booking)
- if booking_doc.owner == frappe.session.user:
- details.booking = booking_doc
- else:
- details.booking = None
- else:
- details.booking = None
-
- details.ticket_type = frappe.get_cached_doc("Event Ticket Type", ticket_doc.ticket_type)
- details.can_transfer_ticket = (
- can_transfer_ticket(details.event.name) if details.event else {"can_transfer": False}
- )
- details.can_change_add_ons = (
- can_change_add_ons(details.event.name) if details.event else {"can_change_add_ons": False}
- )
- details.can_request_cancellation = (
- can_request_cancellation(details.event.name) if details.event else {"can_request_cancellation": False}
- )
-
- details.zoom_join_url = None
- if hasattr(ticket_doc, "zoom_session_registration") and ticket_doc.zoom_session_registration:
- zoom_registration = frappe.db.get_value(
- "Zoom Session Registration",
- ticket_doc.zoom_session_registration,
- ["join_url", "reference_doctype", "reference_name"],
- as_dict=True,
- )
- if zoom_registration:
- details.zoom_join_url = zoom_registration.join_url
- details.zoom_reference_doctype = zoom_registration.reference_doctype
- details.zoom_reference_name = zoom_registration.reference_name
-
- return details
-
-
-@frappe.whitelist()
-def create_cancellation_request(booking_id: str, ticket_ids: list | None = None) -> dict:
- booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
-
- if booking_doc.user != frappe.session.user and not frappe.has_permission(
- "Event Booking", "write", booking_doc
- ):
- frappe.throw(frappe._("Not permitted to request cancellation for this booking."))
-
- if not is_cancellation_request_allowed(booking_doc.event):
- frappe.throw(_("Cancellation requests are no longer allowed for this event."))
-
- existing_request = frappe.db.exists(
- "Ticket Cancellation Request", {"booking": booking_id, "docstatus": 0}
- )
- if existing_request:
- frappe.throw(_("A cancellation request already exists for this booking."))
-
- all_tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking_id}, fields=["name"])
- cancel_full_booking = not ticket_ids or len(ticket_ids) == len(all_tickets)
-
- cancellation_request = frappe.new_doc("Ticket Cancellation Request")
- cancellation_request.booking = booking_id
- cancellation_request.cancel_full_booking = cancel_full_booking
-
- if not cancel_full_booking and ticket_ids:
- for ticket_id in ticket_ids:
- ticket_booking = frappe.db.get_value("Event Ticket", ticket_id, "booking")
- if ticket_booking != booking_id:
- frappe.throw(f"Ticket {ticket_id} does not belong to booking {booking_id}")
-
- cancellation_request.append("tickets", {"ticket": ticket_id})
-
- cancellation_request.insert(ignore_permissions=True)
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def get_user_info() -> dict:
- if frappe.session.user == "Guest":
- return {
- "is_logged_in": False,
- "brand_image": frappe.get_cached_value("Website Settings", "Website Settings", "banner_image"),
- }
-
- user = frappe.get_cached_doc("User", frappe.session.user)
-
- return {
- "name": user.name,
- "is_logged_in": True,
- "first_name": user.first_name,
- "last_name": user.last_name,
- "full_name": user.full_name,
- "email": user.email,
- "user_image": user.user_image,
- "roles": user.roles,
- "brand_image": frappe.get_single_value("Website Settings", "banner_image"),
- "language": user.language,
- }
-
-
-@frappe.whitelist()
-def validate_ticket_for_checkin(ticket_id: str) -> dict:
- frappe.only_for("Frontdesk Manager", True)
- if not frappe.db.exists("Event Ticket", ticket_id):
- frappe.throw(_("Ticket not found"))
-
- ticket_doc = frappe.get_cached_doc("Event Ticket", ticket_id)
-
- if ticket_doc.docstatus == 2:
- frappe.throw(_("This ticket has been cancelled and cannot be checked in"))
-
- event_doc = frappe.get_cached_doc("Buzz Event", ticket_doc.event)
- ticket_type_doc = (
- frappe.get_cached_doc("Event Ticket Type", ticket_doc.ticket_type) if ticket_doc.ticket_type else None
- )
-
- checkin_date = frappe.utils.today()
- existing_checkin = frappe.db.exists("Event Check In", {"ticket": ticket_id, "date": checkin_date})
-
- if existing_checkin:
- checkin_doc = frappe.get_doc("Event Check In", existing_checkin)
- formatted_checkin_time = (
- format_date(checkin_doc.creation) + " at " + format_time(checkin_doc.creation)
- )
-
- frappe.throw(_("This ticket was already checked in today ({0}).").format(formatted_checkin_time))
-
- add_ons = frappe.db.get_all(
- "Ticket Add-on Value",
- filters={"parent": ticket_id},
- fields=[
- "add_on",
- "add_on.title as add_on_title",
- "add_on.user_selects_option as add_on_selects_option",
- "value",
- "price",
- "currency",
- ],
- )
-
- return {
- "message": _("Valid ticket ready for check-in"),
- "ticket": {
- "id": ticket_doc.name,
- "attendee_name": ticket_doc.attendee_name,
- "attendee_email": ticket_doc.attendee_email,
- "event_title": event_doc.title,
- "ticket_type": (ticket_type_doc.title if ticket_type_doc else ticket_doc.ticket_type),
- "venue": event_doc.venue,
- "start_date": event_doc.start_date,
- "start_time": event_doc.start_time,
- "end_date": event_doc.end_date,
- "end_time": event_doc.end_time,
- "is_checked_in": False,
- "check_in_time": None,
- "booking_id": ticket_doc.booking,
- "add_ons": add_ons,
- },
- "payment_details": get_payment_details_for_ticket(ticket_id),
- }
-
-
-def get_payment_details_for_ticket(ticket_id: str) -> dict | None:
- booking_id = frappe.get_cached_value("Event Ticket", ticket_id, "booking")
- if not booking_id:
- return None
-
- payments = frappe.db.get_all(
- "Event Payment",
- filters={
- "reference_doctype": "Event Booking",
- "reference_docname": booking_id,
- "payment_received": 1,
- },
- fields=["name", "amount", "currency"],
- limit=1,
- )
-
- if payments:
- return payments[0]
-
-
-@frappe.whitelist()
-def checkin_ticket(ticket_id: str) -> dict:
- frappe.only_for("Frontdesk Manager", True)
-
- checkin_date = frappe.utils.today()
- validation_result = validate_ticket_for_checkin(ticket_id)
-
- checkin_doc = frappe.new_doc("Event Check In")
- checkin_doc.ticket = ticket_id
- checkin_doc.date = checkin_date
- checkin_doc.insert(ignore_permissions=True)
- checkin_doc.submit()
-
- return {
- "message": _("Successfully checked in {attendee_name} for {checkin_date}").format(
- attendee_name=validation_result["ticket"]["attendee_name"],
- checkin_date=frappe.format(checkin_date, {"fieldtype": "Date"}),
- ),
- "ticket": {
- **validation_result["ticket"],
- "is_checked_in": True,
- "check_in_time": checkin_doc.creation,
- "check_in_date": checkin_date,
- },
- }
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def get_enabled_languages():
- languages = frappe.get_all(
- "Language",
- filters={"enabled": 1},
- fields=["name", "language_name", "language_code"],
- order_by="language_name",
- )
- return languages
-
-
-@frappe.whitelist()
-def update_user_language(language_code: str):
- if not frappe.db.exists("Language", {"language_code": language_code}):
- frappe.throw(_("Invalid language"))
-
- frappe.db.set_value("User", frappe.session.user, "language", language_code)
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def get_translations():
- if frappe.session.user != "Guest":
- language = frappe.db.get_value("User", frappe.session.user, "language")
- else:
- language = frappe.db.get_single_value("System Settings", "language")
-
- return get_all_translations(language)
-
-
-def has_app_permission():
- return True
-
-
-@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
-def validate_coupon(coupon_code: str, event: str, user_email: str | None = None) -> dict:
- if not frappe.db.exists("Buzz Coupon Code", coupon_code):
- return {"valid": False, "error": _("Invalid coupon code")}
-
- coupon = frappe.get_doc("Buzz Coupon Code", coupon_code)
-
- is_valid, error = coupon.is_valid_for_event(event)
- if not is_valid:
- return {"valid": False, "error": error}
-
- is_available, error = coupon.is_usage_available()
- if not is_available:
- return {"valid": False, "error": error}
-
- if frappe.session.user == "Guest":
- check_user = user_email.lower().strip() if user_email else None
- else:
- check_user = frappe.session.user
- is_limited, error = coupon.is_user_limit_reached(user=check_user)
- if is_limited:
- return {"valid": False, "error": error}
-
- if coupon.coupon_type == "Discount":
- return {
- "valid": True,
- "coupon_type": "Discount",
- "discount_type": coupon.discount_type,
- "discount_value": coupon.discount_value,
- "max_discount_amount": coupon.maximum_discount_amount or 0,
- "min_order_value": coupon.minimum_order_value or 0,
- }
-
- remaining = coupon.number_of_free_tickets - coupon.free_tickets_claimed
- if remaining <= 0:
- return {"valid": False, "error": _("All free tickets have been claimed")}
-
- return {
- "valid": True,
- "coupon_type": "Free Tickets",
- "ticket_type": coupon.ticket_type,
- "remaining_tickets": remaining,
- "free_add_ons": [a.add_on for a in coupon.free_add_ons],
- }
-
-
-@frappe.whitelist()
-def get_campaign_details(campaign: str):
- if not frappe.db.exists("Buzz Campaign", campaign):
- frappe.throw(_("Campaign not found"), frappe.DoesNotExistError)
-
- campaign_doc = frappe.get_cached_doc("Buzz Campaign", campaign)
-
- if not campaign_doc.enabled:
- frappe.throw(_("This campaign is not active"))
-
- return {
- "title": campaign_doc.title,
- "description": campaign_doc.description,
- "event": campaign_doc.event,
- }
-
-
-@frappe.whitelist()
-def register_campaign_interest(campaign: str):
- if frappe.session.user == "Guest":
- frappe.throw(_("Please login to register your interest"))
-
- if not is_app_installed("crm"):
- frappe.throw(_("CRM integration is not available"))
-
- if not frappe.db.exists("Buzz Campaign", campaign):
- frappe.throw(_("Campaign not found"), frappe.DoesNotExistError)
-
- campaign_doc = frappe.get_cached_doc("Buzz Campaign", campaign)
-
- user = frappe.get_cached_doc("User", frappe.session.user)
- first_name = user.first_name or user.full_name or frappe.session.user.split("@")[0]
-
- existing_lead = frappe.db.exists(
- "CRM Lead",
- {"email": frappe.session.user, "buzz_campaign": campaign},
- )
- if existing_lead:
- frappe.throw(_("You have already registered for this campaign"))
-
- ticket = None
- if campaign_doc.event:
- ticket = frappe.db.get_value(
- "Event Ticket",
- {
- "attendee_email": frappe.session.user,
- "event": campaign_doc.event,
- "docstatus": 1,
- },
- "name",
- )
-
- lead = frappe.get_doc(
- {
- "doctype": "CRM Lead",
- "first_name": first_name,
- "email": frappe.session.user,
- "status": "New",
- "buzz_campaign": campaign,
- "event_ticket": ticket,
- }
- )
- lead.insert(ignore_permissions=True)
diff --git a/buzz/api/account/__init__.py b/buzz/api/account/__init__.py
new file mode 100644
index 00000000..b53e281f
--- /dev/null
+++ b/buzz/api/account/__init__.py
@@ -0,0 +1,60 @@
+import frappe
+from frappe import _
+from frappe.translate import get_all_translations
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_user_info() -> dict:
+ if frappe.session.user == "Guest":
+ return {
+ "is_logged_in": False,
+ "brand_image": frappe.get_cached_value("Website Settings", "Website Settings", "banner_image"),
+ }
+
+ user = frappe.get_cached_doc("User", frappe.session.user)
+
+ return {
+ "name": user.name,
+ "is_logged_in": True,
+ "first_name": user.first_name,
+ "last_name": user.last_name,
+ "full_name": user.full_name,
+ "email": user.email,
+ "user_image": user.user_image,
+ "roles": user.roles,
+ "brand_image": frappe.get_single_value("Website Settings", "banner_image"),
+ "language": user.language,
+ }
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_enabled_languages():
+ languages = frappe.get_all(
+ "Language",
+ filters={"enabled": 1},
+ fields=["name", "language_name", "language_code"],
+ order_by="language_name",
+ )
+ return languages
+
+
+@frappe.whitelist()
+def update_user_language(language_code: str):
+ if not frappe.db.exists("Language", {"language_code": language_code}):
+ frappe.throw(_("Invalid language"))
+
+ frappe.db.set_value("User", frappe.session.user, "language", language_code)
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_translations():
+ if frappe.session.user != "Guest":
+ language = frappe.db.get_value("User", frappe.session.user, "language")
+ else:
+ language = frappe.db.get_single_value("System Settings", "language")
+
+ return get_all_translations(language)
+
+
+def has_app_permission():
+ return True
diff --git a/buzz/api/auth.py b/buzz/api/auth/__init__.py
similarity index 100%
rename from buzz/api/auth.py
rename to buzz/api/auth/__init__.py
diff --git a/buzz/api/booking/__init__.py b/buzz/api/booking/__init__.py
new file mode 100644
index 00000000..948fd517
--- /dev/null
+++ b/buzz/api/booking/__init__.py
@@ -0,0 +1,679 @@
+import hashlib
+import hmac
+import os
+from base64 import b32encode
+
+import frappe
+import pyotp
+from frappe import _
+from frappe.auth import LoginAttemptTracker
+from frappe.core.doctype.sms_settings.sms_settings import send_sms
+from frappe.rate_limiter import rate_limit
+from frappe.utils import (
+ get_datetime,
+ get_datetime_in_timezone,
+ get_system_timezone,
+ validate_email_address,
+)
+from frappe.utils.password import get_encryption_key
+
+from buzz.api.tickets import (
+ can_change_add_ons,
+ can_request_cancellation,
+ can_transfer_ticket,
+ create_add_on_doc,
+)
+from buzz.payments import get_payment_gateways_for_event, get_payment_link_for_booking
+from buzz.utils import ZOOM_BACKED_CATEGORIES, build_event_datetimes
+
+OFFLINE_PAYMENT_METHOD = "Offline"
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+@rate_limit(key="identifier", limit=5, seconds=3600)
+def send_guest_booking_otp(event: int, identifier: str) -> dict:
+ event_doc = frappe.get_cached_doc("Buzz Event", event)
+
+ if not event_doc.allow_guest_booking:
+ frappe.throw(_("Guest booking is not enabled for this event"))
+
+ if event_doc.guest_verification_method == "None":
+ frappe.throw(_("OTP verification is not enabled for this event"))
+
+ channel = "phone" if event_doc.guest_verification_method == "Phone OTP" else "email"
+
+ identifier = identifier.strip()
+ if not identifier:
+ frappe.throw(_("Email or phone is required"))
+
+ if channel == "email":
+ identifier = identifier.lower()
+ validate_email_address(identifier, throw=True)
+
+ otp_secret = b32encode(os.urandom(10)).decode("utf-8")
+ otp_code = pyotp.HOTP(otp_secret).at(0)
+ cache_key = f"guest_booking_otp:{channel}:{identifier}"
+
+ if frappe.in_test:
+ frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
+ return {"otp": otp_code}
+
+ try:
+ if channel == "email":
+ frappe.sendmail(
+ recipients=[identifier],
+ subject=_("Your Booking Verification Code"),
+ message=_(
+ "Your verification code is: {0}
This code expires in 10 minutes."
+ ).format(otp_code),
+ now=True,
+ )
+ else:
+ send_sms(
+ receiver_list=[identifier],
+ msg=_("Your booking verification code is: {0}. It expires in 10 minutes.").format(otp_code),
+ )
+ except Exception:
+ frappe.throw(_("Failed to send verification code. Please try again."))
+
+ frappe.cache.set_value(cache_key, otp_secret, expires_in_sec=600)
+
+
+def verify_guest_otp(channel: str, identifier: str, otp: str):
+ cache_key = f"guest_booking_otp:{channel}:{identifier}"
+ tracker = LoginAttemptTracker(
+ key=f"guest_otp:{channel}:{identifier}",
+ max_consecutive_login_attempts=5,
+ lock_interval=600,
+ )
+
+ if not tracker.is_user_allowed():
+ frappe.throw(_("Too many failed attempts. Please try again later."))
+
+ otp_secret = frappe.cache.get_value(cache_key)
+ if not otp_secret:
+ frappe.throw(_("Verification code expired. Please request a new one."))
+
+ if not pyotp.HOTP(otp_secret).verify(otp.strip(), 0):
+ tracker.add_failure_attempt()
+ frappe.throw(_("Invalid verification code"))
+
+ frappe.cache.delete_value(cache_key)
+ tracker.add_success_attempt()
+
+
+def get_or_create_guest_user(email: str, full_name: str) -> str:
+ email = email.lower().strip()
+
+ validate_email_address(email, throw=True)
+ if frappe.db.exists("User", email):
+ return email
+
+ name_parts = full_name.strip().split(" ", 1)
+ first_name = name_parts[0] if name_parts else "Guest"
+ last_name = name_parts[1] if len(name_parts) > 1 else ""
+
+ user = frappe.get_doc(
+ {
+ "doctype": "User",
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "enabled": 1,
+ "user_type": "Website User",
+ "send_welcome_email": 0,
+ }
+ )
+ user.insert(ignore_permissions=True)
+
+ return email
+
+
+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)
+
+ if event_doc.registrations_close_at:
+ return current_datetime_in_event_timezone > get_datetime(event_doc.registrations_close_at)
+
+ # No explicit cutoff set - registrations close once the event itself has ended.
+ _, event_end_datetime = build_event_datetimes(event_doc)
+ return current_datetime_in_event_timezone > event_end_datetime
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_event_booking_data(event_route: str) -> dict:
+ data = frappe._dict()
+ event_doc = frappe.get_cached_doc("Buzz Event", {"route": event_route})
+
+ if not event_doc.is_published:
+ frappe.throw(_("Event not found"), frappe.DoesNotExistError)
+
+ data.registrations_closed = are_registrations_closed(event_doc)
+
+ is_guest = frappe.session.user == "Guest"
+ if is_guest:
+ data.event_details = {
+ "name": event_doc.name,
+ "title": event_doc.title,
+ "route": event_doc.route,
+ "start_date": event_doc.start_date,
+ "end_date": event_doc.end_date,
+ "start_time": event_doc.start_time,
+ "end_time": event_doc.end_time,
+ "time_zone": event_doc.time_zone,
+ "time_zone_label": event_doc.time_zone_label,
+ "venue": event_doc.venue,
+ "medium": event_doc.medium,
+ "category": event_doc.category,
+ "banner_image": event_doc.banner_image,
+ "short_description": event_doc.short_description,
+ "free_event": event_doc.free_event,
+ "send_ticket_email": event_doc.send_ticket_email,
+ "allow_guest_booking": event_doc.allow_guest_booking,
+ "guest_verification_method": event_doc.guest_verification_method,
+ "default_ticket_type": event_doc.default_ticket_type,
+ }
+ else:
+ data.event_details = event_doc
+
+ available_ticket_types = []
+ published_ticket_types = frappe.db.get_all(
+ "Event Ticket Type", filters={"is_published": True, "event": event_doc.name}, pluck="name"
+ )
+ for ticket_type in published_ticket_types:
+ tt = frappe.get_cached_doc("Event Ticket Type", ticket_type)
+ if tt.are_tickets_available(1):
+ available_ticket_types.append(tt)
+ data.available_ticket_types = available_ticket_types
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on", filters={"event": event_doc.name, "enabled": 1}, fields=["*"], order_by="title"
+ )
+
+ for add_on in add_ons:
+ if add_on.user_selects_option:
+ add_on.options = add_on.options.split("\n")
+
+ data.available_add_ons = add_ons
+
+ data.tax_settings = {
+ "apply_tax": event_doc.apply_tax,
+ "tax_inclusive": event_doc.tax_inclusive,
+ "tax_label": event_doc.tax_label or "Tax",
+ "tax_percentage": event_doc.tax_percentage or 0,
+ }
+
+ custom_fields = frappe.db.get_all(
+ "Buzz Custom Field",
+ filters={"event": event_doc.name, "enabled": 1},
+ fields=["*"],
+ order_by="order",
+ )
+ data.custom_fields = custom_fields
+
+ payment_gateways = get_payment_gateways_for_event(event_doc.name)
+
+ offline_methods_raw = frappe.get_all(
+ "Offline Payment Method",
+ filters={"event": event_doc.name, "enabled": 1},
+ fields=["name", "title", "description", "collect_payment_proof"],
+ order_by="creation",
+ )
+
+ offline_methods = []
+ for method in offline_methods_raw:
+ method_custom_fields = frappe.get_all(
+ "Buzz Custom Field",
+ filters={
+ "event": event_doc.name,
+ "enabled": 1,
+ "applied_to": "Offline Payment Form",
+ "offline_payment_method": method.name,
+ },
+ fields=["*"],
+ order_by="order",
+ )
+ offline_methods.append(
+ {
+ "name": method.name,
+ "title": method.title,
+ "description": method.description,
+ "collect_payment_proof": method.collect_payment_proof,
+ "custom_fields": method_custom_fields,
+ }
+ )
+ payment_gateways.append(method.title)
+
+ data.payment_gateways = payment_gateways
+ data.offline_payment_enabled = len(offline_methods) > 0
+ data.offline_methods = offline_methods
+
+ return data
+
+
+def validate_custom_fields(custom_fields_data: dict, phone_field_map: dict) -> None:
+ for field_name, field_value in custom_fields_data.items():
+ if field_value and field_name in phone_field_map:
+ frappe.utils.validate_phone_number_with_country_code(
+ str(field_value), phone_field_map[field_name]
+ )
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def process_booking(
+ attendees: list[dict],
+ event: str,
+ coupon_code: str | None = None,
+ booking_custom_fields: dict | None = None,
+ payment_gateway: str | None = None,
+ utm_parameters: list[dict] | None = None,
+ guest_email: str | None = None,
+ guest_full_name: str | None = None,
+ otp: str | None = None,
+ guest_phone: str | None = None,
+ payment_proof: str | None = None,
+ is_offline: bool = False,
+ offline_payment_method: str | None = None,
+ invoice_requested: bool = False,
+ tax_id: str | None = None,
+ billing_address: str | None = None,
+) -> dict:
+ event_doc = frappe.get_cached_doc("Buzz Event", event)
+ if not event_doc.is_published:
+ frappe.throw(_("Event is not live"))
+
+ if are_registrations_closed(event_doc):
+ frappe.throw(_("Registrations for this event are closed"))
+
+ is_guest = frappe.session.user == "Guest"
+
+ if is_guest:
+ if not event_doc.allow_guest_booking:
+ frappe.throw(_("Please log in to access this feature"), frappe.AuthenticationError)
+
+ if not guest_email:
+ frappe.throw(_("Email is required for guest booking"))
+
+ validate_email_address(guest_email, throw=True)
+ email = guest_email.lower().strip()
+
+ if event_doc.guest_verification_method == "Email OTP":
+ if not otp:
+ frappe.throw(_("Verification code is required"))
+ verify_guest_otp("email", email, otp)
+
+ elif event_doc.guest_verification_method == "Phone OTP":
+ if not otp:
+ frappe.throw(_("Verification code is required"))
+ if not guest_phone:
+ frappe.throw(_("Phone number is required"))
+ verify_guest_otp("phone", guest_phone.strip(), otp)
+
+ first_name = (attendees[0].get("first_name") or "").strip()
+ last_name = (attendees[0].get("last_name") or "").strip()
+ full_name = (guest_full_name or "").strip() or f"{first_name} {last_name}".strip()
+ if not full_name:
+ frappe.throw(_("Full name is required for guest booking"))
+ booking_user = get_or_create_guest_user(guest_email, full_name)
+ else:
+ booking_user = frappe.session.user
+
+ booking = frappe.new_doc("Event Booking")
+ booking.event = event
+ booking.coupon_code = coupon_code
+ booking.user = booking_user
+
+ if event_doc.apply_tax and invoice_requested:
+ booking.invoice_requested = 1
+ booking.tax_id = tax_id
+ booking.billing_address = billing_address
+
+ if utm_parameters:
+ for utm_param in utm_parameters:
+ booking.append(
+ "utm_parameters",
+ {
+ "utm_name": utm_param.get("utm_name"),
+ "value": utm_param.get("value"),
+ },
+ )
+
+ if booking_custom_fields:
+ booking_custom_field_defs = frappe.db.get_all(
+ "Buzz Custom Field",
+ filters={"event": event, "enabled": 1, "applied_to": "Booking"},
+ fields=["fieldname", "label", "fieldtype"],
+ )
+ custom_field_map = {cf["fieldname"]: cf for cf in booking_custom_field_defs}
+
+ for field_name, field_value in booking_custom_fields.items():
+ if field_value and field_name in custom_field_map:
+ field_def = custom_field_map[field_name]
+ booking.append(
+ "additional_fields",
+ {
+ "fieldname": field_name,
+ "value": str(field_value),
+ "label": field_def["label"],
+ "fieldtype": field_def["fieldtype"],
+ },
+ )
+
+ phone_fields = frappe.db.get_all(
+ "Buzz Custom Field",
+ filters={"event": event, "enabled": 1, "fieldtype": "Phone"},
+ fields=["fieldname", "label"],
+ )
+ phone_map = {cf["fieldname"]: cf["label"] for cf in phone_fields}
+
+ if event_doc.category in ZOOM_BACKED_CATEGORIES:
+ for attendee in attendees:
+ if not (attendee.get("last_name") or "").strip():
+ frappe.throw(_("Last name is required for all attendees in Zoom events"))
+
+ for attendee in attendees:
+ first_name = (attendee.get("first_name") or "").strip()
+ last_name = (attendee.get("last_name") or "").strip()
+
+ if not first_name and attendee.get("full_name"):
+ name_parts = attendee["full_name"].strip().split(" ", 1)
+ first_name = name_parts[0]
+ last_name = last_name or (name_parts[1] if len(name_parts) > 1 else "")
+
+ attendee_full_name = f"{first_name} {last_name}".strip()
+
+ add_ons = attendee.get("add_ons", None)
+ if add_ons:
+ add_ons = create_add_on_doc(
+ attendee_name=attendee_full_name,
+ add_ons=add_ons,
+ )
+
+ custom_fields = attendee.get("custom_fields", {})
+ if custom_fields:
+ validate_custom_fields(custom_fields, phone_map)
+ attendee_row = {
+ "first_name": first_name,
+ "last_name": last_name,
+ "email": attendee.get("email"),
+ "ticket_type": attendee.get("ticket_type"),
+ "add_ons": add_ons.name if add_ons else None,
+ "custom_fields": custom_fields if custom_fields else None,
+ }
+
+ booking.append("attendees", attendee_row)
+
+ booking.insert(ignore_permissions=True)
+ frappe.db.commit() # nosemgrep: frappe-semgrep-rules.rules.frappe-manual-commit
+
+ if booking.total_amount == 0:
+ booking.flags.ignore_permissions = True
+ booking.submit()
+ return {
+ "booking_name": booking.name,
+ "redirect_to": f"/booking-success/{booking.name}?token={get_booking_access_token(booking.name)}",
+ }
+
+ if is_offline:
+ method_filters = {"event": event, "enabled": 1}
+ if offline_payment_method:
+ method_filters["name"] = offline_payment_method
+ method_doc = frappe.db.get_value(
+ "Offline Payment Method", method_filters, ["name", "title"], as_dict=True
+ )
+ if not method_doc:
+ frappe.throw(_("Offline payment is not enabled for this event"))
+
+ booking.payment_method = OFFLINE_PAYMENT_METHOD
+ booking.offline_payment_method = method_doc.title
+
+ booking.status = "Approval Pending"
+ booking.payment_status = "Verification Pending"
+ booking.flags.ignore_permissions = True
+ booking.save()
+
+ if payment_proof:
+ try:
+ file_doc = frappe.get_doc(
+ {
+ "doctype": "File",
+ "file_url": payment_proof,
+ "attached_to_doctype": "Event Booking",
+ "attached_to_name": booking.name,
+ "is_private": 1,
+ }
+ )
+ file_doc.insert(ignore_permissions=True)
+ except Exception as e:
+ frappe.log_error(f"Failed to attach payment proof: {e}")
+
+ return {"booking_name": booking.name, "offline_payment": True}
+
+ return {
+ "payment_link": get_payment_link_for_booking(
+ booking.name,
+ redirect_to=f"/b/booking-success/{booking.name}?token={get_booking_access_token(booking.name)}",
+ payment_gateway=payment_gateway,
+ )
+ }
+
+
+def get_booking_access_token(booking_name: str) -> str:
+ """HMAC of the booking name signed with the site encryption_key.
+
+ Lets a guest (whose browser session stays "Guest") open their own booking
+ confirmation without logging in, while keeping sequential booking names
+ unguessable (no IDOR)."""
+ key = get_encryption_key().encode()
+ return hmac.new(key, booking_name.encode(), hashlib.sha256).hexdigest()
+
+
+def verify_booking_access_token(booking_name: str, token: str | None) -> bool:
+ return bool(token) and hmac.compare_digest(get_booking_access_token(booking_name), token)
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def get_booking_confirmation(booking_id: str, token: str | None = None) -> dict:
+ """Read-only booking confirmation for the post-payment success page.
+
+ Authorized by a valid access token (guest flow) OR read permission on the
+ booking (logged-in owner). Returns a minimal payload — no transfer/cancel data.
+ """
+ # nosemgrep: frappe-semgrep-rules.rules.unchecked-frappe-permission-call -- return value checked below
+ authorized = verify_booking_access_token(booking_id, token) or frappe.has_permission(
+ "Event Booking", "read", doc=booking_id
+ )
+ if not authorized:
+ frappe.throw(_("You are not allowed to view this booking."), frappe.PermissionError)
+
+ booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
+ event_doc = frappe.get_cached_doc("Buzz Event", booking_doc.event)
+
+ tickets = frappe.db.get_all(
+ "Event Ticket",
+ filters={"booking": booking_id},
+ fields=[
+ "name",
+ "attendee_name",
+ "attendee_email",
+ "ticket_type.title as ticket_type",
+ "qr_code",
+ ],
+ )
+
+ venue = None
+ if event_doc.venue:
+ venue_doc = frappe.get_cached_doc("Event Venue", event_doc.venue)
+ venue = {"name": venue_doc.name, "address": venue_doc.get("address")}
+
+ return frappe._dict(
+ {
+ "event": {
+ "title": event_doc.title,
+ "route": event_doc.route,
+ "start_date": event_doc.start_date,
+ "end_date": event_doc.end_date,
+ "start_time": event_doc.start_time,
+ "end_time": event_doc.end_time,
+ "short_description": event_doc.get("short_description"),
+ "free_event": event_doc.get("free_event"),
+ },
+ "venue": venue,
+ "booking": {
+ "name": booking_doc.name,
+ "total_amount": booking_doc.total_amount,
+ "net_amount": booking_doc.net_amount,
+ "currency": booking_doc.currency,
+ "payment_status": booking_doc.payment_status,
+ "status": booking_doc.status,
+ "tax_amount": booking_doc.tax_amount,
+ "tax_label": booking_doc.tax_label,
+ "tax_percentage": booking_doc.tax_percentage,
+ "discount_amount": booking_doc.discount_amount,
+ "coupon_code": booking_doc.coupon_code,
+ },
+ "tickets": tickets,
+ }
+ )
+
+
+@frappe.whitelist()
+def get_booking_details(booking_id: str) -> dict:
+ details = frappe._dict()
+ booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
+ details.doc = booking_doc
+
+ tickets = frappe.db.get_all(
+ "Event Ticket",
+ filters={"booking": booking_id},
+ fields=[
+ "name",
+ "attendee_name",
+ "attendee_email",
+ "ticket_type.title as ticket_type",
+ "qr_code",
+ "event",
+ "docstatus",
+ ],
+ )
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ("in", [ticket.name for ticket in tickets])},
+ fields=[
+ "parent",
+ "name",
+ "add_on",
+ "value",
+ "add_on.title as add_on_title",
+ "add_on.user_selects_option as user_selects_option",
+ ],
+ )
+
+ event_add_ons = frappe.db.get_all(
+ "Ticket Add-on",
+ filters={"event": booking_doc.event, "user_selects_option": True},
+ fields=["name", "title", "user_selects_option", "options"],
+ )
+
+ add_on_options_map = {}
+ for event_add_on in event_add_ons:
+ if event_add_on.user_selects_option:
+ add_on_options_map[event_add_on.name] = (
+ event_add_on.options.split("\n") if event_add_on.options else []
+ )
+
+ for ticket in tickets:
+ ticket.add_ons = []
+ for add_on in add_ons:
+ if add_on.parent == ticket.name:
+ add_on_data = {
+ "id": add_on.name,
+ "name": add_on.add_on,
+ "title": add_on.add_on_title,
+ "value": add_on.value,
+ "user_selects_option": add_on.user_selects_option,
+ "options": add_on_options_map.get(add_on.add_on, []),
+ }
+ ticket.add_ons.append(add_on_data)
+ ticket.add_ons = sorted(ticket.add_ons, key=lambda x: x["title"])
+
+ details.tickets = tickets
+ details.event = frappe.get_cached_doc("Buzz Event", booking_doc.event)
+
+ if details.event.venue:
+ details.venue = frappe.get_cached_doc("Event Venue", details.event.venue)
+
+ details.can_transfer_ticket = can_transfer_ticket(details.event.name)
+ details.can_change_add_ons = can_change_add_ons(details.event.name)
+ details.can_request_cancellation = can_request_cancellation(details.event.name)
+
+ existing_cancellation = frappe.db.get_value(
+ "Ticket Cancellation Request",
+ {"booking": booking_id},
+ ["name", "cancel_full_booking", "creation", "status", "docstatus"],
+ as_dict=True,
+ )
+ details.cancellation_request = existing_cancellation
+
+ details.cancellation_requested_tickets = []
+
+ if existing_cancellation and existing_cancellation.docstatus == 0:
+ if existing_cancellation.cancel_full_booking:
+ details.cancellation_requested_tickets = [ticket.name for ticket in tickets]
+ else:
+ requested_tickets = frappe.db.get_all(
+ "Ticket Cancellation Item", filters={"parent": existing_cancellation.name}, fields=["ticket"]
+ )
+ details.cancellation_requested_tickets = [item.ticket for item in requested_tickets]
+
+ details.cancelled_tickets = [ticket.name for ticket in tickets if ticket.docstatus == 2]
+
+ return details
+
+
+@frappe.whitelist(allow_guest=True) # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method
+def validate_coupon(coupon_code: str, event: str, user_email: str | None = None) -> dict:
+ if not frappe.db.exists("Buzz Coupon Code", coupon_code):
+ return {"valid": False, "error": _("Invalid coupon code")}
+
+ coupon = frappe.get_doc("Buzz Coupon Code", coupon_code)
+
+ is_valid, error = coupon.is_valid_for_event(event)
+ if not is_valid:
+ return {"valid": False, "error": error}
+
+ is_available, error = coupon.is_usage_available()
+ if not is_available:
+ return {"valid": False, "error": error}
+
+ if frappe.session.user == "Guest":
+ check_user = user_email.lower().strip() if user_email else None
+ else:
+ check_user = frappe.session.user
+ is_limited, error = coupon.is_user_limit_reached(user=check_user)
+ if is_limited:
+ return {"valid": False, "error": error}
+
+ if coupon.coupon_type == "Discount":
+ return {
+ "valid": True,
+ "coupon_type": "Discount",
+ "discount_type": coupon.discount_type,
+ "discount_value": coupon.discount_value,
+ "max_discount_amount": coupon.maximum_discount_amount or 0,
+ "min_order_value": coupon.minimum_order_value or 0,
+ }
+
+ remaining = coupon.number_of_free_tickets - coupon.free_tickets_claimed
+ if remaining <= 0:
+ return {"valid": False, "error": _("All free tickets have been claimed")}
+
+ return {
+ "valid": True,
+ "coupon_type": "Free Tickets",
+ "ticket_type": coupon.ticket_type,
+ "remaining_tickets": remaining,
+ "free_add_ons": [a.add_on for a in coupon.free_add_ons],
+ }
diff --git a/buzz/api/campaigns/__init__.py b/buzz/api/campaigns/__init__.py
new file mode 100644
index 00000000..2922cb91
--- /dev/null
+++ b/buzz/api/campaigns/__init__.py
@@ -0,0 +1,69 @@
+import frappe
+from frappe import _
+
+from buzz.utils import is_app_installed
+
+
+@frappe.whitelist()
+def get_campaign_details(campaign: str):
+ if not frappe.db.exists("Buzz Campaign", campaign):
+ frappe.throw(_("Campaign not found"), frappe.DoesNotExistError)
+
+ campaign_doc = frappe.get_cached_doc("Buzz Campaign", campaign)
+
+ if not campaign_doc.enabled:
+ frappe.throw(_("This campaign is not active"))
+
+ return {
+ "title": campaign_doc.title,
+ "description": campaign_doc.description,
+ "event": campaign_doc.event,
+ }
+
+
+@frappe.whitelist()
+def register_campaign_interest(campaign: str):
+ if frappe.session.user == "Guest":
+ frappe.throw(_("Please login to register your interest"))
+
+ if not is_app_installed("crm"):
+ frappe.throw(_("CRM integration is not available"))
+
+ if not frappe.db.exists("Buzz Campaign", campaign):
+ frappe.throw(_("Campaign not found"), frappe.DoesNotExistError)
+
+ campaign_doc = frappe.get_cached_doc("Buzz Campaign", campaign)
+
+ user = frappe.get_cached_doc("User", frappe.session.user)
+ first_name = user.first_name or user.full_name or frappe.session.user.split("@")[0]
+
+ existing_lead = frappe.db.exists(
+ "CRM Lead",
+ {"email": frappe.session.user, "buzz_campaign": campaign},
+ )
+ if existing_lead:
+ frappe.throw(_("You have already registered for this campaign"))
+
+ ticket = None
+ if campaign_doc.event:
+ ticket = frappe.db.get_value(
+ "Event Ticket",
+ {
+ "attendee_email": frappe.session.user,
+ "event": campaign_doc.event,
+ "docstatus": 1,
+ },
+ "name",
+ )
+
+ lead = frappe.get_doc(
+ {
+ "doctype": "CRM Lead",
+ "first_name": first_name,
+ "email": frappe.session.user,
+ "status": "New",
+ "buzz_campaign": campaign,
+ "event_ticket": ticket,
+ }
+ )
+ lead.insert(ignore_permissions=True)
diff --git a/buzz/api/checkin/__init__.py b/buzz/api/checkin/__init__.py
new file mode 100644
index 00000000..d9be7e4a
--- /dev/null
+++ b/buzz/api/checkin/__init__.py
@@ -0,0 +1,112 @@
+import frappe
+from frappe import _
+from frappe.utils import format_date, format_time
+
+
+@frappe.whitelist()
+def validate_ticket_for_checkin(ticket_id: str) -> dict:
+ frappe.only_for("Frontdesk Manager", True)
+ if not frappe.db.exists("Event Ticket", ticket_id):
+ frappe.throw(_("Ticket not found"))
+
+ ticket_doc = frappe.get_cached_doc("Event Ticket", ticket_id)
+
+ if ticket_doc.docstatus == 2:
+ frappe.throw(_("This ticket has been cancelled and cannot be checked in"))
+
+ event_doc = frappe.get_cached_doc("Buzz Event", ticket_doc.event)
+ ticket_type_doc = (
+ frappe.get_cached_doc("Event Ticket Type", ticket_doc.ticket_type) if ticket_doc.ticket_type else None
+ )
+
+ checkin_date = frappe.utils.today()
+ existing_checkin = frappe.db.exists("Event Check In", {"ticket": ticket_id, "date": checkin_date})
+
+ if existing_checkin:
+ checkin_doc = frappe.get_doc("Event Check In", existing_checkin)
+ formatted_checkin_time = (
+ format_date(checkin_doc.creation) + " at " + format_time(checkin_doc.creation)
+ )
+
+ frappe.throw(_("This ticket was already checked in today ({0}).").format(formatted_checkin_time))
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ticket_id},
+ fields=[
+ "add_on",
+ "add_on.title as add_on_title",
+ "add_on.user_selects_option as add_on_selects_option",
+ "value",
+ "price",
+ "currency",
+ ],
+ )
+
+ return {
+ "message": _("Valid ticket ready for check-in"),
+ "ticket": {
+ "id": ticket_doc.name,
+ "attendee_name": ticket_doc.attendee_name,
+ "attendee_email": ticket_doc.attendee_email,
+ "event_title": event_doc.title,
+ "ticket_type": (ticket_type_doc.title if ticket_type_doc else ticket_doc.ticket_type),
+ "venue": event_doc.venue,
+ "start_date": event_doc.start_date,
+ "start_time": event_doc.start_time,
+ "end_date": event_doc.end_date,
+ "end_time": event_doc.end_time,
+ "is_checked_in": False,
+ "check_in_time": None,
+ "booking_id": ticket_doc.booking,
+ "add_ons": add_ons,
+ },
+ "payment_details": get_payment_details_for_ticket(ticket_id),
+ }
+
+
+def get_payment_details_for_ticket(ticket_id: str) -> dict | None:
+ booking_id = frappe.get_cached_value("Event Ticket", ticket_id, "booking")
+ if not booking_id:
+ return None
+
+ payments = frappe.db.get_all(
+ "Event Payment",
+ filters={
+ "reference_doctype": "Event Booking",
+ "reference_docname": booking_id,
+ "payment_received": 1,
+ },
+ fields=["name", "amount", "currency"],
+ limit=1,
+ )
+
+ if payments:
+ return payments[0]
+
+
+@frappe.whitelist()
+def checkin_ticket(ticket_id: str) -> dict:
+ frappe.only_for("Frontdesk Manager", True)
+
+ checkin_date = frappe.utils.today()
+ validation_result = validate_ticket_for_checkin(ticket_id)
+
+ checkin_doc = frappe.new_doc("Event Check In")
+ checkin_doc.ticket = ticket_id
+ checkin_doc.date = checkin_date
+ checkin_doc.insert(ignore_permissions=True)
+ checkin_doc.submit()
+
+ return {
+ "message": _("Successfully checked in {attendee_name} for {checkin_date}").format(
+ attendee_name=validation_result["ticket"]["attendee_name"],
+ checkin_date=frappe.format(checkin_date, {"fieldtype": "Date"}),
+ ),
+ "ticket": {
+ **validation_result["ticket"],
+ "is_checked_in": True,
+ "check_in_time": checkin_doc.creation,
+ "check_in_date": checkin_date,
+ },
+ }
diff --git a/buzz/api/exceptions.py b/buzz/api/exceptions.py
new file mode 100644
index 00000000..c5c4fb1a
--- /dev/null
+++ b/buzz/api/exceptions.py
@@ -0,0 +1,43 @@
+from typing import NoReturn
+
+import frappe
+from frappe import _lt
+
+
+class BuzzAPIError(frappe.ValidationError):
+ """Base for API errors.
+
+ `http_status_code` is read by frappe.app.handle_exception. `title` and `message` are
+ declared with `_lt` so they are translated per request rather than at import, and reach
+ the dashboard as `_server_messages`, which is what `err.messages[0]` renders.
+
+ Raise via `throw()`, never `raise SomeError` — a bare raise skips msgprint, leaving the
+ dashboard to fall back to "Internal Server Error".
+ """
+
+ http_status_code = 400
+ title = _lt("Error")
+ message = _lt("Something went wrong. Please try again.")
+
+ @classmethod
+ def throw(cls, **context) -> NoReturn:
+ message = str(cls.message)
+ frappe.throw(message.format(**context) if context else message, cls, title=str(cls.title))
+
+
+class ResourceNotFound(BuzzAPIError):
+ http_status_code = 404
+ title = _lt("Not Found")
+ message = _lt("The record you are looking for does not exist.")
+
+
+class NotPermitted(BuzzAPIError):
+ http_status_code = 403
+ title = _lt("Not Permitted")
+ message = _lt("You do not have permission to do this.")
+
+
+class Conflict(BuzzAPIError):
+ http_status_code = 409
+ title = _lt("Not Allowed")
+ message = _lt("This action conflicts with the current state of the record.")
diff --git a/buzz/api/forms.py b/buzz/api/forms/__init__.py
similarity index 100%
rename from buzz/api/forms.py
rename to buzz/api/forms/__init__.py
diff --git a/buzz/api/test_forms.py b/buzz/api/forms/test_forms.py
similarity index 100%
rename from buzz/api/test_forms.py
rename to buzz/api/forms/test_forms.py
diff --git a/buzz/api/payments/__init__.py b/buzz/api/payments/__init__.py
new file mode 100644
index 00000000..83fd4645
--- /dev/null
+++ b/buzz/api/payments/__init__.py
@@ -0,0 +1,8 @@
+import frappe
+
+from buzz.payments import get_payment_gateways_for_event
+
+
+@frappe.whitelist()
+def get_event_payment_gateways(event: str) -> list[str]:
+ return get_payment_gateways_for_event(event)
diff --git a/buzz/api/proposals.py b/buzz/api/proposals/__init__.py
similarity index 100%
rename from buzz/api/proposals.py
rename to buzz/api/proposals/__init__.py
diff --git a/buzz/api/test_proposals.py b/buzz/api/proposals/test_proposals.py
similarity index 97%
rename from buzz/api/test_proposals.py
rename to buzz/api/proposals/test_proposals.py
index 538d6d49..e7fd7912 100644
--- a/buzz/api/test_proposals.py
+++ b/buzz/api/proposals/test_proposals.py
@@ -2,8 +2,8 @@
from frappe.tests import IntegrationTestCase
from frappe.utils.data import cstr
+from buzz.api.forms.test_forms import ensure_prompt_named_record
from buzz.api.proposals import get_my_proposals
-from buzz.api.test_forms import ensure_prompt_named_record
from buzz.proposals.doctype.talk_proposal.test_talk_proposal import (
make_guest_proposal,
make_test_event,
diff --git a/buzz/api/schemas.py b/buzz/api/schemas.py
new file mode 100644
index 00000000..698a1728
--- /dev/null
+++ b/buzz/api/schemas.py
@@ -0,0 +1,14 @@
+from pydantic import BaseModel, ConfigDict
+
+
+class APIRequest(BaseModel):
+ model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
+
+
+class APIResponse(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ def __json__(self) -> dict:
+ # frappe's json_handler checks __json__ before its Iterable branch; without this a
+ # BaseModel serializes as a list of (key, value) pairs.
+ return self.model_dump()
diff --git a/buzz/api/sponsorships/__init__.py b/buzz/api/sponsorships/__init__.py
new file mode 100644
index 00000000..ff08ac21
--- /dev/null
+++ b/buzz/api/sponsorships/__init__.py
@@ -0,0 +1,127 @@
+import frappe
+
+from buzz.payments import get_payment_link_for_sponsorship
+
+
+@frappe.whitelist()
+def get_sponsorship_details(enquiry_id: str) -> dict:
+ enquiry = frappe.get_doc("Sponsorship Enquiry", enquiry_id)
+
+ if enquiry.owner != frappe.session.user and not frappe.has_permission(
+ "Sponsorship Enquiry", "read", enquiry
+ ):
+ frappe.throw(frappe._("Not permitted to view this sponsorship enquiry"))
+
+ tier_title = ""
+ if enquiry.tier:
+ tier_title = frappe.db.get_value("Sponsorship Tier", enquiry.tier, "title") or enquiry.tier
+
+ event_details = {}
+ if enquiry.event:
+ event = frappe.get_cached_doc("Buzz Event", enquiry.event)
+ event_details = {
+ "title": event.title,
+ "short_description": getattr(event, "short_description", ""),
+ "about": getattr(event, "about", ""),
+ "start_date": event.start_date,
+ "end_date": getattr(event, "end_date", ""),
+ "venue": getattr(event, "venue", ""),
+ "route": getattr(event, "route", ""),
+ }
+
+ sponsor_details = None
+ sponsors = frappe.db.get_all(
+ "Event Sponsor",
+ filters={"enquiry": enquiry_id},
+ fields=["name", "company_name", "company_logo", "creation", "event", "tier"],
+ limit=1,
+ )
+
+ if sponsors:
+ sponsor_details = sponsors[0]
+ if sponsor_details.get("tier"):
+ sponsor_tier_title = frappe.db.get_value("Sponsorship Tier", sponsor_details["tier"], "title")
+ sponsor_details["tier_title"] = sponsor_tier_title or sponsor_details["tier"]
+
+ return {
+ "enquiry": {
+ "name": enquiry.name,
+ "company_name": enquiry.company_name,
+ "company_logo": enquiry.company_logo,
+ "event": enquiry.event,
+ "tier": enquiry.tier,
+ "tier_title": tier_title,
+ "status": enquiry.status,
+ "creation": enquiry.creation,
+ "owner": enquiry.owner,
+ },
+ "event_details": event_details,
+ "sponsor_details": sponsor_details,
+ "has_sponsor": bool(sponsor_details),
+ }
+
+
+@frappe.whitelist()
+def get_user_sponsorship_inquiries() -> list:
+ inquiries = frappe.db.get_all(
+ "Sponsorship Enquiry",
+ filters={"owner": frappe.session.user},
+ fields=["name", "company_name", "event", "tier", "status", "creation"],
+ order_by="creation desc",
+ )
+
+ for inquiry in inquiries:
+ if inquiry.event:
+ event_title = frappe.db.get_value("Buzz Event", inquiry.event, "title")
+ inquiry["event_title"] = event_title
+
+ if inquiry.tier:
+ tier_title = frappe.db.get_value("Sponsorship Tier", inquiry.tier, "title")
+ inquiry["tier_title"] = tier_title or inquiry.tier
+ else:
+ inquiry["tier_title"] = ""
+
+ inquiry_names = [inquiry.name for inquiry in inquiries]
+ if inquiry_names:
+ sponsors = frappe.db.get_all(
+ "Event Sponsor",
+ filters={"enquiry": ["in", inquiry_names]},
+ fields=["enquiry"],
+ )
+ sponsored_inquiries = {sponsor.enquiry for sponsor in sponsors}
+
+ for inquiry in inquiries:
+ inquiry["has_sponsor"] = inquiry.name in sponsored_inquiries
+ else:
+ for inquiry in inquiries:
+ inquiry["has_sponsor"] = False
+
+ return inquiries
+
+
+@frappe.whitelist()
+def create_sponsorship_payment_link(enquiry_id: str, tier_id: str, payment_gateway: str | None = None) -> str:
+ enquiry = frappe.get_doc("Sponsorship Enquiry", enquiry_id)
+ if enquiry.owner != frappe.session.user:
+ frappe.throw(frappe._("Not permitted to create payment for this enquiry"))
+
+ redirect_url = f"/b/account/sponsorships/{enquiry_id}?success=true"
+ return get_payment_link_for_sponsorship(
+ enquiry_id, tier_id, redirect_url, payment_gateway=payment_gateway
+ )
+
+
+@frappe.whitelist()
+def withdraw_sponsorship_enquiry(enquiry_id: str):
+ enquiry = frappe.get_cached_doc("Sponsorship Enquiry", enquiry_id)
+ if enquiry.owner != frappe.session.user:
+ frappe.throw(frappe._("Not permitted to withdraw this enquiry"))
+
+ if enquiry.status == "Paid":
+ frappe.throw(frappe._("Cannot withdraw a paid sponsorship enquiry"))
+
+ if enquiry.status == "Withdrawn":
+ frappe.throw(frappe._("This sponsorship enquiry has already been withdrawn"))
+
+ enquiry.status = "Withdrawn"
+ enquiry.save(ignore_permissions=True)
diff --git a/buzz/api/test_exceptions.py b/buzz/api/test_exceptions.py
new file mode 100644
index 00000000..697e979e
--- /dev/null
+++ b/buzz/api/test_exceptions.py
@@ -0,0 +1,56 @@
+import frappe
+from frappe import _lt
+from frappe.tests import IntegrationTestCase
+
+from buzz.api.exceptions import BuzzAPIError, Conflict, NotPermitted, ResourceNotFound
+
+
+class Parameterized(BuzzAPIError):
+ http_status_code = 418
+ title = _lt("Teapot")
+ message = _lt("{item} is not available.")
+
+
+class TestBuzzAPIError(IntegrationTestCase):
+ def setUp(self):
+ frappe.clear_messages()
+
+ def last_message(self) -> dict:
+ return frappe.local.message_log[-1]
+
+ def test_throw_raises_its_own_class(self):
+ with self.assertRaises(Conflict):
+ Conflict.throw()
+
+ def test_throw_publishes_title_and_message(self):
+ with self.assertRaises(Conflict):
+ Conflict.throw()
+
+ self.assertEqual(self.last_message()["title"], "Not Allowed")
+ self.assertIn("conflicts", self.last_message()["message"])
+
+ def test_message_accepts_context(self):
+ with self.assertRaises(Parameterized):
+ Parameterized.throw(item="Chai")
+
+ self.assertEqual(self.last_message()["message"], "Chai is not available.")
+
+ def test_bare_raise_publishes_nothing(self):
+ # A bare raise skips msgprint, so the dashboard would show "Internal Server Error".
+ with self.assertRaises(Conflict):
+ raise Conflict
+
+ self.assertEqual(frappe.local.message_log, [])
+
+ def test_status_codes(self):
+ self.assertEqual(BuzzAPIError.http_status_code, 400)
+ self.assertEqual(ResourceNotFound.http_status_code, 404)
+ self.assertEqual(NotPermitted.http_status_code, 403)
+ self.assertEqual(Conflict.http_status_code, 409)
+
+ def test_subclass_inherits_status_code(self):
+ self.assertEqual(Parameterized.http_status_code, 418)
+
+ def test_errors_are_validation_errors(self):
+ # Keeps existing `except frappe.ValidationError` handlers working.
+ self.assertTrue(issubclass(BuzzAPIError, frappe.ValidationError))
diff --git a/buzz/api/tickets/__init__.py b/buzz/api/tickets/__init__.py
new file mode 100644
index 00000000..97d0604f
--- /dev/null
+++ b/buzz/api/tickets/__init__.py
@@ -0,0 +1,324 @@
+import frappe
+from frappe import _
+from frappe.utils import days_diff, format_date, format_time, today
+
+
+def is_ticket_transfer_allowed(event_id: str | int) -> bool:
+ try:
+ event = frappe.get_cached_doc("Buzz Event", event_id)
+ settings = frappe.get_single("Buzz Settings")
+ transfer_cutoff_days = settings.get("allow_transfer_ticket_before_event_start_days", 7)
+
+ if not event.start_date:
+ return False
+
+ return days_diff(event.start_date, today()) >= transfer_cutoff_days
+ except Exception as e:
+ frappe.log_error(f"Error checking ticket transfer eligibility: {e!s}")
+ return False
+
+
+def is_add_on_change_allowed(event_id: str | int) -> bool:
+ try:
+ event = frappe.get_cached_doc("Buzz Event", event_id)
+ settings = frappe.get_cached_doc("Buzz Settings")
+ add_on_change_cutoff_days = settings.get("allow_add_ons_change_before_event_start_days", 7)
+
+ if not event.start_date:
+ return False
+
+ return days_diff(event.start_date, today()) >= add_on_change_cutoff_days
+ except Exception as e:
+ frappe.log_error(f"Error checking add-on change eligibility: {e!s}")
+ return False
+
+
+def is_cancellation_request_allowed(event_id: str | int) -> bool:
+ try:
+ event = frappe.get_cached_doc("Buzz Event", event_id)
+ settings = frappe.get_cached_doc("Buzz Settings")
+ cancellation_cutoff_days = settings.get(
+ "allow_ticket_cancellation_request_before_event_start_days", 7
+ )
+
+ if not event.start_date:
+ return False
+
+ return days_diff(event.start_date, today()) >= cancellation_cutoff_days
+ except Exception as e:
+ frappe.log_error(f"Error checking cancellation request eligibility: {e!s}")
+ return False
+
+
+@frappe.whitelist()
+def can_transfer_ticket(event_id: str | int) -> dict:
+ return {"can_transfer": is_ticket_transfer_allowed(event_id), "event_id": event_id}
+
+
+@frappe.whitelist()
+def can_change_add_ons(event_id: str | int) -> dict:
+ return {"can_change_add_ons": is_add_on_change_allowed(event_id), "event_id": event_id}
+
+
+@frappe.whitelist()
+def can_request_cancellation(event_id: str | int) -> dict:
+ return {"can_request_cancellation": is_cancellation_request_allowed(event_id), "event_id": event_id}
+
+
+def create_add_on_doc(attendee_name: str, add_ons: list[dict]):
+ for add_on in add_ons:
+ add_on["currency"] = frappe.db.get_value("Ticket Add-on", add_on["add_on"], "currency")
+
+ return frappe.get_doc(
+ {"doctype": "Attendee Ticket Add-on", "add_ons": add_ons, "attendee_name": attendee_name}
+ ).insert(ignore_permissions=True)
+
+
+@frappe.whitelist()
+def transfer_ticket(ticket_id: str, new_first_name: str, new_last_name: str, new_email: str):
+ if not frappe.db.exists("Event Ticket", ticket_id):
+ frappe.throw(frappe._("Ticket not found."))
+
+ ticket = frappe.get_doc("Event Ticket", ticket_id)
+ booking_user = frappe.db.get_value("Event Booking", ticket.booking, "user")
+
+ if (
+ ticket.attendee_email != frappe.session.user
+ and booking_user != frappe.session.user
+ and not frappe.has_permission("Event Ticket", "write", ticket)
+ ):
+ frappe.throw(frappe._("Not permitted to transfer this ticket."))
+
+ if not is_ticket_transfer_allowed(ticket.event):
+ frappe.throw(frappe._("Ticket transfer is not allowed at this time. The transfer window has closed."))
+
+ old_name = ticket.attendee_name
+ old_email = ticket.attendee_email
+ new_name = f"{new_first_name} {new_last_name}".strip()
+
+ ticket.first_name = new_first_name
+ ticket.last_name = new_last_name
+ ticket.attendee_email = new_email
+ ticket.save(ignore_permissions=True)
+
+ send_ticket_transfer_emails(ticket.name, old_name, old_email, new_name, new_email)
+
+
+def send_ticket_transfer_emails(ticket_id: str, old_name: str, old_email: str, new_name: str, new_email: str):
+ try:
+ ticket = frappe.get_doc("Event Ticket", ticket_id)
+ event = frappe.get_doc("Buzz Event", ticket.event)
+ booking = frappe.get_doc("Event Booking", ticket.booking)
+
+ old_attendee_subject = f"Your ticket for {event.title} has been transferred"
+ old_attendee_message = f"""
+ Dear {old_name},
+
+ This is to inform you that your ticket for {event.title} has been transferred to {new_name} ({new_email}).
+
+ Event Details:
+
+ - Event: {event.title}
+ - Date: {format_date(event.start_date)}
+ - Ticket Type: {ticket.ticket_type}
+ - Booking ID: {booking.name}
+
+
+ If you have any questions about this transfer, please contact us.
+
+ Best regards,
+ {event.title} Team
+ """
+
+ frappe.sendmail(
+ recipients=[old_email], subject=old_attendee_subject, message=old_attendee_message, delayed=False
+ )
+
+ new_attendee_subject = f"Welcome! Your ticket for {event.title}"
+ new_attendee_message = f"""
+ Dear {new_name},
+
+ Great news! A ticket for {event.title} has been transferred to you.
+
+ Event Details:
+
+ - Event: {event.title}
+ - Date: {format_date(event.start_date)}
+ - Time: {format_time(event.start_time) if event.start_time else "TBA"}
+ - Venue: {event.venue or "TBA"}
+ - Ticket Type: {ticket.ticket_type}
+ - Booking ID: {booking.name}
+
+
+ Your Ticket Details:
+
+ - Ticket ID: {ticket.name}
+ - Attendee Name: {new_name}
+ - Email: {new_email}
+
+
+ Please save this email for your records. You may need to present this ticket information at the event entrance.
+
+ We look forward to seeing you at the event!
+
+ Best regards,
+ {event.title} Team
+ """
+
+ frappe.sendmail(
+ recipients=[new_email], subject=new_attendee_subject, message=new_attendee_message, delayed=False
+ )
+
+ except Exception as e:
+ frappe.log_error(f"Failed to send ticket transfer emails for ticket {ticket_id}: {e!s}")
+
+
+@frappe.whitelist()
+def change_add_on_preference(add_on_id: str, new_value: str):
+ if not frappe.db.exists("Ticket Add-on Value", add_on_id):
+ frappe.throw(frappe._("Add-on value not found."))
+
+ add_on_value = frappe.get_cached_doc("Ticket Add-on Value", add_on_id)
+ ticket = frappe.get_cached_doc("Event Ticket", add_on_value.parent)
+
+ if not is_add_on_change_allowed(ticket.event):
+ frappe.throw(
+ frappe._(
+ "Add-on changes are not allowed at this time. The change window has closed as the event is approaching."
+ )
+ )
+
+ frappe.db.set_value(
+ "Ticket Add-on Value",
+ add_on_id,
+ "value",
+ new_value,
+ )
+
+
+@frappe.whitelist()
+def get_ticket_details(ticket_id: str) -> dict:
+ details = frappe._dict()
+ ticket_doc = frappe.get_cached_doc("Event Ticket", ticket_id)
+
+ if frappe.session.user != "Administrator":
+ if ticket_doc.attendee_email != frappe.session.user:
+ frappe.throw(frappe._("Not permitted to view this ticket"))
+
+ details.doc = ticket_doc
+
+ add_ons = frappe.db.get_all(
+ "Ticket Add-on Value",
+ filters={"parent": ticket_id},
+ fields=[
+ "name",
+ "add_on",
+ "add_on.title as add_on_title",
+ "value",
+ "price",
+ "currency",
+ "add_on.user_selects_option as user_selects_option",
+ ],
+ )
+
+ event_add_ons = frappe.db.get_all(
+ "Ticket Add-on",
+ filters={"event": ticket_doc.event, "user_selects_option": True},
+ fields=["name", "title", "user_selects_option", "options"],
+ )
+
+ add_on_options_map = {}
+ for event_add_on in event_add_ons:
+ if event_add_on.user_selects_option:
+ add_on_options_map[event_add_on.name] = (
+ event_add_on.options.split("\n") if event_add_on.options else []
+ )
+
+ enhanced_add_ons = []
+ for add_on in add_ons:
+ add_on_data = {
+ "id": add_on.name,
+ "name": add_on.add_on,
+ "title": add_on.add_on_title,
+ "value": add_on.value,
+ "price": add_on.price,
+ "currency": add_on.currency,
+ "user_selects_option": add_on.user_selects_option,
+ "options": add_on_options_map.get(add_on.add_on, []),
+ }
+ enhanced_add_ons.append(add_on_data)
+
+ details.add_ons = enhanced_add_ons
+ details.event = frappe.get_cached_doc("Buzz Event", ticket_doc.event)
+
+ booking_doc = None
+ if ticket_doc.booking:
+ booking_doc = frappe.get_cached_doc("Event Booking", ticket_doc.booking)
+ if booking_doc.owner == frappe.session.user:
+ details.booking = booking_doc
+ else:
+ details.booking = None
+ else:
+ details.booking = None
+
+ details.ticket_type = frappe.get_cached_doc("Event Ticket Type", ticket_doc.ticket_type)
+ details.can_transfer_ticket = (
+ can_transfer_ticket(details.event.name) if details.event else {"can_transfer": False}
+ )
+ details.can_change_add_ons = (
+ can_change_add_ons(details.event.name) if details.event else {"can_change_add_ons": False}
+ )
+ details.can_request_cancellation = (
+ can_request_cancellation(details.event.name) if details.event else {"can_request_cancellation": False}
+ )
+
+ details.zoom_join_url = None
+ if hasattr(ticket_doc, "zoom_session_registration") and ticket_doc.zoom_session_registration:
+ zoom_registration = frappe.db.get_value(
+ "Zoom Session Registration",
+ ticket_doc.zoom_session_registration,
+ ["join_url", "reference_doctype", "reference_name"],
+ as_dict=True,
+ )
+ if zoom_registration:
+ details.zoom_join_url = zoom_registration.join_url
+ details.zoom_reference_doctype = zoom_registration.reference_doctype
+ details.zoom_reference_name = zoom_registration.reference_name
+
+ return details
+
+
+@frappe.whitelist()
+def create_cancellation_request(booking_id: str, ticket_ids: list | None = None) -> dict:
+ booking_doc = frappe.get_cached_doc("Event Booking", booking_id)
+
+ if booking_doc.user != frappe.session.user and not frappe.has_permission(
+ "Event Booking", "write", booking_doc
+ ):
+ frappe.throw(frappe._("Not permitted to request cancellation for this booking."))
+
+ if not is_cancellation_request_allowed(booking_doc.event):
+ frappe.throw(_("Cancellation requests are no longer allowed for this event."))
+
+ existing_request = frappe.db.exists(
+ "Ticket Cancellation Request", {"booking": booking_id, "docstatus": 0}
+ )
+ if existing_request:
+ frappe.throw(_("A cancellation request already exists for this booking."))
+
+ all_tickets = frappe.db.get_all("Event Ticket", filters={"booking": booking_id}, fields=["name"])
+ cancel_full_booking = not ticket_ids or len(ticket_ids) == len(all_tickets)
+
+ cancellation_request = frappe.new_doc("Ticket Cancellation Request")
+ cancellation_request.booking = booking_id
+ cancellation_request.cancel_full_booking = cancel_full_booking
+
+ if not cancel_full_booking and ticket_ids:
+ for ticket_id in ticket_ids:
+ ticket_booking = frappe.db.get_value("Event Ticket", ticket_id, "booking")
+ if ticket_booking != booking_id:
+ frappe.throw(f"Ticket {ticket_id} does not belong to booking {booking_id}")
+
+ cancellation_request.append("tickets", {"ticket": ticket_id})
+
+ cancellation_request.insert(ignore_permissions=True)
diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py
index fa20b849..3961c95d 100644
--- a/buzz/events/doctype/buzz_event/test_buzz_event.py
+++ b/buzz/events/doctype/buzz_event/test_buzz_event.py
@@ -7,7 +7,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase
-from buzz.api import are_registrations_closed
+from buzz.api.booking import are_registrations_closed
from buzz.events.doctype.buzz_event.buzz_event import RESERVED_EVENT_ROUTES, create_from_template
from buzz.events.doctype.event_template.event_template import create_template_from_event
from buzz.patches.set_time_zone_label_for_existing_events import execute as backfill_time_zone_labels
@@ -719,7 +719,7 @@ def test_no_close_at_and_event_in_future_returns_false(self):
end_date="2026-06-20",
end_time="18:00:00",
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_now):
self.assertFalse(are_registrations_closed(event))
def test_no_close_at_falls_back_to_event_end(self):
@@ -732,7 +732,7 @@ def test_no_close_at_falls_back_to_event_end(self):
end_date="2026-06-15",
end_time="18:00:00", # event ended 2 hours before fake_now
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_now):
self.assertTrue(are_registrations_closed(event))
def test_close_at_takes_priority_over_event_end(self):
@@ -746,7 +746,7 @@ def test_close_at_takes_priority_over_event_end(self):
end_date="2026-06-15",
end_time="18:00:00",
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_now):
self.assertFalse(are_registrations_closed(event))
def test_future_close_at_returns_false(self):
@@ -756,7 +756,7 @@ def test_future_close_at_returns_false(self):
registrations_close_at="2026-06-15 12:00:00", # 2 hours after fake_now
time_zone="UTC",
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_now):
self.assertFalse(are_registrations_closed(event))
def test_past_close_at_returns_true(self):
@@ -766,7 +766,7 @@ def test_past_close_at_returns_true(self):
registrations_close_at="2026-06-15 12:00:00", # 2 hours before fake_now
time_zone="UTC",
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_now):
self.assertTrue(are_registrations_closed(event))
def test_timezone_ahead_of_utc_closes_earlier(self):
@@ -783,7 +783,7 @@ def test_timezone_ahead_of_utc_closes_earlier(self):
time_zone="Asia/Kolkata",
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_ist_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_ist_now):
# 19:30 IST > 18:00 IST → closed
self.assertTrue(are_registrations_closed(event))
@@ -801,7 +801,7 @@ def test_timezone_behind_utc_stays_open_longer(self):
time_zone="US/Pacific",
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_pdt_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_pdt_now):
# 16:00 PDT < 18:00 PDT → still open
self.assertFalse(are_registrations_closed(event))
@@ -819,12 +819,12 @@ def test_same_close_time_different_timezones(self):
# 17:30 UTC = 23:00 IST
fake_ist_now = datetime(2026, 6, 15, 23, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_ist_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_ist_now):
self.assertTrue(are_registrations_closed(event_ist))
# 17:30 UTC = 10:30 PDT
fake_pdt_now = datetime(2026, 6, 15, 10, 30, 0, tzinfo=timezone(timedelta(hours=-7)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_pdt_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_pdt_now):
self.assertFalse(are_registrations_closed(event_pdt))
def test_falls_back_to_system_timezone_when_event_tz_not_set(self):
@@ -834,7 +834,7 @@ def test_falls_back_to_system_timezone_when_event_tz_not_set(self):
registrations_close_at="2026-06-15 13:00:00", # 1 hour before fake_now
time_zone=None,
)
- with patch("buzz.api.get_datetime_in_timezone", return_value=fake_now):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=fake_now):
self.assertTrue(are_registrations_closed(event))
def test_closing_moment_is_same_absolute_instant_for_viewers_anywhere(self):
@@ -858,17 +858,17 @@ def test_closing_moment_is_same_absolute_instant_for_viewers_anywhere(self):
# Exactly at the closing instant (11:00 UTC / noon BST / 16:30 IST) -> comparison is
# strictly-greater-than, so registrations are still open at the exact boundary.
at_close = datetime(2026, 6, 15, 16, 30, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=at_close):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=at_close):
self.assertFalse(are_registrations_closed(event))
# One minute before that shared instant (10:59 UTC / 11:59 BST / 16:29 IST) -> still open.
before_close = datetime(2026, 6, 15, 16, 29, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=before_close):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=before_close):
self.assertFalse(are_registrations_closed(event))
# One minute after (11:01 UTC / 12:01 BST / 16:31 IST) -> closed.
after_close = datetime(2026, 6, 15, 16, 31, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=after_close):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=after_close):
self.assertTrue(are_registrations_closed(event))
def test_event_end_fallback_is_also_timezone_consistent(self):
@@ -885,12 +885,12 @@ def test_event_end_fallback_is_also_timezone_consistent(self):
# 16:29 IST (11:59 BST / noon-minus-1 in London) -> event still ongoing, open.
before_end = datetime(2026, 6, 15, 16, 29, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=before_end):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=before_end):
self.assertFalse(are_registrations_closed(event))
# 16:31 IST (12:01 BST, just past noon in London) -> event over, closed.
after_end = datetime(2026, 6, 15, 16, 31, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
- with patch("buzz.api.get_datetime_in_timezone", return_value=after_end):
+ with patch("buzz.api.booking.get_datetime_in_timezone", return_value=after_end):
self.assertTrue(are_registrations_closed(event))
diff --git a/buzz/hooks.py b/buzz/hooks.py
index 63613030..3c1af317 100644
--- a/buzz/hooks.py
+++ b/buzz/hooks.py
@@ -78,7 +78,7 @@
"logo": "/assets/buzz/images/buzz-logo-rounded.png",
"title": "Buzz",
"route": "/app/buzz",
- "has_permission": "buzz.api.has_app_permission",
+ "has_permission": "buzz.api.account.has_app_permission",
}
]
diff --git a/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py b/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py
index 82ec00cb..446141d6 100644
--- a/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py
+++ b/buzz/proposals/doctype/talk_proposal/test_talk_proposal.py
@@ -4,7 +4,7 @@
import frappe
from frappe.tests import IntegrationTestCase
-from buzz.api.test_forms import ensure_prompt_named_record
+from buzz.api.forms.test_forms import ensure_prompt_named_record
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
diff --git a/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py b/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py
index 7187ee7b..f0196a0d 100644
--- a/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py
+++ b/buzz/ticketing/doctype/buzz_coupon_code/test_buzz_coupon_code.py
@@ -1099,7 +1099,7 @@ def setUp(self):
def test_validate_coupon_returns_discount_info(self):
"""Test that validate_coupon returns correct discount info."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
frappe.get_doc(
{
@@ -1121,7 +1121,7 @@ def test_validate_coupon_returns_discount_info(self):
def test_validate_coupon_returns_max_and_min_values(self):
"""Test that validate_coupon returns max_discount_amount and min_order_value."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
frappe.get_doc(
{
@@ -1144,7 +1144,7 @@ def test_validate_coupon_returns_max_and_min_values(self):
def test_validate_coupon_returns_free_tickets_info(self):
"""Test that validate_coupon returns correct free tickets info."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
frappe.get_doc(
{
@@ -1167,7 +1167,7 @@ def test_validate_coupon_returns_free_tickets_info(self):
def test_validate_coupon_invalid_code(self):
"""Test that invalid coupon code returns error."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
result = validate_coupon("INVALIDCODE", str(self.test_event.name))
@@ -1178,7 +1178,7 @@ def test_validate_coupon_invalid_code(self):
def test_coupon_not_yet_active(self):
"""Test that coupon with future valid_from is rejected."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
frappe.get_doc(
{
@@ -1199,7 +1199,7 @@ def test_coupon_not_yet_active(self):
def test_expired_coupon_rejected(self):
"""Test that expired coupon is rejected."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
frappe.get_doc(
{
@@ -1220,7 +1220,7 @@ def test_expired_coupon_rejected(self):
def test_coupon_within_validity_period(self):
"""Test that coupon within valid date range works."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
frappe.get_doc(
{
@@ -1243,7 +1243,7 @@ def test_coupon_within_validity_period(self):
def test_max_usage_per_user_enforced(self):
"""Test that user cannot exceed per-user usage limit."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
coupon = frappe.get_doc(
{
@@ -1283,7 +1283,7 @@ def test_max_usage_per_user_enforced(self):
def test_per_user_limit_does_not_affect_other_users(self):
"""Test that per-user limit doesn't block other users."""
- from buzz.api import validate_coupon
+ from buzz.api.booking import validate_coupon
coupon = frappe.get_doc(
{
diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py
index 709baa5e..4611dc37 100644
--- a/buzz/ticketing/doctype/event_booking/event_booking.py
+++ b/buzz/ticketing/doctype/event_booking/event_booking.py
@@ -7,7 +7,7 @@
from frappe.email.doctype.email_template.email_template import get_email_template
from frappe.model.document import Document
-from buzz.api import OFFLINE_PAYMENT_METHOD
+from buzz.api.booking import OFFLINE_PAYMENT_METHOD
from buzz.payments import mark_payment_as_received
diff --git a/buzz/ticketing/doctype/event_booking/test_event_booking.py b/buzz/ticketing/doctype/event_booking/test_event_booking.py
index 3e10595d..a49fe046 100644
--- a/buzz/ticketing/doctype/event_booking/test_event_booking.py
+++ b/buzz/ticketing/doctype/event_booking/test_event_booking.py
@@ -471,7 +471,7 @@ class TestProcessBookingAPI(IntegrationTestCase):
def test_process_booking_with_utm_parameters(self):
"""Test that process_booking API correctly saves UTM parameters."""
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
@@ -826,7 +826,7 @@ def test_offline_booking_requires_payment_method_field(self):
def test_process_booking_offline_stays_in_draft(self):
"""Test that offline bookings via process_booking stay in draft with no tickets."""
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
test_event.apply_tax = False
@@ -883,7 +883,7 @@ def test_process_booking_offline_stays_in_draft(self):
def test_process_booking_offline_generates_tickets_on_approval(self):
"""Test that approving an offline booking created via API generates tickets."""
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
test_event.apply_tax = False
@@ -945,7 +945,7 @@ def test_process_booking_offline_generates_tickets_on_approval(self):
def test_process_booking_free_event_returns_redirect_to(self):
"""Free bookings (total_amount == 0) must get a redirect_to the new
token-gated booking-success screen, same as the paid-gateway branch."""
- from buzz.api import process_booking, verify_booking_access_token
+ from buzz.api.booking import process_booking, verify_booking_access_token
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
test_event.apply_tax = False
@@ -983,7 +983,7 @@ def test_process_booking_free_event_returns_redirect_to(self):
def test_process_booking_without_utm_parameters(self):
"""Test that process_booking API works without UTM parameters."""
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
test_event.is_published = True
@@ -1025,7 +1025,7 @@ def test_process_booking_without_utm_parameters(self):
def test_process_booking_with_empty_utm_parameters(self):
"""Test that process_booking API handles empty UTM list."""
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
@@ -1066,7 +1066,7 @@ def test_process_booking_with_empty_utm_parameters(self):
def test_process_booking_failed_for_unpublished_event(self):
"""Booking must fail when Buzz Event is not published."""
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
test_event = frappe.get_doc("Buzz Event", {"route": "test-route"})
test_event.is_published = 0
@@ -1165,7 +1165,7 @@ def _make_submitted_booking(self):
return booking
def test_access_token_roundtrip(self):
- from buzz.api import get_booking_access_token, verify_booking_access_token
+ from buzz.api.booking import get_booking_access_token, verify_booking_access_token
token = get_booking_access_token("B-TEST-001")
self.assertTrue(verify_booking_access_token("B-TEST-001", token))
@@ -1178,7 +1178,7 @@ def test_access_token_roundtrip(self):
self.assertFalse(verify_booking_access_token("B-OTHER-002", token))
def test_get_booking_confirmation_valid_token_as_guest(self):
- from buzz.api import get_booking_access_token, get_booking_confirmation
+ from buzz.api.booking import get_booking_access_token, get_booking_confirmation
booking = self._make_submitted_booking()
token = get_booking_access_token(booking.name)
@@ -1195,7 +1195,7 @@ def test_get_booking_confirmation_valid_token_as_guest(self):
self.assertTrue(result["event"]["title"])
def test_get_booking_confirmation_bad_token_as_guest_raises(self):
- from buzz.api import get_booking_confirmation
+ from buzz.api.booking import get_booking_confirmation
booking = self._make_submitted_booking()
@@ -1209,7 +1209,7 @@ def test_get_booking_confirmation_bad_token_as_guest_raises(self):
frappe.set_user("Administrator")
def test_get_booking_confirmation_owner_without_token(self):
- from buzz.api import get_booking_confirmation
+ from buzz.api.booking import get_booking_confirmation
booking = self._make_submitted_booking()
@@ -1400,7 +1400,7 @@ def tearDown(self):
frappe.db.rollback()
def _book_without_last_name(self, category):
- from buzz.api import process_booking
+ from buzz.api.booking import process_booking
self.event.db_set("category", category)
return process_booking(
diff --git a/buzz/ticketing/doctype/event_booking/test_guest_booking.py b/buzz/ticketing/doctype/event_booking/test_guest_booking.py
index 8cc3416d..dd10db0f 100644
--- a/buzz/ticketing/doctype/event_booking/test_guest_booking.py
+++ b/buzz/ticketing/doctype/event_booking/test_guest_booking.py
@@ -8,7 +8,7 @@
import pyotp
from frappe.tests import IntegrationTestCase
-from buzz.api import process_booking
+from buzz.api.booking import process_booking
class TestGuestBooking(IntegrationTestCase):
diff --git a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py
index 6eb6331d..3d7a47e4 100644
--- a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py
+++ b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py
@@ -277,7 +277,7 @@ def test_ticket_details_expose_the_zoom_session_reference(self):
create_meeting_response,
)
- from buzz.api import get_ticket_details
+ from buzz.api.tickets import get_ticket_details
meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting"
diff --git a/dashboard/src/components/AddOnPreferenceDialog.vue b/dashboard/src/components/AddOnPreferenceDialog.vue
index 6425348a..5ce52f82 100644
--- a/dashboard/src/components/AddOnPreferenceDialog.vue
+++ b/dashboard/src/components/AddOnPreferenceDialog.vue
@@ -104,7 +104,7 @@ watch(
);
const savePreferences = createResource({
- url: "buzz.api.change_add_on_preference",
+ url: "buzz.api.tickets.change_add_on_preference",
onSuccess: () => {
toast.success("Add-on preferences updated successfully!");
emit("success");
diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue
index 92acd776..64021834 100644
--- a/dashboard/src/components/BookingForm.vue
+++ b/dashboard/src/components/BookingForm.vue
@@ -963,11 +963,11 @@ function prefillAttendee(field: string) {
}
const processBooking = createResource({
- url: "buzz.api.process_booking",
+ url: "buzz.api.booking.process_booking",
});
const validateCoupon = createResource({
- url: "buzz.api.validate_coupon",
+ url: "buzz.api.booking.validate_coupon",
});
function startResendCooldown() {
@@ -982,7 +982,7 @@ function startResendCooldown() {
}
const sendOtpResource = createResource({
- url: "buzz.api.send_guest_booking_otp",
+ url: "buzz.api.booking.send_guest_booking_otp",
onSuccess: () => {
showOtpModal.value = true;
startResendCooldown();
diff --git a/dashboard/src/components/CancellationRequestDialog.vue b/dashboard/src/components/CancellationRequestDialog.vue
index 6e218344..e5cb2575 100644
--- a/dashboard/src/components/CancellationRequestDialog.vue
+++ b/dashboard/src/components/CancellationRequestDialog.vue
@@ -270,7 +270,7 @@ const closeDialog = () => {
};
const createCancellationRequest = createResource({
- url: "buzz.api.create_cancellation_request",
+ url: "buzz.api.tickets.create_cancellation_request",
onSuccess: (data: any) => {
submitting.value = false;
const ticketCount = selectedTickets.value.length;
diff --git a/dashboard/src/components/SponsorshipPaymentDialog.vue b/dashboard/src/components/SponsorshipPaymentDialog.vue
index be86186c..b31cc8ae 100644
--- a/dashboard/src/components/SponsorshipPaymentDialog.vue
+++ b/dashboard/src/components/SponsorshipPaymentDialog.vue
@@ -214,7 +214,7 @@ const tiers = useList({
// Fetch payment gateways for the event
const paymentGatewaysResource = createResource({
- url: "buzz.api.get_event_payment_gateways",
+ url: "buzz.api.payments.get_event_payment_gateways",
onSuccess: (data: any[]) => {
paymentGateways.value = data || [];
},
@@ -229,7 +229,7 @@ function fetchPaymentGateways() {
// Resource to create payment link
const paymentLink = createResource({
- url: "buzz.api.create_sponsorship_payment_link",
+ url: "buzz.api.sponsorships.create_sponsorship_payment_link",
onSuccess: (paymentUrl: string) => {
emit("payment-started");
closeDialog();
diff --git a/dashboard/src/components/TicketTransferDialog.vue b/dashboard/src/components/TicketTransferDialog.vue
index 893685d2..1b8de5a9 100644
--- a/dashboard/src/components/TicketTransferDialog.vue
+++ b/dashboard/src/components/TicketTransferDialog.vue
@@ -82,7 +82,7 @@ const transferForm = ref({
// Transfer ticket resource
const transferResource = createResource({
- url: "buzz.api.transfer_ticket",
+ url: "buzz.api.tickets.transfer_ticket",
onSuccess: () => {
toast.success(__("Ticket transferred successfully!"));
isOpen.value = false;
diff --git a/dashboard/src/components/TransferTicketDialog.vue b/dashboard/src/components/TransferTicketDialog.vue
index 2f891ee7..47e7c2ac 100644
--- a/dashboard/src/components/TransferTicketDialog.vue
+++ b/dashboard/src/components/TransferTicketDialog.vue
@@ -71,7 +71,7 @@ const transferForm = ref({
// Transfer ticket resource
const transferResource = createResource({
- url: "buzz.api.transfer_ticket",
+ url: "buzz.api.tickets.transfer_ticket",
onSuccess: () => {
toast.success("Ticket transferred successfully!");
isOpen.value = false;
diff --git a/dashboard/src/composables/useLanguage.ts b/dashboard/src/composables/useLanguage.ts
index e916126d..cd1e6f11 100644
--- a/dashboard/src/composables/useLanguage.ts
+++ b/dashboard/src/composables/useLanguage.ts
@@ -11,7 +11,7 @@ interface LanguageComposable {
export function useLanguage(): LanguageComposable {
const availableLanguages = createResource({
- url: "buzz.api.get_enabled_languages",
+ url: "buzz.api.account.get_enabled_languages",
auto: true,
cache: "enabled_languages",
})
@@ -21,7 +21,7 @@ export function useLanguage(): LanguageComposable {
})
const switchLanguage = createResource({
- url: "buzz.api.update_user_language",
+ url: "buzz.api.account.update_user_language",
onSuccess() {
// Reload the page to apply new translations
window.location.reload()
diff --git a/dashboard/src/composables/useTicketValidation.ts b/dashboard/src/composables/useTicketValidation.ts
index 3dda251a..36130716 100644
--- a/dashboard/src/composables/useTicketValidation.ts
+++ b/dashboard/src/composables/useTicketValidation.ts
@@ -92,7 +92,7 @@ const showDebouncedToast = (
// Ticket validation resource
const validateTicketResource = createResource({
- url: "buzz.api.validate_ticket_for_checkin",
+ url: "buzz.api.checkin.validate_ticket_for_checkin",
onSuccess: (data: ValidationResult) => {
validationResult.value = data
showTicketModal.value = true
@@ -129,7 +129,7 @@ const validateTicketResource = createResource({
// Check-in resource
const checkInResource = createResource({
- url: "buzz.api.checkin_ticket",
+ url: "buzz.api.checkin.checkin_ticket",
onSuccess: (data: ValidationResult) => {
validationResult.value = data
showTicketModal.value = false
diff --git a/dashboard/src/data/user.ts b/dashboard/src/data/user.ts
index e13a118e..65a73136 100644
--- a/dashboard/src/data/user.ts
+++ b/dashboard/src/data/user.ts
@@ -1,6 +1,6 @@
import { createResource } from "frappe-ui"
export const userResource = createResource({
- url: "buzz.api.get_user_info",
+ url: "buzz.api.account.get_user_info",
cache: "User",
})
diff --git a/dashboard/src/pages/Account.vue b/dashboard/src/pages/Account.vue
index a35b2af9..e35ef565 100644
--- a/dashboard/src/pages/Account.vue
+++ b/dashboard/src/pages/Account.vue
@@ -39,7 +39,7 @@ const route = useRoute();
const router = useRouter();
const sponsorships = createResource({
- url: "buzz.api.get_user_sponsorship_inquiries",
+ url: "buzz.api.sponsorships.get_user_sponsorship_inquiries",
auto: true,
cacheKey: "account-sponsorships-check",
onError: console.error,
diff --git a/dashboard/src/pages/BookTickets.vue b/dashboard/src/pages/BookTickets.vue
index 48c73590..11fa4b79 100644
--- a/dashboard/src/pages/BookTickets.vue
+++ b/dashboard/src/pages/BookTickets.vue
@@ -110,7 +110,7 @@ const goToHome = () => {
};
const eventBookingResource = createResource({
- url: "buzz.api.get_event_booking_data",
+ url: "buzz.api.booking.get_event_booking_data",
params: {
event_route: props.eventRoute,
},
diff --git a/dashboard/src/pages/BookingDetails.vue b/dashboard/src/pages/BookingDetails.vue
index dd9ec886..6b12396a 100644
--- a/dashboard/src/pages/BookingDetails.vue
+++ b/dashboard/src/pages/BookingDetails.vue
@@ -156,7 +156,7 @@ const { showSuccessMessage } = usePaymentSuccess({
const showCancellationDialog = ref(false);
const bookingDetails = createResource({
- url: "buzz.api.get_booking_details",
+ url: "buzz.api.booking.get_booking_details",
params: { booking_id: props.bookingId },
auto: true,
onSuccess: (data: Record) => {
diff --git a/dashboard/src/pages/BookingSuccess.vue b/dashboard/src/pages/BookingSuccess.vue
index ac40a061..8e98cf99 100644
--- a/dashboard/src/pages/BookingSuccess.vue
+++ b/dashboard/src/pages/BookingSuccess.vue
@@ -94,7 +94,7 @@ const showPaymentSummary = computed(() => {
});
const confirmation = createResource({
- url: "buzz.api.get_booking_confirmation",
+ url: "buzz.api.booking.get_booking_confirmation",
params: { booking_id: props.bookingId, token: route.query.token },
auto: true,
onSuccess: (data: Record) => {
diff --git a/dashboard/src/pages/RegisterInterest.vue b/dashboard/src/pages/RegisterInterest.vue
index 18012657..f948e748 100644
--- a/dashboard/src/pages/RegisterInterest.vue
+++ b/dashboard/src/pages/RegisterInterest.vue
@@ -90,7 +90,7 @@ const renderedDescription = computed(() => {
});
const campaignResource = createResource({
- url: "buzz.api.get_campaign_details",
+ url: "buzz.api.campaigns.get_campaign_details",
params: {
campaign: props.campaign,
},
@@ -104,7 +104,7 @@ const campaignResource = createResource({
});
const registerResource = createResource({
- url: "buzz.api.register_campaign_interest",
+ url: "buzz.api.campaigns.register_campaign_interest",
onSuccess: () => {
registered.value = true;
errorMessage.value = null;
diff --git a/dashboard/src/pages/SponsorshipDetails.vue b/dashboard/src/pages/SponsorshipDetails.vue
index 4fc55499..f366cc0c 100644
--- a/dashboard/src/pages/SponsorshipDetails.vue
+++ b/dashboard/src/pages/SponsorshipDetails.vue
@@ -375,7 +375,7 @@ const showPaymentDialog = ref(false);
const showWithdrawDialog = ref(false);
const enquiryDetails = createResource({
- url: "buzz.api.get_sponsorship_details",
+ url: "buzz.api.sponsorships.get_sponsorship_details",
params: {
enquiry_id: props.enquiryId,
},
@@ -384,7 +384,7 @@ const enquiryDetails = createResource({
// Resource to withdraw sponsorship inquiry
const withdrawResource = createResource({
- url: "buzz.api.withdraw_sponsorship_enquiry",
+ url: "buzz.api.sponsorships.withdraw_sponsorship_enquiry",
makeParams() {
return {
enquiry_id: props.enquiryId,
diff --git a/dashboard/src/pages/SponsorshipsList.vue b/dashboard/src/pages/SponsorshipsList.vue
index ae0b65ef..2bd7a57d 100644
--- a/dashboard/src/pages/SponsorshipsList.vue
+++ b/dashboard/src/pages/SponsorshipsList.vue
@@ -70,7 +70,7 @@ const columns = [
];
const sponsorships = createResource({
- url: "buzz.api.get_user_sponsorship_inquiries",
+ url: "buzz.api.sponsorships.get_user_sponsorship_inquiries",
auto: true,
cacheKey: "sponsorships-list",
onError: console.error,
diff --git a/dashboard/src/pages/TicketDetails.vue b/dashboard/src/pages/TicketDetails.vue
index d4f3a42d..14fd3adc 100644
--- a/dashboard/src/pages/TicketDetails.vue
+++ b/dashboard/src/pages/TicketDetails.vue
@@ -375,7 +375,7 @@ const showAddOnPreferenceDialog = ref(false);
const showQRExpanded = ref(false);
const ticketDetails = createResource({
- url: "buzz.api.get_ticket_details",
+ url: "buzz.api.tickets.get_ticket_details",
params: { ticket_id: props.ticketId },
auto: true,
transform(data: any) {
diff --git a/dashboard/src/translation.ts b/dashboard/src/translation.ts
index 34d621af..18ff8b18 100644
--- a/dashboard/src/translation.ts
+++ b/dashboard/src/translation.ts
@@ -65,7 +65,7 @@ const translate: TranslateFn = (message, replace, context = null) => {
function fetchTranslations(_lang?: string) {
createResource({
- url: "buzz.api.get_translations",
+ url: "buzz.api.account.get_translations",
auto: true,
transform: (data: Record) => {
window.translatedMessages = data