diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0485e32..d59d9204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,8 +94,10 @@ jobs: run: | bench get-app buzz $GITHUB_WORKSPACE bench get-app payments + bench get-app zoom_integration https://github.com/bwhtech/zoom_integration bench setup requirements --dev bench new-site --db-root-password root --admin-password admin test_site + bench --site test_site install-app zoom_integration bench --site test_site install-app buzz bench build env: diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py index 045cb898..b5048227 100644 --- a/buzz/api/__init__.py +++ b/buzz/api/__init__.py @@ -28,7 +28,7 @@ get_payment_link_for_booking, get_payment_link_for_sponsorship, ) -from buzz.utils import build_event_datetimes, is_app_installed +from buzz.utils import ZOOM_BACKED_CATEGORIES, build_event_datetimes, is_app_installed OFFLINE_PAYMENT_METHOD = "Offline" @@ -239,7 +239,7 @@ def get_event_booking_data(event_route: str) -> dict: "category": event_doc.category, "banner_image": event_doc.banner_image, "short_description": event_doc.short_description, - "free_webinar": event_doc.free_webinar, + "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, @@ -438,10 +438,10 @@ def process_booking( ) phone_map = {cf["fieldname"]: cf["label"] for cf in phone_fields} - if event_doc.category == "Webinars": + 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 webinar events")) + frappe.throw(_("Last name is required for all attendees in Zoom events")) for attendee in attendees: first_name = (attendee.get("first_name") or "").strip() @@ -588,7 +588,7 @@ def get_booking_confirmation(booking_id: str, token: str | None = None) -> dict: "start_time": event_doc.start_time, "end_time": event_doc.end_time, "short_description": event_doc.get("short_description"), - "free_webinar": event_doc.get("free_webinar"), + "free_event": event_doc.get("free_event"), }, "venue": venue, "booking": { @@ -1036,16 +1036,17 @@ def get_ticket_details(ticket_id: str) -> dict: ) details.zoom_join_url = None - if hasattr(ticket_doc, "zoom_webinar_registration") and ticket_doc.zoom_webinar_registration: + if hasattr(ticket_doc, "zoom_session_registration") and ticket_doc.zoom_session_registration: zoom_registration = frappe.db.get_value( - "Zoom Webinar Registration", - ticket_doc.zoom_webinar_registration, - ["join_url", "webinar"], + "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_webinar = zoom_registration.webinar + details.zoom_reference_doctype = zoom_registration.reference_doctype + details.zoom_reference_name = zoom_registration.reference_name return details diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js index c5dfcf6f..ec4cf6e1 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.js +++ b/buzz/events/doctype/buzz_event/buzz_event.js @@ -1,6 +1,12 @@ // Copyright (c) 2025, BWH Studios and contributors // For license information, please see license.txt +// Keep in sync with ZOOM_BACKED_CATEGORIES in buzz/utils.py +const ZOOM_SESSION_BY_CATEGORY = { + Webinars: "webinar", + "Zoom Meeting": "meeting", +}; + const FIELD_LABELS = { category: __("Category"), host: __("Host"), @@ -353,24 +359,31 @@ frappe.ui.form.on("Buzz Event", { add_zoom_custom_actions(frm) { const installed_apps = frappe.boot.app_data.map((app) => app.app_name); - if (!installed_apps.includes("zoom_integration") || frm.doc.category != "Webinars") { + const session = ZOOM_SESSION_BY_CATEGORY[frm.doc.category]; + if (!installed_apps.includes("zoom_integration") || !session) { return; } - if (frm.doc.zoom_webinar) { - frm.add_custom_button(__("View Webinar on Zoom"), () => { - window.open(`https://zoom.us/webinar/${frm.doc.zoom_webinar}`, "_blank"); + const labels = { + webinar: { create: __("Create Webinar on Zoom"), view: __("View Webinar") }, + meeting: { create: __("Create Meeting on Zoom"), view: __("View Meeting") }, + }[session]; + + const existing = frm.doc[`zoom_${session}`]; + if (existing) { + frm.add_custom_button(labels.view, () => { + window.open(`https://zoom.us/${session}/${existing}`, "_blank"); }); return; } - const btn = frm.add_custom_button(__("Create Webinar on Zoom"), () => { + const create_btn = frm.add_custom_button(labels.create, () => { frm.call({ doc: frm.doc, - method: "create_webinar_on_zoom", - btn, + method: `create_${session}_on_zoom`, + btn: create_btn, freeze: true, - }).then(({ message }) => { + }).then(() => { frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); }); }); @@ -378,11 +391,7 @@ frappe.ui.form.on("Buzz Event", { category(frm) { if (!frm.is_new()) return; - if (frm.doc.category === "Webinars") { - frm.set_value("attach_email_ticket", 0); - } else { - frm.set_value("attach_email_ticket", 1); - } + frm.set_value("attach_email_ticket", frm.doc.category in ZOOM_SESSION_BY_CATEGORY ? 0 : 1); }, }); diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 992f98cc..14e38bb1 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -8,7 +8,7 @@ "field_order": [ "title", "category", - "free_webinar", + "free_event", "medium", "column_break_cxqh", "banner_image", @@ -390,10 +390,10 @@ }, { "default": "0", - "depends_on": "eval:doc.category==\"Webinars\"", - "fieldname": "free_webinar", + "depends_on": "eval:[\"Webinars\",\"Zoom Meeting\"].includes(doc.category)", + "fieldname": "free_event", "fieldtype": "Check", - "label": "Free Webinar?" + "label": "Free Event?" }, { "fieldname": "connections_tab", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index dc001142..22b74905 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -56,7 +56,7 @@ class BuzzEvent(Document): end_time: DF.Time external_registration_page: DF.Check featured_speakers: DF.Table[EventFeaturedSpeaker] - free_webinar: DF.Check + free_event: DF.Check guest_verification_method: DF.Literal["None", "Email OTP", "Phone OTP"] host: DF.Link is_published: DF.Check @@ -236,8 +236,30 @@ def create_webinar_on_zoom(self): return zoom_webinar + @frappe.whitelist() + @only_if_app_installed("zoom_integration", raise_exception=True) + def create_meeting_on_zoom(self): + if not self.end_time: + frappe.throw(_("End time is needed for Zoom Meeting creation")) + + zoom_meeting = frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": self.title, + "date": self.start_date, + "start_time": self.start_time, + "duration": int(time_diff_in_seconds(self.end_time, self.start_time)), + "timezone": self.time_zone, + } + ).insert() + + self.db_set("zoom_meeting", zoom_meeting.name) + + return zoom_meeting + def on_update(self): self.update_zoom_webinar() + self.update_zoom_meeting() @only_if_app_installed("zoom_integration") def update_zoom_webinar(self): @@ -261,6 +283,28 @@ def update_zoom_webinar(self): ) webinar.save() + @only_if_app_installed("zoom_integration") + def update_zoom_meeting(self): + if not self.zoom_meeting: + return + + if ( + self.has_value_changed("start_date") + or self.has_value_changed("end_time") + or self.has_value_changed("start_time") + or self.has_value_changed("time_zone") + ): + meeting = frappe.get_doc("Zoom Meeting", self.zoom_meeting) + meeting.update( + { + "date": self.start_date, + "start_time": self.start_time, + "duration": int(time_diff_in_seconds(self.end_time, self.start_time)), + "timezone": self.time_zone, + } + ) + meeting.save() + @frappe.whitelist() def create_from_template(template_name: str, options: str, additional_fields: str = "{}") -> str: diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index fb7e5882..fa20b849 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -1022,3 +1022,82 @@ def test_backfill_patch_skips_events_missing_start_fields(self): backfill_time_zone_labels() self.assertEqual(frappe.db.get_value("Buzz Event", event.name, "time_zone_label"), "") + + +class TestBuzzEventZoomMeeting(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + if not frappe.db.exists("Event Category", "Test Category"): + frappe.get_doc({"doctype": "Event Category", "category_name": "Test Category"}).insert( + ignore_permissions=True + ) + if not frappe.db.exists("Event Host", "Test Host"): + frappe.get_doc({"doctype": "Event Host", "host_name": "Test Host"}).insert( + ignore_permissions=True + ) + + def tearDown(self): + frappe.db.rollback() + + def _make_event(self): + return frappe.get_doc( + { + "doctype": "Buzz Event", + "title": "Meeting Event", + "category": "Test Category", + "host": "Test Host", + "start_date": "2026-08-01", + "end_date": "2026-08-01", + "start_time": "10:00:00", + "end_time": "11:00:00", + } + ).insert(ignore_permissions=True) + + def test_create_meeting_on_zoom_links_meeting_to_event(self): + from zoom_integration.tests.zoom_fixtures import create_meeting_response + + meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + event = self._make_event() + response = create_meeting_response() + + with patch(f"{meeting_controller}.create_zoom_session", return_value=response): + meeting = event.create_meeting_on_zoom() + + self.assertTrue(meeting.name) + event.reload() + self.assertEqual(event.zoom_meeting, meeting.name) + self.assertEqual(meeting.zoom_meeting_id, str(response["id"])) + + def test_event_stores_the_zoom_meeting_id_the_desk_link_is_built_from(self): + """buzz_event.js builds https://zoom.us/meeting/ from this field.""" + from zoom_integration.tests.zoom_fixtures import create_meeting_response + + meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + event = self._make_event() + response = create_meeting_response() + + with patch(f"{meeting_controller}.create_zoom_session", return_value=response): + event.create_meeting_on_zoom() + + event.reload() + self.assertEqual(event.zoom_meeting, str(response["id"])) + + def test_update_event_schedule_pushes_to_zoom_meeting(self): + from zoom_integration.tests.zoom_fixtures import CREATE_MEETING_RESPONSE + + meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + event = self._make_event() + + with patch(f"{meeting_controller}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + event.create_meeting_on_zoom() + + # Note: do not reload() — Time fields come back as timedelta and trip event + # validation's time diff. The in-memory doc keeps string times and has + # zoom_meeting set via db_set already. + with patch(f"{meeting_controller}.update_zoom_session") as mock_update: + event.end_time = "12:00:00" + event.save(ignore_permissions=True) + + mock_update.assert_called_once() + self.assertEqual(mock_update.call_args.args[0], "meetings") diff --git a/buzz/events/doctype/event_category/test_event_category.py b/buzz/events/doctype/event_category/test_event_category.py index 43e0e2e1..619b9efa 100644 --- a/buzz/events/doctype/event_category/test_event_category.py +++ b/buzz/events/doctype/event_category/test_event_category.py @@ -1,20 +1,13 @@ # Copyright (c) 2025, BWH Studios and Contributors # See license.txt -# import frappe +import frappe from frappe.tests import IntegrationTestCase -# On IntegrationTestCase, the doctype test records and all -# link-field test record dependencies are recursively loaded -# Use these module variables to add/remove to/from that list -EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +from buzz.utils import ZOOM_BACKED_CATEGORIES class IntegrationTestEventCategory(IntegrationTestCase): - """ - Integration tests for EventCategory. - Use this class for testing interactions between multiple components. - """ - - pass + def test_zoom_backed_categories_are_seeded(self): + for category in ZOOM_BACKED_CATEGORIES: + self.assertTrue(frappe.db.exists("Event Category", category), category) diff --git a/buzz/install.py b/buzz/install.py index fb7677a4..0d62e046 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -49,6 +49,13 @@ "options": "Zoom Webinar", "insert_after": "zoom_integration_tab", }, + { + "fieldname": "zoom_meeting", + "label": "Zoom Meeting", + "fieldtype": "Link", + "options": "Zoom Meeting", + "insert_after": "zoom_webinar", + }, ], "Buzz Settings": [ { @@ -67,10 +74,10 @@ ], "Event Ticket": [ { - "fieldname": "zoom_webinar_registration", - "label": "Zoom Webinar Registration", + "fieldname": "zoom_session_registration", + "label": "Zoom Session Registration", "fieldtype": "Link", - "options": "Zoom Webinar Registration", + "options": "Zoom Session Registration", "insert_after": "ticket_type", "read_only": 1, }, @@ -121,6 +128,8 @@ def after_install(): def on_migrate(): + # insert is ignore_if_duplicate, so this only fills in categories added since install + create_event_categories() create_talk_proposal_statuses() create_custom_fields() @@ -209,6 +218,14 @@ def create_event_categories(): "name": "Webinars", "icon_svg": """ +""", + "enabled": 1, + }, + { + "name": "Zoom Meeting", + "icon_svg": """ + + """, "enabled": 1, }, diff --git a/buzz/patches.txt b/buzz/patches.txt index 3c8ab29b..954a6e2b 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -13,3 +13,5 @@ buzz.patches.set_applies_to_for_existing_coupons buzz.patches.set_payment_status_for_existing_bookings buzz.patches.normalize_phone_format buzz.patches.set_time_zone_label_for_existing_events +buzz.patches.rename_zoom_registration_field +buzz.patches.rename_free_webinar_to_free_event diff --git a/buzz/patches/rename_free_webinar_to_free_event.py b/buzz/patches/rename_free_webinar_to_free_event.py new file mode 100644 index 00000000..6310ea91 --- /dev/null +++ b/buzz/patches/rename_free_webinar_to_free_event.py @@ -0,0 +1,18 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + +DOCTYPES = ("Buzz Event", "Event Proposal") +OLD_FIELD = "free_webinar" +NEW_FIELD = "free_event" + + +def execute(): + """The flag applies to any Zoom-backed event, not just webinars. + + Both doctypes rename together: Event Proposal maps onto Buzz Event through + get_mapped_doc, which matches on fieldname, so a half-rename would silently + stop carrying the flag over. The old column is left for `bench trim-tables`. + """ + for doctype in DOCTYPES: + if frappe.db.has_column(doctype, OLD_FIELD): + rename_field(doctype, OLD_FIELD, NEW_FIELD) diff --git a/buzz/patches/rename_zoom_registration_field.py b/buzz/patches/rename_zoom_registration_field.py new file mode 100644 index 00000000..8d024ed5 --- /dev/null +++ b/buzz/patches/rename_zoom_registration_field.py @@ -0,0 +1,26 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + +from buzz.install import create_zoom_integration_custom_fields + +DOCTYPE = "Event Ticket" +OLD_FIELD = "zoom_webinar_registration" +NEW_FIELD = "zoom_session_registration" + + +def execute(): + """Follow zoom_integration renaming Zoom Webinar Registration -> Zoom Session Registration. + + No-op on sites without zoom_integration, where the custom field was never created. + """ + if not frappe.db.has_column(DOCTYPE, OLD_FIELD): + return + + # rename_field copies values into an existing field, it does not create one. The + # after_migrate hook that creates our custom fields runs after patches, so do it here. + create_zoom_integration_custom_fields() + + rename_field(DOCTYPE, OLD_FIELD, NEW_FIELD) + + # the old column is left behind for `bench trim-tables` to reclaim + frappe.delete_doc("Custom Field", f"{DOCTYPE}-{OLD_FIELD}", ignore_missing=True, force=True) diff --git a/buzz/proposals/doctype/event_proposal/event_proposal.json b/buzz/proposals/doctype/event_proposal/event_proposal.json index f94de1bf..780ed273 100644 --- a/buzz/proposals/doctype/event_proposal/event_proposal.json +++ b/buzz/proposals/doctype/event_proposal/event_proposal.json @@ -7,7 +7,7 @@ "engine": "InnoDB", "field_order": [ "title", - "free_webinar", + "free_event", "column_break_bixo", "category", "medium", @@ -151,10 +151,10 @@ }, { "default": "0", - "depends_on": "eval:doc.event_category==\"Webinars\"", - "fieldname": "free_webinar", + "depends_on": "eval:[\"Webinars\",\"Zoom Meeting\"].includes(doc.event_category)", + "fieldname": "free_event", "fieldtype": "Check", - "label": "Free Webinar?" + "label": "Free Event?" }, { "fieldname": "amended_from", diff --git a/buzz/proposals/doctype/event_proposal/event_proposal.py b/buzz/proposals/doctype/event_proposal/event_proposal.py index adb9f261..c121f9a8 100644 --- a/buzz/proposals/doctype/event_proposal/event_proposal.py +++ b/buzz/proposals/doctype/event_proposal/event_proposal.py @@ -25,7 +25,7 @@ class EventProposal(Document): end_date: DF.Date | None end_time: DF.Time | None event_banner: DF.AttachImage | None - free_webinar: DF.Check + free_event: DF.Check host: DF.Link | None host_company: DF.Data | None host_company_logo: DF.AttachImage | None diff --git a/buzz/proposals/doctype/event_proposal/test_event_proposal.py b/buzz/proposals/doctype/event_proposal/test_event_proposal.py index 3bebc06b..1bb95759 100644 --- a/buzz/proposals/doctype/event_proposal/test_event_proposal.py +++ b/buzz/proposals/doctype/event_proposal/test_event_proposal.py @@ -144,3 +144,13 @@ def test_submit_without_host_or_company_throws(self): proposal = self.make_proposal(status="Approved") with self.assertRaises(frappe.ValidationError): proposal.submit() + + def test_free_event_flag_carries_to_the_created_event(self): + """get_mapped_doc matches on fieldname, so both doctypes must use the same one.""" + company = f"Free {frappe.generate_hash(length=6)}" + proposal = self.make_proposal(host_company=company, status="Approved", free_event=1) + + proposal.submit() + + event = frappe.get_doc("Buzz Event", {"proposal": proposal.name}) + self.assertEqual(event.free_event, 1) diff --git a/buzz/ticketing/doctype/event_booking/test_event_booking.py b/buzz/ticketing/doctype/event_booking/test_event_booking.py index 7fb8796d..3e10595d 100644 --- a/buzz/ticketing/doctype/event_booking/test_event_booking.py +++ b/buzz/ticketing/doctype/event_booking/test_event_booking.py @@ -1378,3 +1378,50 @@ def test_event_template_takes_precedence_over_global(self, mock_sendmail): mock_sendmail.assert_called_once() self.assertIn("EVENT", mock_sendmail.call_args[1]["subject"]) self.assertNotIn("GLOBAL", mock_sendmail.call_args[1]["subject"]) + + +class TestZoomBackedCategoryBooking(IntegrationTestCase): + """Zoom needs a last name on every registrant, for meetings as much as webinars.""" + + def setUp(self): + super().setUp() + self.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + self.ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "event": self.event.name, + "title": "Zoom Category Ticket", + "price": 0, + "is_published": True, + } + ).insert() + + def tearDown(self): + frappe.db.rollback() + + def _book_without_last_name(self, category): + from buzz.api import process_booking + + self.event.db_set("category", category) + return process_booking( + attendees=[ + { + "first_name": "Nolast", + "email": "nolast@example.com", + "ticket_type": str(self.ticket_type.name), + "add_ons": [], + } + ], + event=str(self.event.name), + ) + + def test_last_name_required_for_webinar_category(self): + self.assertRaises(frappe.ValidationError, self._book_without_last_name, "Webinars") + + def test_last_name_required_for_zoom_meeting_category(self): + self.assertRaises(frappe.ValidationError, self._book_without_last_name, "Zoom Meeting") + + def test_last_name_not_required_for_other_categories(self): + result = self._book_without_last_name("Conferences") + + self.assertIn("booking_name", result) diff --git a/buzz/ticketing/doctype/event_ticket/event_ticket.py b/buzz/ticketing/doctype/event_ticket/event_ticket.py index 9b55e0b5..755a2f7d 100644 --- a/buzz/ticketing/doctype/event_ticket/event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/event_ticket.py @@ -67,21 +67,28 @@ def create_zoom_registration_if_applicable(self): event_doc = frappe.get_cached_doc("Buzz Event", self.event) if event_doc.zoom_webinar: - doc = { - "doctype": "Zoom Webinar Registration", - "webinar": event_doc.zoom_webinar, + session_ref = {"reference_doctype": "Zoom Webinar", "reference_name": event_doc.zoom_webinar} + elif event_doc.get("zoom_meeting"): + session_ref = {"reference_doctype": "Zoom Meeting", "reference_name": event_doc.zoom_meeting} + else: + return + + registration = frappe.get_doc( + { + "doctype": "Zoom Session Registration", + **session_ref, "email": self.attendee_email, "first_name": self.first_name, "last_name": self.last_name or "-", } - registration = frappe.get_doc(doc).insert(ignore_permissions=True) - - try: - registration.submit() - # Store the registration reference on the ticket - self.db_set("zoom_webinar_registration", registration.name) - except Exception: - frappe.log_error("Failed to create registration on Zoom") + ).insert(ignore_permissions=True) + + try: + registration.submit() + # Store the registration reference on the ticket (holds meeting or webinar registration) + self.db_set("zoom_session_registration", registration.name) + except Exception: + frappe.log_error("Failed to create registration on Zoom") def send_user_invitation(self): invite_by_email( diff --git a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py index d32bd40c..6eb6331d 100644 --- a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py @@ -174,3 +174,133 @@ def test_generate_qr_code_file_creates_attachment(self): # Cleanup file_doc.delete() + + +class TestEventTicketZoomMeeting(IntegrationTestCase): + def setUp(self): + # tearDown rolls back, so the fixtures are rebuilt per test rather than per class. + super().setUp() + self.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + self.ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "title": "Meeting TT", + "event": self.event.name, + "currency": "USD", + } + ).insert(ignore_permissions=True, ignore_if_duplicate=True) + + def tearDown(self): + frappe.db.rollback() + + def _submit_ticket(self, email="alice@example.com"): + ticket = frappe.get_doc( + { + "doctype": "Event Ticket", + "event": self.event.name, + "ticket_type": self.ticket_type.name, + "first_name": "Alice", + "last_name": "Smith", + "attendee_email": email, + } + ).insert(ignore_permissions=True) + ticket.submit() + return ticket + + def test_ticket_registration_points_at_the_events_zoom_meeting(self): + from zoom_integration.tests.zoom_fixtures import ( + add_meeting_registrant_response, + create_meeting_response, + ) + + meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + + with patch(f"{meeting_controller}.create_zoom_session", return_value=create_meeting_response()): + meeting = frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Ticket Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert(ignore_permissions=True) + + self.event.db_set("zoom_meeting", meeting.name) + registrant = add_meeting_registrant_response() + + with patch(f"{meeting_controller}.add_zoom_registrant", return_value=registrant): + ticket = self._submit_ticket() + + self.assertTrue(ticket.zoom_session_registration) + registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) + self.assertEqual(registration.reference_doctype, "Zoom Meeting") + self.assertEqual(registration.reference_name, meeting.name) + self.assertEqual(registration.registrant_id, registrant["registrant_id"]) + + def test_ticket_registration_points_at_the_events_zoom_webinar(self): + from zoom_integration.tests.zoom_fixtures import ( + add_webinar_registrant_response, + create_webinar_response, + mock_zoom_post, + ) + + webinar_controller = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar" + + with mock_zoom_post(webinar_controller, 201, create_webinar_response()): + webinar = frappe.get_doc( + { + "doctype": "Zoom Webinar", + "title": "Ticket Webinar", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert(ignore_permissions=True) + + self.event.db_set("zoom_webinar", webinar.name) + registrant = add_webinar_registrant_response() + + with mock_zoom_post(webinar_controller, 200, registrant): + ticket = self._submit_ticket("carol@example.com") + + registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) + self.assertEqual(registration.reference_doctype, "Zoom Webinar") + self.assertEqual(registration.reference_name, webinar.name) + self.assertEqual(registration.registrant_id, registrant["registrant_id"]) + + def test_ticket_details_expose_the_zoom_session_reference(self): + from zoom_integration.tests.zoom_fixtures import ( + add_meeting_registrant_response, + create_meeting_response, + ) + + from buzz.api import get_ticket_details + + meeting_controller = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + + with patch(f"{meeting_controller}.create_zoom_session", return_value=create_meeting_response()): + meeting = frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Details Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert(ignore_permissions=True) + + self.event.db_set("zoom_meeting", meeting.name) + registrant = add_meeting_registrant_response() + + with patch(f"{meeting_controller}.add_zoom_registrant", return_value=registrant): + ticket = self._submit_ticket("dana@example.com") + + details = get_ticket_details(ticket.name) + + self.assertEqual(details.zoom_join_url, registrant["join_url"]) + self.assertEqual(details.zoom_reference_doctype, "Zoom Meeting") + self.assertEqual(details.zoom_reference_name, meeting.name) diff --git a/buzz/utils.py b/buzz/utils.py index 6b66337b..3b982e03 100644 --- a/buzz/utils.py +++ b/buzz/utils.py @@ -8,6 +8,10 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.utils import now_datetime +# Categories whose events are run through Zoom. Keyed to the Zoom session doctype each +# one creates, so a lookup doubles as the "is this Zoom-backed?" check. +ZOOM_BACKED_CATEGORIES = {"Webinars": "webinar", "Zoom Meeting": "meeting"} + def is_app_installed(app_name: str) -> bool: """Check if a specified app is installed.""" diff --git a/dashboard/src/components/AttendeeFormControl.vue b/dashboard/src/components/AttendeeFormControl.vue index d579bfa0..51402ddd 100644 --- a/dashboard/src/components/AttendeeFormControl.vue +++ b/dashboard/src/components/AttendeeFormControl.vue @@ -32,7 +32,7 @@ v-model="attendee.last_name" :label="__('Last Name')" :placeholder="__('Enter last name')" - :required="eventDetails.category === 'Webinars'" + :required="isZoomEvent" type="text" /> import { type FrappeField, getFieldDefaultValue } from "@/composables/useCustomFields"; import { formatPriceOrFree } from "@/utils/currency"; +import { isZoomBackedCategory } from "@/utils/zoomCategory"; import { Tooltip } from "frappe-ui"; -import { type PropType } from "vue"; +import { type PropType, computed } from "vue"; import CustomFieldInput from "./CustomFieldInput.vue"; interface AvailableTicketType { @@ -173,6 +171,8 @@ const props = defineProps({ defineEmits(["remove"]); +const isZoomEvent = computed(() => isZoomBackedCategory(props.eventDetails.category)); + // Helper methods to safely access add-on properties const ensureAddOnExists = (addOnName: string) => { if (!props.attendee.add_ons) { diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index d512a824..92acd776 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -64,10 +64,10 @@

- {{ isWebinar ? __("Registration Confirmed!") : __("Booking Confirmed!") }} + {{ isZoomEvent ? __("Registration Confirmed!") : __("Booking Confirmed!") }}

-