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:

- - -

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:

- - -

Your Ticket Details:

- - -

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:

+ + +

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:

+ + +

Your Ticket Details:

+ + +

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