From e635ad8f38581014d75ade1140baf00f9f55aba7 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 17:11:14 +0530 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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;