From e635ad8f38581014d75ade1140baf00f9f55aba7 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 17:11:14 +0530 Subject: [PATCH 1/8] feat: back Buzz Events with Zoom Meetings (create, sync, ticket registration) Co-Authored-By: Claude Opus 4.8 --- buzz/api/__init__.py | 3 +- buzz/events/doctype/buzz_event/buzz_event.py | 44 +++++++++++++ .../doctype/buzz_event/test_buzz_event.py | 64 +++++++++++++++++++ buzz/install.py | 7 ++ .../doctype/event_ticket/event_ticket.py | 27 +++++--- .../doctype/event_ticket/test_event_ticket.py | 58 +++++++++++++++++ 6 files changed, 192 insertions(+), 11 deletions(-) diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py index 045cb898..e36cbb00 100644 --- a/buzz/api/__init__.py +++ b/buzz/api/__init__.py @@ -1040,12 +1040,13 @@ def get_ticket_details(ticket_id: str) -> dict: zoom_registration = frappe.db.get_value( "Zoom Webinar Registration", ticket_doc.zoom_webinar_registration, - ["join_url", "webinar"], + ["join_url", "webinar", "meeting"], as_dict=True, ) if zoom_registration: details.zoom_join_url = zoom_registration.join_url details.zoom_webinar = zoom_registration.webinar + details.zoom_meeting = zoom_registration.meeting return details diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index dc001142..7ca0fef1 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -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..12bdf58b 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -1022,3 +1022,67 @@ 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() + + with patch(f"{meeting_controller}.create_zoom_session", return_value=CREATE_MEETING_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, "91234567890") + + 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/install.py b/buzz/install.py index fb7677a4..7cd87ca8 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": [ { diff --git a/buzz/ticketing/doctype/event_ticket/event_ticket.py b/buzz/ticketing/doctype/event_ticket/event_ticket.py index 9b55e0b5..e58b0c85 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 = { + registration_ref = {"webinar": event_doc.zoom_webinar} + elif event_doc.get("zoom_meeting"): + registration_ref = {"meeting": event_doc.zoom_meeting} + else: + return + + registration = frappe.get_doc( + { "doctype": "Zoom Webinar Registration", - "webinar": event_doc.zoom_webinar, + **registration_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_webinar_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..bed2f0fa 100644 --- a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py @@ -174,3 +174,61 @@ def test_generate_qr_code_file_creates_attachment(self): # Cleanup file_doc.delete() + + +class TestEventTicketZoomMeeting(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) + cls.ticket_type = frappe.get_doc( + { + "doctype": "Event Ticket Type", + "title": "Meeting TT", + "event": cls.event.name, + "currency": "USD", + } + ).insert(ignore_permissions=True, ignore_if_duplicate=True) + + def tearDown(self): + frappe.db.rollback() + + def test_ticket_creates_meeting_registration_when_event_has_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) + + with patch(f"{meeting_controller}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE): + 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": "alice@example.com", + } + ).insert(ignore_permissions=True) + ticket.submit() + + self.assertTrue(ticket.zoom_webinar_registration) + registration = frappe.get_doc("Zoom Webinar Registration", ticket.zoom_webinar_registration) + self.assertEqual(registration.meeting, meeting.name) + self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") From 9433f60fa99ff394ca01a914cf39fa1c094014ca Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 18:02:45 +0530 Subject: [PATCH 2/8] ci: install zoom_integration app for server tests Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) 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: From e3863ae5dccd2b1c080e4b1997fb7a433e68d483 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sun, 26 Jul 2026 20:28:17 +0530 Subject: [PATCH 3/8] refactor!: follow Zoom Session Registration rename zoom_integration renamed Zoom Webinar Registration to Zoom Session Registration. Point the Event Ticket custom field, its creation in install.py, the registration created on ticket submit and get_ticket_details at the new name, and rename the field itself to zoom_session_registration. rename_field copies values into a field that must already exist, and the after_migrate hook that creates our custom fields runs after patches, so the patch calls create_zoom_integration_custom_fields() first, then copies, then drops the old Custom Field. The old column is left for `bench trim-tables`. Requires zoom_integration to be migrated first: until it is, the custom field points at a doctype that does not exist yet. The dashboard is untouched - get_ticket_details still returns zoom_join_url, zoom_webinar and zoom_meeting under the same keys. BREAKING CHANGE: Event Ticket.zoom_webinar_registration is now zoom_session_registration. Any REST read, custom report or client script naming the old fieldname breaks. Co-Authored-By: Claude Opus 5 --- buzz/api/__init__.py | 6 ++--- buzz/install.py | 6 ++--- buzz/patches.txt | 1 + .../patches/rename_zoom_registration_field.py | 26 +++++++++++++++++++ .../doctype/event_ticket/event_ticket.py | 4 +-- .../doctype/event_ticket/test_event_ticket.py | 4 +-- 6 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 buzz/patches/rename_zoom_registration_field.py diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py index e36cbb00..857d91dd 100644 --- a/buzz/api/__init__.py +++ b/buzz/api/__init__.py @@ -1036,10 +1036,10 @@ 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, + "Zoom Session Registration", + ticket_doc.zoom_session_registration, ["join_url", "webinar", "meeting"], as_dict=True, ) diff --git a/buzz/install.py b/buzz/install.py index 7cd87ca8..ea541b19 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -74,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, }, diff --git a/buzz/patches.txt b/buzz/patches.txt index 3c8ab29b..4ac96077 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -13,3 +13,4 @@ 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 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/ticketing/doctype/event_ticket/event_ticket.py b/buzz/ticketing/doctype/event_ticket/event_ticket.py index e58b0c85..d2d64666 100644 --- a/buzz/ticketing/doctype/event_ticket/event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/event_ticket.py @@ -75,7 +75,7 @@ def create_zoom_registration_if_applicable(self): registration = frappe.get_doc( { - "doctype": "Zoom Webinar Registration", + "doctype": "Zoom Session Registration", **registration_ref, "email": self.attendee_email, "first_name": self.first_name, @@ -86,7 +86,7 @@ def create_zoom_registration_if_applicable(self): try: registration.submit() # Store the registration reference on the ticket (holds meeting or webinar registration) - self.db_set("zoom_webinar_registration", registration.name) + self.db_set("zoom_session_registration", registration.name) except Exception: frappe.log_error("Failed to create registration on Zoom") diff --git a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py index bed2f0fa..eaf7b467 100644 --- a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py @@ -228,7 +228,7 @@ def test_ticket_creates_meeting_registration_when_event_has_meeting(self): ).insert(ignore_permissions=True) ticket.submit() - self.assertTrue(ticket.zoom_webinar_registration) - registration = frappe.get_doc("Zoom Webinar Registration", ticket.zoom_webinar_registration) + self.assertTrue(ticket.zoom_session_registration) + registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) self.assertEqual(registration.meeting, meeting.name) self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") From c6b793a87a52df423fff16594696660f12b3a393 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sun, 26 Jul 2026 20:28:27 +0530 Subject: [PATCH 4/8] feat(events): group Zoom create/view actions on Buzz Event Move the single "Create Webinar on Zoom" button into a "Create on Zoom" group and add the meeting equivalents, so an event can be backed by either a Zoom Webinar or a Zoom Meeting from the form. Viewing a meeting resolves zoom_link off the Zoom Meeting record rather than building a URL, since Zoom Meeting is named by hash and the docname is not the Zoom id. Co-Authored-By: Claude Opus 5 --- buzz/events/doctype/buzz_event/buzz_event.js | 69 ++++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js index c5dfcf6f..e6e5f4c9 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.js +++ b/buzz/events/doctype/buzz_event/buzz_event.js @@ -357,23 +357,64 @@ frappe.ui.form.on("Buzz Event", { return; } + const group = __("Create on Zoom"); + if (frm.doc.zoom_webinar) { - frm.add_custom_button(__("View Webinar on Zoom"), () => { - window.open(`https://zoom.us/webinar/${frm.doc.zoom_webinar}`, "_blank"); - }); - return; + frm.add_custom_button( + __("View Webinar"), + () => { + window.open(`https://zoom.us/webinar/${frm.doc.zoom_webinar}`, "_blank"); + }, + group + ); + } else { + const webinar_btn = frm.add_custom_button( + __("Create Webinar"), + () => { + frm.call({ + doc: frm.doc, + method: "create_webinar_on_zoom", + btn: webinar_btn, + freeze: true, + }).then(() => { + frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); + }); + }, + group + ); } - const btn = frm.add_custom_button(__("Create Webinar on Zoom"), () => { - frm.call({ - doc: frm.doc, - method: "create_webinar_on_zoom", - btn, - freeze: true, - }).then(({ message }) => { - frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); - }); - }); + if (frm.doc.zoom_meeting) { + frm.add_custom_button( + __("View Meeting"), + async () => { + // Zoom Meeting uses hash naming, so frm.doc.zoom_meeting is not the Zoom id. + // zoom_link holds the actual join_url returned by Zoom. + const { message } = await frappe.db.get_value( + "Zoom Meeting", + frm.doc.zoom_meeting, + "zoom_link" + ); + window.open(message.zoom_link, "_blank"); + }, + group + ); + } else { + const meeting_btn = frm.add_custom_button( + __("Create Meeting"), + () => { + frm.call({ + doc: frm.doc, + method: "create_meeting_on_zoom", + btn: meeting_btn, + freeze: true, + }).then(() => { + frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); + }); + }, + group + ); + } }, category(frm) { if (!frm.is_new()) return; From caeda6d6af95923da7a52d46710516ea3d3c2e0c Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 16:32:23 +0530 Subject: [PATCH 5/8] refactor!: follow the Zoom session dynamic reference Zoom Session Registration now carries reference_doctype + reference_name instead of separate webinar and meeting links. Event Ticket builds that pair from whichever the event has, and get_ticket_details returns zoom_reference_doctype / zoom_reference_name in place of zoom_webinar / zoom_meeting. The Buzz Event fields stay two separate links for now; folding those in wants a UX pass on the create/view buttons first. "View Meeting" drops the round-trip that fetched zoom_link. It existed because Zoom Meeting used hash naming; the docname is the Zoom meeting ID now, so the URL is built the same way as the webinar one. BREAKING CHANGE: get_ticket_details no longer returns zoom_webinar or zoom_meeting. Requires zoom_integration with the dynamic session reference. Co-Authored-By: Claude Opus 5 --- buzz/api/__init__.py | 6 +- buzz/events/doctype/buzz_event/buzz_event.js | 11 +- .../doctype/buzz_event/test_buzz_event.py | 21 +++- .../doctype/event_ticket/event_ticket.py | 6 +- .../doctype/event_ticket/test_event_ticket.py | 118 ++++++++++++++---- 5 files changed, 122 insertions(+), 40 deletions(-) diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py index 857d91dd..4919c956 100644 --- a/buzz/api/__init__.py +++ b/buzz/api/__init__.py @@ -1040,13 +1040,13 @@ def get_ticket_details(ticket_id: str) -> dict: zoom_registration = frappe.db.get_value( "Zoom Session Registration", ticket_doc.zoom_session_registration, - ["join_url", "webinar", "meeting"], + ["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_meeting = zoom_registration.meeting + 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 e6e5f4c9..f94e7922 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.js +++ b/buzz/events/doctype/buzz_event/buzz_event.js @@ -387,15 +387,8 @@ frappe.ui.form.on("Buzz Event", { if (frm.doc.zoom_meeting) { frm.add_custom_button( __("View Meeting"), - async () => { - // Zoom Meeting uses hash naming, so frm.doc.zoom_meeting is not the Zoom id. - // zoom_link holds the actual join_url returned by Zoom. - const { message } = await frappe.db.get_value( - "Zoom Meeting", - frm.doc.zoom_meeting, - "zoom_link" - ); - window.open(message.zoom_link, "_blank"); + () => { + window.open(`https://zoom.us/meeting/${frm.doc.zoom_meeting}`, "_blank"); }, group ); diff --git a/buzz/events/doctype/buzz_event/test_buzz_event.py b/buzz/events/doctype/buzz_event/test_buzz_event.py index 12bdf58b..fa20b849 100644 --- a/buzz/events/doctype/buzz_event/test_buzz_event.py +++ b/buzz/events/doctype/buzz_event/test_buzz_event.py @@ -1055,18 +1055,33 @@ def _make_event(self): ).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 + 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=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, "91234567890") + 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 diff --git a/buzz/ticketing/doctype/event_ticket/event_ticket.py b/buzz/ticketing/doctype/event_ticket/event_ticket.py index d2d64666..755a2f7d 100644 --- a/buzz/ticketing/doctype/event_ticket/event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/event_ticket.py @@ -67,16 +67,16 @@ def create_zoom_registration_if_applicable(self): event_doc = frappe.get_cached_doc("Buzz Event", self.event) if event_doc.zoom_webinar: - registration_ref = {"webinar": event_doc.zoom_webinar} + session_ref = {"reference_doctype": "Zoom Webinar", "reference_name": event_doc.zoom_webinar} elif event_doc.get("zoom_meeting"): - registration_ref = {"meeting": event_doc.zoom_meeting} + session_ref = {"reference_doctype": "Zoom Meeting", "reference_name": event_doc.zoom_meeting} else: return registration = frappe.get_doc( { "doctype": "Zoom Session Registration", - **registration_ref, + **session_ref, "email": self.attendee_email, "first_name": self.first_name, "last_name": self.last_name or "-", diff --git a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py index eaf7b467..a5d54aaf 100644 --- a/buzz/ticketing/doctype/event_ticket/test_event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/test_event_ticket.py @@ -177,15 +177,15 @@ def test_generate_qr_code_file_creates_attachment(self): class TestEventTicketZoomMeeting(IntegrationTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.event = frappe.get_doc("Buzz Event", {"route": "test-route"}) - cls.ticket_type = frappe.get_doc( + 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": cls.event.name, + "event": self.event.name, "currency": "USD", } ).insert(ignore_permissions=True, ignore_if_duplicate=True) @@ -193,15 +193,29 @@ def setUpClass(cls): def tearDown(self): frappe.db.rollback() - def test_ticket_creates_meeting_registration_when_event_has_meeting(self): + 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, + 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): + with patch(f"{meeting_controller}.create_zoom_session", return_value=create_meeting_response()): meeting = frappe.get_doc( { "doctype": "Zoom Meeting", @@ -214,21 +228,81 @@ def test_ticket_creates_meeting_registration_when_event_has_meeting(self): ).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_response, + ) + + webinar_controller = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar" - with patch(f"{meeting_controller}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE): - ticket = frappe.get_doc( + with patch(f"{webinar_controller}.requests") as mock_requests: + mock_requests.post.return_value = mock_response(201, create_webinar_response()) + webinar = frappe.get_doc( { - "doctype": "Event Ticket", - "event": self.event.name, - "ticket_type": self.ticket_type.name, - "first_name": "Alice", - "last_name": "Smith", - "attendee_email": "alice@example.com", + "doctype": "Zoom Webinar", + "title": "Ticket Webinar", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", } ).insert(ignore_permissions=True) - ticket.submit() - self.assertTrue(ticket.zoom_session_registration) + self.event.db_set("zoom_webinar", webinar.name) + registrant = add_webinar_registrant_response() + + with patch(f"{webinar_controller}.requests") as mock_requests: + mock_requests.post.return_value = mock_response(200, registrant) + ticket = self._submit_ticket("carol@example.com") + registration = frappe.get_doc("Zoom Session Registration", ticket.zoom_session_registration) - self.assertEqual(registration.meeting, meeting.name) - self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") + 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) From 990c438b5274768cc2fe124b7754a250c2a00d94 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 17:28:51 +0530 Subject: [PATCH 6/8] feat(events): pick the Zoom action from the event category Buzz Events could only be backed by a Zoom Webinar: the desk showed a "Create on Zoom" group holding both a webinar and a meeting button, and everything keyed off the literal category name "Webinars". A new "Zoom Meeting" category picks the meeting path instead. Only one session type applies to an event now, so the button group collapses to a single button labelled for that type -- "Create Webinar on Zoom" or "Create Meeting on Zoom", and the matching View button once created. The category name was hardcoded in eight places, and a second session type would have doubled that, so the mapping now lives in one constant per layer: ZOOM_BACKED_CATEGORIES in buzz/utils.py, ZOOM_SESSION_BY_CATEGORY in buzz_event.js, and isZoomBackedCategory in the dashboard. The two depends_on expressions stay inline because eval strings cannot import. Behaviour that was webinar-only now applies to both, since Zoom needs the same things either way: a last name on every registrant, attach_email_ticket off by default, and the free-event checkbox. isWebinar becomes isZoomEvent in the booking form, which is what it actually meant. create_event_categories is called on migrate so the new category reaches existing sites; the insert is ignore_if_duplicate, so re-running it only fills in what is missing. Co-Authored-By: Claude Opus 5 --- buzz/api/__init__.py | 6 +- buzz/events/doctype/buzz_event/buzz_event.js | 83 +++++++------------ .../events/doctype/buzz_event/buzz_event.json | 2 +- .../event_category/test_event_category.py | 17 ++-- buzz/install.py | 10 +++ .../event_proposal/event_proposal.json | 2 +- .../event_booking/test_event_booking.py | 47 +++++++++++ buzz/utils.py | 4 + .../src/components/AttendeeFormControl.vue | 10 ++- dashboard/src/components/BookingForm.vue | 19 +++-- dashboard/src/utils/zoomCategory.test.ts | 21 +++++ dashboard/src/utils/zoomCategory.ts | 9 ++ 12 files changed, 146 insertions(+), 84 deletions(-) create mode 100644 dashboard/src/utils/zoomCategory.test.ts create mode 100644 dashboard/src/utils/zoomCategory.ts diff --git a/buzz/api/__init__.py b/buzz/api/__init__.py index 4919c956..22cff63e 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" @@ -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() diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js index f94e7922..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,70 +359,39 @@ 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; } - const group = __("Create on Zoom"); + const labels = { + webinar: { create: __("Create Webinar on Zoom"), view: __("View Webinar") }, + meeting: { create: __("Create Meeting on Zoom"), view: __("View Meeting") }, + }[session]; - if (frm.doc.zoom_webinar) { - frm.add_custom_button( - __("View Webinar"), - () => { - window.open(`https://zoom.us/webinar/${frm.doc.zoom_webinar}`, "_blank"); - }, - group - ); - } else { - const webinar_btn = frm.add_custom_button( - __("Create Webinar"), - () => { - frm.call({ - doc: frm.doc, - method: "create_webinar_on_zoom", - btn: webinar_btn, - freeze: true, - }).then(() => { - frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); - }); - }, - group - ); + const existing = frm.doc[`zoom_${session}`]; + if (existing) { + frm.add_custom_button(labels.view, () => { + window.open(`https://zoom.us/${session}/${existing}`, "_blank"); + }); + return; } - if (frm.doc.zoom_meeting) { - frm.add_custom_button( - __("View Meeting"), - () => { - window.open(`https://zoom.us/meeting/${frm.doc.zoom_meeting}`, "_blank"); - }, - group - ); - } else { - const meeting_btn = frm.add_custom_button( - __("Create Meeting"), - () => { - frm.call({ - doc: frm.doc, - method: "create_meeting_on_zoom", - btn: meeting_btn, - freeze: true, - }).then(() => { - frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); - }); - }, - group - ); - } + const create_btn = frm.add_custom_button(labels.create, () => { + frm.call({ + doc: frm.doc, + method: `create_${session}_on_zoom`, + btn: create_btn, + freeze: true, + }).then(() => { + frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); + }); + }); }, 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..66c54d11 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -390,7 +390,7 @@ }, { "default": "0", - "depends_on": "eval:doc.category==\"Webinars\"", + "depends_on": "eval:[\"Webinars\",\"Zoom Meeting\"].includes(doc.category)", "fieldname": "free_webinar", "fieldtype": "Check", "label": "Free Webinar?" 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 ea541b19..0d62e046 100644 --- a/buzz/install.py +++ b/buzz/install.py @@ -128,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() @@ -216,6 +218,14 @@ def create_event_categories(): "name": "Webinars", "icon_svg": """ +""", + "enabled": 1, + }, + { + "name": "Zoom Meeting", + "icon_svg": """ + + """, "enabled": 1, }, diff --git a/buzz/proposals/doctype/event_proposal/event_proposal.json b/buzz/proposals/doctype/event_proposal/event_proposal.json index f94de1bf..c92a08f9 100644 --- a/buzz/proposals/doctype/event_proposal/event_proposal.json +++ b/buzz/proposals/doctype/event_proposal/event_proposal.json @@ -151,7 +151,7 @@ }, { "default": "0", - "depends_on": "eval:doc.event_category==\"Webinars\"", + "depends_on": "eval:[\"Webinars\",\"Zoom Meeting\"].includes(doc.event_category)", "fieldname": "free_webinar", "fieldtype": "Check", "label": "Free Webinar?" 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/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..4cfbec98 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 +173,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..a2963319 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!") }}

-