From 59aef29679bb9da84a81338d1d069207be72e0d8 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:50:13 +0530 Subject: [PATCH] feat: back Buzz Events with Zoom Meetings (#288) * feat: back Buzz Events with Zoom Meetings (create, sync, ticket registration) Co-Authored-By: Claude Opus 4.8 * ci: install zoom_integration app for server tests Co-Authored-By: Claude Opus 4.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 * 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 * 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 * 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 * refactor!: rename free_webinar to free_event The flag hides pricing, payment sections and the ticket type picker. None of that is webinar-specific, and it now applies to Zoom Meeting events too, so the name was wrong on two counts. Both doctypes rename in one patch. Event Proposal maps onto Buzz Event through get_mapped_doc, which matches on fieldname with no field_map, so renaming one side alone would have stopped carrying the flag over without raising anything. A test covers that carry-over. SuccessMessage took an isWebinar prop fed by this flag; it selects registration wording over booking wording, so it becomes isRegistration. BREAKING CHANGE: the booking payloads return free_event instead of free_webinar. API clients reading the old key must be updated. Co-Authored-By: Claude Opus 5 * test: stop the ticket webinar test from needing a live Zoom token Zoom Webinar fetches its own token rather than going through api.py, so patching `requests` alone left the token call live. It passed locally, where credentials exist, and failed in CI where they do not. Uses mock_zoom_post, which patches both, matching the fix already applied to the zoom_integration tests. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 4.8 (cherry picked from commit f3ced3f6c36e064a336f82a494a6b949a7aaf8ad) --- .github/workflows/ci.yml | 2 + buzz/api/__init__.py | 21 +-- buzz/events/doctype/buzz_event/buzz_event.js | 35 +++-- .../events/doctype/buzz_event/buzz_event.json | 8 +- buzz/events/doctype/buzz_event/buzz_event.py | 46 ++++++- .../doctype/buzz_event/test_buzz_event.py | 79 +++++++++++ .../event_category/test_event_category.py | 17 +-- buzz/install.py | 23 +++- buzz/patches.txt | 2 + .../rename_free_webinar_to_free_event.py | 18 +++ .../patches/rename_zoom_registration_field.py | 26 ++++ .../event_proposal/event_proposal.json | 8 +- .../doctype/event_proposal/event_proposal.py | 2 +- .../event_proposal/test_event_proposal.py | 10 ++ .../event_booking/test_event_booking.py | 47 +++++++ .../doctype/event_ticket/event_ticket.py | 29 ++-- .../doctype/event_ticket/test_event_ticket.py | 130 ++++++++++++++++++ buzz/utils.py | 4 + .../src/components/AttendeeFormControl.vue | 12 +- dashboard/src/components/BookingForm.vue | 21 +-- dashboard/src/components/SuccessMessage.vue | 6 +- dashboard/src/pages/BookingDetails.vue | 11 +- dashboard/src/pages/BookingSuccess.vue | 4 +- dashboard/src/types/Events/BuzzEvent.ts | 4 +- .../src/types/Proposals/EventProposal.ts | 4 +- dashboard/src/utils/zoomCategory.test.ts | 21 +++ dashboard/src/utils/zoomCategory.ts | 9 ++ 27 files changed, 508 insertions(+), 91 deletions(-) create mode 100644 buzz/patches/rename_free_webinar_to_free_event.py create mode 100644 buzz/patches/rename_zoom_registration_field.py create mode 100644 dashboard/src/utils/zoomCategory.test.ts create mode 100644 dashboard/src/utils/zoomCategory.ts 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!") }}

-