From abb566485fffef3c05bcc88eef5a9a6c77ff4446 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 17:03:10 +0530 Subject: [PATCH 01/20] feat: add resource-parameterized Zoom API helpers with mocked tests Co-Authored-By: Claude Opus 4.8 --- zoom_integration/api.py | 113 ++++++++++++++++++++++++ zoom_integration/tests/__init__.py | 0 zoom_integration/tests/test_api.py | 103 +++++++++++++++++++++ zoom_integration/tests/zoom_fixtures.py | 92 +++++++++++++++++++ 4 files changed, 308 insertions(+) create mode 100644 zoom_integration/api.py create mode 100644 zoom_integration/tests/__init__.py create mode 100644 zoom_integration/tests/test_api.py create mode 100644 zoom_integration/tests/zoom_fixtures.py diff --git a/zoom_integration/api.py b/zoom_integration/api.py new file mode 100644 index 0000000..f19f55f --- /dev/null +++ b/zoom_integration/api.py @@ -0,0 +1,113 @@ +import json + +import frappe +import requests +from frappe.integrations.utils import create_request_log + +from zoom_integration.utils import ZOOM_API_BASE_PATH, get_authenticated_headers_for_zoom + +DEFAULT_PAGE_SIZE = 300 # Zoom API allows max 300 per page + + +def create_zoom_session(resource: str, body: dict) -> dict: + """Create a meeting or webinar. `resource` is 'meetings' or 'webinars'.""" + url = f"{ZOOM_API_BASE_PATH}/users/me/{resource}" + headers = get_authenticated_headers_for_zoom() + response = requests.post(url, headers=headers, data=json.dumps(body)) + + if response.status_code == 201: + data = response.json() + create_request_log( + data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" + ) + return data + + create_request_log( + response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" + ) + frappe.throw(f"Failed to create {resource[:-1]} on Zoom: {response.text}") + + +def update_zoom_session(resource: str, session_id: str, body: dict) -> None: + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}" + headers = get_authenticated_headers_for_zoom() + response = requests.patch(url, headers=headers, data=json.dumps(body)) + + if response.status_code != 204: + frappe.throw(f"Failed to update {resource[:-1]} on Zoom: {response.text}") + + +def delete_zoom_session(resource: str, session_id: str) -> bool: + """Returns True if deleted on Zoom, False if it was already gone (code 3001).""" + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}" + headers = get_authenticated_headers_for_zoom() + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + if response.json().get("code") == 3001: + return False + frappe.throw(f"Failed to delete {resource[:-1]} on Zoom: {response.text}") + + +def add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict: + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants" + headers = get_authenticated_headers_for_zoom() + response = requests.post(url, headers=headers, data=json.dumps(body)) + + if response.status_code not in (200, 201): + create_request_log( + response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" + ) + frappe.throw(f"Failed to add registrant on Zoom: {response.text}") + + data = response.json() + create_request_log( + data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" + ) + return data + + +def _paginate(url_builder, item_key: str, page_size: int) -> list[dict]: + """Follow Zoom's next_page_token pagination and collect `item_key` items.""" + headers = get_authenticated_headers_for_zoom() + items: list[dict] = [] + next_page_token = None + + while True: + url = url_builder(page_size) + if next_page_token: + url += f"&next_page_token={next_page_token}" + + response = requests.get(url, headers=headers, timeout=30) + if response.status_code != 200: + frappe.throw(f"Failed to fetch {item_key} from Zoom: {response.text}") + + data = response.json() + items.extend(data.get(item_key, [])) + next_page_token = data.get("next_page_token") + if not next_page_token: + break + + return items + + +def get_zoom_registrants(resource: str, session_id: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: + return _paginate( + lambda size: f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants?page_size={size}", + "registrants", + page_size, + ) + + +def get_zoom_participants(resource: str, session_uuid: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: + # ponytail: single-encoding the UUID covers the common case; double-encode + # here if you hit meeting UUIDs that start with "/" or contain "//". + from urllib.parse import quote + + encoded_uuid = quote(session_uuid, safe="") + return _paginate( + lambda size: f"{ZOOM_API_BASE_PATH}/past_{resource}/{encoded_uuid}/participants?page_size={size}", + "participants", + page_size, + ) diff --git a/zoom_integration/tests/__init__.py b/zoom_integration/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zoom_integration/tests/test_api.py b/zoom_integration/tests/test_api.py new file mode 100644 index 0000000..0e6f76c --- /dev/null +++ b/zoom_integration/tests/test_api.py @@ -0,0 +1,103 @@ +from unittest.mock import patch + +from frappe.tests import IntegrationTestCase + +from zoom_integration import api +from zoom_integration.tests.zoom_fixtures import ( + ADD_MEETING_REGISTRANT_RESPONSE, + CREATE_MEETING_RESPONSE, + MEETING_PARTICIPANTS_RESPONSE, + MEETING_REGISTRANTS_LIST_RESPONSE, + NOT_FOUND_DELETE_RESPONSE, + mock_response, +) + +HEADERS = {"Authorization": "Bearer test-token", "content-type": "application/json"} + + +class TestZoomAPI(IntegrationTestCase): + def test_create_meeting_posts_to_meetings_endpoint_and_returns_data(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.post.return_value = mock_response(201, CREATE_MEETING_RESPONSE) + + result = api.create_zoom_session("meetings", {"topic": "Test Meeting", "type": 2}) + + called_url = mock_requests.post.call_args.args[0] + self.assertTrue(called_url.endswith("/users/me/meetings")) + self.assertEqual(result["id"], 91234567890) + self.assertEqual(result["uuid"], "aDEFghiJKLmno12PQ==") + self.assertEqual(result["join_url"], CREATE_MEETING_RESPONSE["join_url"]) + + def test_update_meeting_patches_meeting_endpoint(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.patch.return_value = mock_response(204) + + api.update_zoom_session("meetings", "91234567890", {"topic": "New"}) + + called_url = mock_requests.patch.call_args.args[0] + self.assertTrue(called_url.endswith("/meetings/91234567890")) + + def test_delete_meeting_returns_true_on_204(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.delete.return_value = mock_response(204) + self.assertTrue(api.delete_zoom_session("meetings", "91234567890")) + + def test_delete_meeting_returns_false_when_already_gone(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.delete.return_value = mock_response(404, NOT_FOUND_DELETE_RESPONSE) + self.assertFalse(api.delete_zoom_session("meetings", "91234567890")) + + def test_add_registrant_returns_registrant_id_and_join_url(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.post.return_value = mock_response(201, ADD_MEETING_REGISTRANT_RESPONSE) + + result = api.add_zoom_registrant( + "meetings", "91234567890", {"email": "alice@example.com", "first_name": "Alice"} + ) + + called_url = mock_requests.post.call_args.args[0] + self.assertTrue(called_url.endswith("/meetings/91234567890/registrants")) + self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") + self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + + def test_get_participants_uses_past_meetings_uuid_and_returns_list(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.get.return_value = mock_response(200, MEETING_PARTICIPANTS_RESPONSE) + + result = api.get_zoom_participants("meetings", "aDEFghiJKLmno12PQ==") + + called_url = mock_requests.get.call_args.args[0] + self.assertIn("/past_meetings/", called_url) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["user_email"], "alice@example.com") + + def test_get_registrants_returns_list(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.get.return_value = mock_response(200, MEETING_REGISTRANTS_LIST_RESPONSE) + + result = api.get_zoom_registrants("meetings", "91234567890") + + called_url = mock_requests.get.call_args.args[0] + self.assertIn("/meetings/91234567890/registrants", called_url) + self.assertEqual(result[0]["email"], "alice@example.com") diff --git a/zoom_integration/tests/zoom_fixtures.py b/zoom_integration/tests/zoom_fixtures.py new file mode 100644 index 0000000..de256e2 --- /dev/null +++ b/zoom_integration/tests/zoom_fixtures.py @@ -0,0 +1,92 @@ +"""Realistic Zoom API response fixtures. Shapes mirror the live Zoom v2 API +so mocks behave like production. Reused by all zoom_integration tests.""" + +CREATE_MEETING_RESPONSE = { + "uuid": "aDEFghiJKLmno12PQ==", + "id": 91234567890, + "host_id": "hostAbc123", + "topic": "Test Meeting", + "type": 2, + "status": "waiting", + "start_time": "2026-08-01T10:00:00Z", + "duration": 60, + "timezone": "Asia/Calcutta", + "created_at": "2026-07-24T09:00:00Z", + "join_url": "https://zoom.us/j/91234567890?pwd=xYzToken", + "start_url": "https://zoom.us/s/91234567890?zak=zakToken", + "password": "aB3dEf", + "settings": {"approval_type": 0, "registration_type": 1, "audio": "both"}, +} + +# PATCH /meetings/{id} and DELETE /meetings/{id} return 204 with an empty body. + +ADD_MEETING_REGISTRANT_RESPONSE = { + "id": 91234567890, + "topic": "Test Meeting", + "registrant_id": "abcDEF12ghIJ", + "start_time": "2026-08-01T10:00:00Z", + "join_url": "https://zoom.us/w/91234567890?tk=regToken&pwd=xYz", +} + +MEETING_REGISTRANTS_LIST_RESPONSE = { + "page_count": 1, + "page_size": 300, + "total_records": 1, + "next_page_token": "", + "registrants": [ + { + "id": "abcDEF12ghIJ", + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Smith", + "status": "approved", + "create_time": "2026-07-24T09:10:00Z", + "join_url": "https://zoom.us/w/91234567890?tk=regToken", + "custom_questions": [], + } + ], +} + +MEETING_PARTICIPANTS_RESPONSE = { + "page_count": 1, + "page_size": 300, + "total_records": 2, + "next_page_token": "", + "participants": [ + { + "id": "", + "user_id": "16778240", + "name": "Alice", + "user_email": "alice@example.com", + "join_time": "2026-08-01T10:01:00Z", + "leave_time": "2026-08-01T10:55:00Z", + "duration": 3240, + "registrant_id": "abcDEF12ghIJ", + }, + { + "id": "", + "user_id": "16778241", + "name": "Bob", + "user_email": "bob@example.com", + "join_time": "2026-08-01T10:05:00Z", + "leave_time": "2026-08-01T10:50:00Z", + "duration": 2700, + "registrant_id": "klmNOP34qrST", + }, + ], +} + +# Zoom "resource not found on delete" error body. +NOT_FOUND_DELETE_RESPONSE = {"code": 3001, "message": "Meeting does not exist."} + + +def mock_response(status_code, json_data=None, text=""): + """Build a fake requests.Response-like object for patching.""" + from unittest.mock import MagicMock + + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data if json_data is not None else {} + resp.text = text or "" + resp.headers = {} + return resp From be834b97f59c45c23ee6248986824186b68f2a17 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 17:06:01 +0530 Subject: [PATCH 02/20] feat: add Zoom Meeting doctype with create, registrant, attendance sync Co-Authored-By: Claude Opus 4.8 --- .../doctype/zoom_meeting/__init__.py | 0 .../doctype/zoom_meeting/test_zoom_meeting.py | 71 +++++++ .../doctype/zoom_meeting/zoom_meeting.json | 56 ++++++ .../doctype/zoom_meeting/zoom_meeting.py | 173 ++++++++++++++++++ .../zoom_webinar_attendance_record.json | 9 +- 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py create mode 100644 zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py create mode 100644 zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json create mode 100644 zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py new file mode 100644 index 0000000..981e006 --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py @@ -0,0 +1,71 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.tests.zoom_fixtures import ( + ADD_MEETING_REGISTRANT_RESPONSE, + CREATE_MEETING_RESPONSE, +) + +CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + + +class TestZoomMeeting(IntegrationTestCase): + def _new_meeting(self): + return frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Test Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ) + + def test_insert_creates_meeting_on_zoom_and_stores_ids(self): + with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE) as mock_create: + meeting = self._new_meeting().insert() + + resource, body = mock_create.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(body["type"], 2) + self.assertEqual(body["settings"]["approval_type"], 0) + self.assertEqual(meeting.zoom_meeting_id, "91234567890") + self.assertEqual(meeting.zoom_meeting_uuid, "aDEFghiJKLmno12PQ==") + self.assertEqual(meeting.zoom_link, CREATE_MEETING_RESPONSE["join_url"]) + + def test_add_registrant_returns_registrant_id_and_join_url(self): + with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + meeting = self._new_meeting().insert() + + with patch( + f"{CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE + ) as mock_add: + result = meeting.add_registrant("alice@example.com", "Alice", "Smith") + + resource, session_id, body = mock_add.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(session_id, "91234567890") + self.assertEqual(body["email"], "alice@example.com") + self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") + self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + + def test_sync_attendance_creates_records_from_participants(self): + from zoom_integration.tests.zoom_fixtures import MEETING_PARTICIPANTS_RESPONSE + + with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + meeting = self._new_meeting().insert() + + participants = MEETING_PARTICIPANTS_RESPONSE["participants"] + with patch(f"{CONTROLLER}.get_zoom_participants", return_value=participants): + meeting.sync_attendance() + + records = frappe.get_all( + "Zoom Webinar Attendance Record", + filters={"meeting": meeting.name}, + fields=["user_email", "total_duration"], + ) + emails = {r.user_email for r in records} + self.assertEqual(emails, {"alice@example.com", "bob@example.com"}) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json new file mode 100644 index 0000000..bb263bf --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-07-24 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "agenda", + "timings_section", + "date", + "column_break_time", + "start_time", + "duration", + "column_break_tz", + "timezone", + "meeting_details_section", + "zoom_meeting_id", + "zoom_meeting_uuid", + "column_break_details", + "zoom_link", + "send_zoom_registration_email", + "attendance_synced" + ], + "fields": [ + {"fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1, "in_list_view": 1}, + {"fieldname": "agenda", "fieldtype": "Small Text", "label": "Agenda"}, + {"fieldname": "timings_section", "fieldtype": "Section Break", "label": "Timings"}, + {"fieldname": "date", "fieldtype": "Date", "label": "Date", "reqd": 1}, + {"fieldname": "column_break_time", "fieldtype": "Column Break"}, + {"fieldname": "start_time", "fieldtype": "Time", "label": "Start Time", "reqd": 1}, + {"fieldname": "duration", "fieldtype": "Duration", "label": "Duration"}, + {"fieldname": "column_break_tz", "fieldtype": "Column Break"}, + {"fieldname": "timezone", "fieldtype": "Autocomplete", "label": "Timezone"}, + {"fieldname": "meeting_details_section", "fieldtype": "Section Break", "label": "Meeting Details"}, + {"fieldname": "zoom_meeting_id", "fieldtype": "Data", "label": "Zoom Meeting ID", "read_only": 1}, + {"fieldname": "zoom_meeting_uuid", "fieldtype": "Data", "label": "Zoom Meeting UUID", "read_only": 1}, + {"fieldname": "column_break_details", "fieldtype": "Column Break"}, + {"fieldname": "zoom_link", "fieldtype": "Data", "label": "Join URL", "read_only": 1}, + {"fieldname": "send_zoom_registration_email", "fieldtype": "Check", "label": "Send Zoom Registration Email"}, + {"fieldname": "attendance_synced", "fieldtype": "Check", "label": "Attendance Synced", "read_only": 1} + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-07-24 00:00:00", + "modified_by": "Administrator", + "module": "Zoom Integration", + "name": "Zoom Meeting", + "owner": "Administrator", + "permissions": [ + {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1} + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py new file mode 100644 index 0000000..1febb4f --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -0,0 +1,173 @@ +# Copyright (c) 2026, Build With Hussain and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint, format_datetime, get_time + +from zoom_integration.api import ( + add_zoom_registrant, + create_zoom_session, + delete_zoom_session, + get_zoom_participants, + update_zoom_session, +) + +RESOURCE = "meetings" + + +class ZoomMeeting(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + agenda: DF.SmallText | None + attendance_synced: DF.Check + date: DF.Date + duration: DF.Duration + send_zoom_registration_email: DF.Check + start_time: DF.Time + timezone: DF.Autocomplete | None + title: DF.Data + zoom_link: DF.Data | None + zoom_meeting_id: DF.Data | None + zoom_meeting_uuid: DF.Data | None + # end: auto-generated types + + def before_insert(self): + self.create_meeting_on_zoom() + + def create_meeting_on_zoom(self): + if self.zoom_meeting_id: + return + + body = { + "topic": self.title, + "agenda": self.agenda or self.title, + "type": 2, # Scheduled meeting + "duration": cint(self.duration / 60) if self.duration else 60, + "start_time": format_datetime(f"{self.date} {self.start_time}", "yyyy-MM-ddTHH:mm:ss"), + "timezone": self.timezone or "Asia/Calcutta", + "settings": { + "host_video": True, + "participant_video": True, + "join_before_host": False, + "approval_type": 0, # Automatically approve registrants (required for registration) + "registration_type": 1, # Register once and join anytime + "audio": "both", + "auto_recording": "cloud", + "meeting_authentication": False, + }, + "registrants_email_notification": bool(self.send_zoom_registration_email), + } + + data = create_zoom_session(RESOURCE, body) + self.zoom_link = data.get("join_url") + self.zoom_meeting_id = str(data.get("id")) if data.get("id") is not None else None + self.zoom_meeting_uuid = data.get("uuid") + frappe.msgprint(_("Meeting created successfully on Zoom.")) + + def on_update(self): + if not self.zoom_meeting_id: + return + self.update_meeting_on_zoom_if_applicable() + + def update_meeting_on_zoom_if_applicable(self): + if self.flags.in_import or self.flags.in_migration or self.is_new(): + return + + before_save = self.get_doc_before_save() + if not before_save: + return + + unchanged = ( + self.title == before_save.title + and self.agenda == before_save.agenda + and self.duration == before_save.duration + and self.timezone == before_save.timezone + and self.get("date") == str(before_save.get("date")) + and get_time(self.get("start_time")) == get_time(before_save.get("start_time")) + ) + if unchanged: + return + + body = { + "topic": self.title, + "agenda": self.agenda or self.title, + "duration": cint(self.duration / 60) if self.duration else 60, + "start_time": format_datetime(f"{self.date} {self.start_time}", "yyyy-MM-ddTHH:mm:ss"), + "timezone": self.timezone or "Asia/Calcutta", + } + update_zoom_session(RESOURCE, self.zoom_meeting_id, body) + frappe.msgprint(_("Meeting updated successfully on Zoom.")) + + def on_trash(self): + if not self.zoom_meeting_id: + return + deleted = delete_zoom_session(RESOURCE, self.zoom_meeting_id) + if not deleted: + frappe.msgprint(_("Meeting not found on Zoom (already deleted). Clearing local reference.")) + + def add_registrant( + self, email: str, first_name: str, last_name: str | None = None, additional_params: dict | None = None + ) -> dict: + if not self.zoom_meeting_id: + frappe.throw(_("Meeting not created on Zoom yet.")) + + body = { + "email": email, + "first_name": first_name, + "last_name": last_name or "N/A", + **(additional_params or {}), + } + return add_zoom_registrant(RESOURCE, self.zoom_meeting_id, body) + + @frappe.whitelist() + def sync_attendance(self): + if not self.zoom_meeting_uuid: + frappe.throw(_("Meeting has not run yet (no UUID to fetch attendance for).")) + + participants = get_zoom_participants(RESOURCE, self.zoom_meeting_uuid) + + # Same user can join multiple times; sum durations per email. + summary: dict[str, dict] = {} + for participant in participants: + email = participant.get("user_email") + if not email: + continue + entry = summary.setdefault( + email, + { + "name": participant.get("name"), + "total_duration": 0, + "registrant_id": participant.get("registrant_id"), + }, + ) + entry["total_duration"] += participant.get("duration", 0) + + for email, details in summary.items(): + if frappe.db.exists("Zoom Webinar Attendance Record", {"meeting": self.name, "user_email": email}): + continue + frappe.get_doc( + { + "doctype": "Zoom Webinar Attendance Record", + "meeting": self.name, + "user_email": email, + "full_name": details["name"], + "total_duration": details["total_duration"], + "registrant_id": details["registrant_id"], + "docstatus": 1, + } + ).insert(ignore_permissions=True) + + self.db_set("attendance_synced", 1) + frappe.msgprint(_("Synced attendance for {0} participants.").format(len(summary))) + + @frappe.whitelist() + def sync_attendance_in_background(self): + frappe.enqueue_doc(self.doctype, self.name, "sync_attendance", queue="long") diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json b/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json index 3d9a404..bff5bb5 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json @@ -7,6 +7,7 @@ "engine": "InnoDB", "field_order": [ "webinar", + "meeting", "total_duration", "full_name", "column_break_bwzj", @@ -37,7 +38,13 @@ "in_list_view": 1, "label": "Webinar", "options": "Zoom Webinar", - "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "meeting", + "fieldtype": "Link", + "label": "Meeting", + "options": "Zoom Meeting", "search_index": 1 }, { From 04f5eb850301ec770ebfafb6aec2afc34fb30408 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 17:07:29 +0530 Subject: [PATCH 03/20] feat: allow Zoom Webinar Registration to register against a Zoom Meeting Co-Authored-By: Claude Opus 4.8 --- .../test_zoom_webinar_registration.py | 45 ++++++++++++++----- .../zoom_webinar_registration.json | 11 ++++- .../zoom_webinar_registration.py | 10 ++++- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py index 8028602..a16c92d 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py @@ -1,20 +1,43 @@ # Copyright (c) 2025, Build With Hussain and Contributors # See license.txt -# import frappe +from unittest.mock import patch + +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 zoom_integration.tests.zoom_fixtures import ADD_MEETING_REGISTRANT_RESPONSE, CREATE_MEETING_RESPONSE + +MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" class IntegrationTestZoomWebinarRegistration(IntegrationTestCase): - """ - Integration tests for ZoomWebinarRegistration. - Use this class for testing interactions between multiple components. - """ + def test_registration_for_meeting_calls_meeting_add_registrant(self): + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + meeting = frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Reg Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + with patch( + f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE + ): + registration = frappe.get_doc( + { + "doctype": "Zoom Webinar Registration", + "meeting": meeting.name, + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Smith", + } + ).insert() + registration.submit() - pass + self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") + self.assertEqual(registration.join_url, ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json index fe0d66c..235651a 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json @@ -7,6 +7,7 @@ "engine": "InnoDB", "field_order": [ "webinar", + "meeting", "column_break_baez", "user", "synced_from_zoom", @@ -46,7 +47,15 @@ "in_standard_filter": 1, "label": "Webinar", "options": "Zoom Webinar", - "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "meeting", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Meeting", + "options": "Zoom Meeting", "search_index": 1 }, { diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py index 4c78eb1..83cc15c 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py @@ -24,6 +24,7 @@ class ZoomWebinarRegistration(Document): first_name: DF.Data | None join_url: DF.Data | None last_name: DF.Data | None + meeting: DF.Link | None registrant_id: DF.Data | None synced_from_zoom: DF.Check user: DF.Link | None @@ -55,7 +56,14 @@ def before_submit(self): if self.additional_params: additional_params = {param.key: param.value for param in self.additional_params} - registration = frappe.get_cached_doc("Zoom Webinar", self.webinar).add_registrant( + if self.meeting: + session = frappe.get_cached_doc("Zoom Meeting", self.meeting) + elif self.webinar: + session = frappe.get_cached_doc("Zoom Webinar", self.webinar) + else: + frappe.throw(frappe._("Registration must reference a Zoom Meeting or Zoom Webinar.")) + + registration = session.add_registrant( self.email, self.first_name, self.last_name, additional_params ) From 0dd8521953076486cb2491fc383dbfb110d05fa8 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 17:51:17 +0530 Subject: [PATCH 04/20] fix: timezone dropdown + hide days on duration for Zoom Meeting Co-Authored-By: Claude Opus 4.8 --- .../doctype/zoom_meeting/zoom_meeting.js | 163 ++++++++++++++++++ .../doctype/zoom_meeting/zoom_meeting.json | 2 +- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js new file mode 100644 index 0000000..8e4b0f4 --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js @@ -0,0 +1,163 @@ +// Copyright (c) 2026, Build With Hussain and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Zoom Meeting", { + refresh(frm) { + frm.fields_dict.timezone.set_data(getSupportedTimezones()); + if (!frm.doc.__islocal) { + frm.add_custom_button(__("Sync Attendance"), () => { + frm.call({ + method: "sync_attendance_in_background", + doc: frm.doc, + freeze: true, + freeze_message: __("Starting attendance sync..."), + }).then(() => { + frappe.show_alert({ + message: __("Attendance sync has been started in the background..."), + indicator: "green", + }); + }); + }); + } + }, +}); + +function getSupportedTimezones() { + return [ + "Pacific/Midway", + "Pacific/Pago_Pago", + "Pacific/Honolulu", + "America/Anchorage", + "America/Vancouver", + "America/Los_Angeles", + "America/Tijuana", + "America/Edmonton", + "America/Denver", + "America/Phoenix", + "America/Mazatlan", + "America/Winnipeg", + "America/Regina", + "America/Chicago", + "America/Mexico_City", + "America/Guatemala", + "America/El_Salvador", + "America/Managua", + "America/Costa_Rica", + "America/Montreal", + "America/New_York", + "America/Indianapolis", + "America/Panama", + "America/Bogota", + "America/Lima", + "America/Halifax", + "America/Puerto_Rico", + "America/Caracas", + "America/Santiago", + "America/St_Johns", + "America/Montevideo", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Godthab", + "America/Sao_Paulo", + "Atlantic/Azores", + "Canada/Atlantic", + "Atlantic/Cape_Verde", + "UTC", + "Etc/Greenwich", + "Europe/Belgrade", + "CET", + "Atlantic/Reykjavik", + "Europe/Dublin", + "Europe/London", + "Europe/Lisbon", + "Africa/Casablanca", + "Africa/Nouakchott", + "Europe/Oslo", + "Europe/Copenhagen", + "Europe/Brussels", + "Europe/Berlin", + "Europe/Helsinki", + "Europe/Amsterdam", + "Europe/Rome", + "Europe/Stockholm", + "Europe/Vienna", + "Europe/Luxembourg", + "Europe/Paris", + "Europe/Zurich", + "Europe/Madrid", + "Africa/Bangui", + "Africa/Algiers", + "Africa/Tunis", + "Africa/Harare", + "Africa/Nairobi", + "Europe/Warsaw", + "Europe/Prague", + "Europe/Budapest", + "Europe/Sofia", + "Europe/Istanbul", + "Europe/Athens", + "Europe/Bucharest", + "Asia/Nicosia", + "Asia/Beirut", + "Asia/Damascus", + "Asia/Jerusalem", + "Asia/Amman", + "Africa/Tripoli", + "Africa/Cairo", + "Africa/Johannesburg", + "Europe/Moscow", + "Asia/Baghdad", + "Asia/Kuwait", + "Asia/Riyadh", + "Asia/Bahrain", + "Asia/Qatar", + "Asia/Aden", + "Asia/Tehran", + "Africa/Khartoum", + "Africa/Djibouti", + "Africa/Mogadishu", + "Asia/Dubai", + "Asia/Muscat", + "Asia/Baku", + "Asia/Kabul", + "Asia/Yekaterinburg", + "Asia/Tashkent", + "Asia/Calcutta", + "Asia/Kathmandu", + "Asia/Novosibirsk", + "Asia/Almaty", + "Asia/Dacca", + "Asia/Krasnoyarsk", + "Asia/Dhaka", + "Asia/Bangkok", + "Asia/Saigon", + "Asia/Jakarta", + "Asia/Irkutsk", + "Asia/Shanghai", + "Asia/Hong_Kong", + "Asia/Taipei", + "Asia/Kuala_Lumpur", + "Asia/Singapore", + "Australia/Perth", + "Asia/Yakutsk", + "Asia/Seoul", + "Asia/Tokyo", + "Australia/Darwin", + "Australia/Adelaide", + "Asia/Vladivostok", + "Pacific/Port_Moresby", + "Australia/Brisbane", + "Australia/Sydney", + "Australia/Hobart", + "Asia/Magadan", + "SST", + "Pacific/Noumea", + "Asia/Kamchatka", + "Pacific/Fiji", + "Pacific/Auckland", + "Asia/Kolkata", + "Europe/Kiev", + "America/Tegucigalpa", + "Pacific/Apia", + ]; +} diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json index bb263bf..41e168b 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json @@ -29,7 +29,7 @@ {"fieldname": "date", "fieldtype": "Date", "label": "Date", "reqd": 1}, {"fieldname": "column_break_time", "fieldtype": "Column Break"}, {"fieldname": "start_time", "fieldtype": "Time", "label": "Start Time", "reqd": 1}, - {"fieldname": "duration", "fieldtype": "Duration", "label": "Duration"}, + {"fieldname": "duration", "fieldtype": "Duration", "hide_days": 1, "label": "Duration"}, {"fieldname": "column_break_tz", "fieldtype": "Column Break"}, {"fieldname": "timezone", "fieldtype": "Autocomplete", "label": "Timezone"}, {"fieldname": "meeting_details_section", "fieldtype": "Section Break", "label": "Meeting Details"}, From 0b7d60848539f95fe4b16b74e419499206bce84e Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 18:01:13 +0530 Subject: [PATCH 05/20] style: apply ruff formatting Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-24-zoom-meetings.md | 980 ++++++++++++++++++ .../doctype/zoom_meeting/zoom_meeting.py | 4 +- .../test_zoom_webinar_registration.py | 4 +- .../zoom_webinar_registration.py | 4 +- 4 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-24-zoom-meetings.md diff --git a/docs/superpowers/plans/2026-07-24-zoom-meetings.md b/docs/superpowers/plans/2026-07-24-zoom-meetings.md new file mode 100644 index 0000000..88b87b6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-zoom-meetings.md @@ -0,0 +1,980 @@ +# Zoom Meetings Support (zoom_integration) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add first-class Zoom **Meeting** support alongside the existing Webinar support, so a Zoom Meeting can be created/updated/deleted, registrants added, and attendance synced — reusing shared API helpers instead of duplicating the Webinar controller. + +**Architecture:** Extract the resource-parameterized HTTP calls into a new `zoom_integration/api.py` (functions take `resource="meetings"|"webinars"`). Add a `Zoom Meeting` DocType whose controller delegates to those helpers. Make the existing `Zoom Webinar Registration` and `Zoom Webinar Attendance Record` doctypes reference **either** a webinar or a meeting via an additive optional `meeting` link (no migration of existing rows). The existing `Zoom Webinar` controller is left untouched (working code, out of scope to refactor). + +**Tech Stack:** Frappe Framework (Python), `requests`, `unittest.mock` for patching the Zoom HTTP seam, `frappe.tests.IntegrationTestCase`. + +## Global Constraints + +- Branch: `feat/zoom-meetings` off `main`. All work on this branch. +- Tabs for indentation (match existing files). +- Zoom Server-to-Server OAuth is already configured in `Zoom Settings`; auth is via `zoom_integration.utils.get_authenticated_headers_for_zoom()` — never call the network in tests, always patch this and `requests`. +- Meeting `type` is `2` (scheduled meeting). Registrants require `settings.approval_type: 0` in the create body. +- Meeting **participant** reports use the meeting **UUID** (`past_meetings/{uuid}`), not the numeric id. Create/update/delete/registrants use the numeric id. +- Mock return values must mirror real Zoom API JSON (shapes fixed in Task 1, reused everywhere). +- Run tests with: `bench --site buzz.localhost run-tests --app zoom_integration --module ` (ask user for site if `buzz.localhost` missing). Credentials Administrator/admin. +- After any doctype schema change: `bench --site buzz.localhost migrate`. + +--- + +### Task 1: Shared Zoom API helpers (`api.py`) + +Resource-parameterized functions all controllers call. Fully mockable at `zoom_integration.api.requests` + `zoom_integration.api.get_authenticated_headers_for_zoom`. + +**Files:** +- Create: `zoom_integration/api.py` +- Create/Test: `zoom_integration/tests/__init__.py` (empty), `zoom_integration/tests/test_api.py` +- Create/Test fixture: `zoom_integration/tests/zoom_fixtures.py` (realistic mock responses reused by all tasks) + +**Interfaces:** +- Produces: + - `create_zoom_session(resource: str, body: dict) -> dict` — POST `/users/me/{resource}`, 201 → parsed JSON (has `id`, `uuid`, `join_url`), else `frappe.throw`. + - `update_zoom_session(resource: str, session_id: str, body: dict) -> None` — PATCH `/{resource}/{id}`, expects 204. + - `delete_zoom_session(resource: str, session_id: str) -> bool` — DELETE `/{resource}/{id}`, 204 → `True`; Zoom code 3001 (already gone) → `False`; else throw. + - `add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict` — POST `/{resource}/{id}/registrants`, 200/201 → parsed JSON (has `registrant_id`, `join_url`), else throw. + - `get_zoom_registrants(resource: str, session_id: str, page_size: int = 300) -> list[dict]` — GET `/{resource}/{id}/registrants`, follows `next_page_token`. + - `get_zoom_participants(resource: str, session_uuid: str, page_size: int = 300) -> list[dict]` — GET `/past_{resource}/{uuid}/participants`, follows `next_page_token`. + +- [ ] **Step 1: Write the realistic mock fixtures** + +Create `zoom_integration/tests/__init__.py` (empty file) and `zoom_integration/tests/zoom_fixtures.py`: + +```python +"""Realistic Zoom API response fixtures. Shapes mirror the live Zoom v2 API +so mocks behave like production. Reused by all zoom_integration tests.""" + +CREATE_MEETING_RESPONSE = { + "uuid": "aDEFghiJKLmno12PQ==", + "id": 91234567890, + "host_id": "hostAbc123", + "topic": "Test Meeting", + "type": 2, + "status": "waiting", + "start_time": "2026-08-01T10:00:00Z", + "duration": 60, + "timezone": "Asia/Calcutta", + "created_at": "2026-07-24T09:00:00Z", + "join_url": "https://zoom.us/j/91234567890?pwd=xYzToken", + "start_url": "https://zoom.us/s/91234567890?zak=zakToken", + "password": "aB3dEf", + "settings": {"approval_type": 0, "registration_type": 1, "audio": "both"}, +} + +# PATCH /meetings/{id} and DELETE /meetings/{id} return 204 with an empty body. + +ADD_MEETING_REGISTRANT_RESPONSE = { + "id": 91234567890, + "topic": "Test Meeting", + "registrant_id": "abcDEF12ghIJ", + "start_time": "2026-08-01T10:00:00Z", + "join_url": "https://zoom.us/w/91234567890?tk=regToken&pwd=xYz", +} + +MEETING_REGISTRANTS_LIST_RESPONSE = { + "page_count": 1, + "page_size": 300, + "total_records": 1, + "next_page_token": "", + "registrants": [ + { + "id": "abcDEF12ghIJ", + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Smith", + "status": "approved", + "create_time": "2026-07-24T09:10:00Z", + "join_url": "https://zoom.us/w/91234567890?tk=regToken", + "custom_questions": [], + } + ], +} + +MEETING_PARTICIPANTS_RESPONSE = { + "page_count": 1, + "page_size": 300, + "total_records": 2, + "next_page_token": "", + "participants": [ + { + "id": "", + "user_id": "16778240", + "name": "Alice", + "user_email": "alice@example.com", + "join_time": "2026-08-01T10:01:00Z", + "leave_time": "2026-08-01T10:55:00Z", + "duration": 3240, + "registrant_id": "abcDEF12ghIJ", + }, + { + "id": "", + "user_id": "16778241", + "name": "Bob", + "user_email": "bob@example.com", + "join_time": "2026-08-01T10:05:00Z", + "leave_time": "2026-08-01T10:50:00Z", + "duration": 2700, + "registrant_id": "klmNOP34qrST", + }, + ], +} + +# Zoom "resource not found on delete" error body. +NOT_FOUND_DELETE_RESPONSE = {"code": 3001, "message": "Meeting does not exist."} + + +def mock_response(status_code, json_data=None, text=""): + """Build a fake requests.Response-like object for patching.""" + from unittest.mock import MagicMock + + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data if json_data is not None else {} + resp.text = text or "" + resp.headers = {} + return resp +``` + +- [ ] **Step 2: Write the failing test for `create_zoom_session`** + +Create `zoom_integration/tests/test_api.py`: + +```python +from unittest.mock import patch + +from frappe.tests import IntegrationTestCase + +from zoom_integration import api +from zoom_integration.tests.zoom_fixtures import ( + ADD_MEETING_REGISTRANT_RESPONSE, + CREATE_MEETING_RESPONSE, + MEETING_PARTICIPANTS_RESPONSE, + MEETING_REGISTRANTS_LIST_RESPONSE, + NOT_FOUND_DELETE_RESPONSE, + mock_response, +) + +HEADERS = {"Authorization": "Bearer test-token", "content-type": "application/json"} + + +class TestZoomAPI(IntegrationTestCase): + def test_create_meeting_posts_to_meetings_endpoint_and_returns_data(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.post.return_value = mock_response(201, CREATE_MEETING_RESPONSE) + + result = api.create_zoom_session("meetings", {"topic": "Test Meeting", "type": 2}) + + called_url = mock_requests.post.call_args.args[0] + self.assertTrue(called_url.endswith("/users/me/meetings")) + self.assertEqual(result["id"], 91234567890) + self.assertEqual(result["uuid"], "aDEFghiJKLmno12PQ==") + self.assertEqual(result["join_url"], CREATE_MEETING_RESPONSE["join_url"]) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` +Expected: FAIL — `ModuleNotFoundError: No module named 'zoom_integration.api'` + +- [ ] **Step 4: Write `api.py` (create/update/delete/registrant)** + +Create `zoom_integration/api.py`: + +```python +import json + +import frappe +import requests +from frappe.integrations.utils import create_request_log + +from zoom_integration.utils import ZOOM_API_BASE_PATH, get_authenticated_headers_for_zoom + +DEFAULT_PAGE_SIZE = 300 # Zoom API allows max 300 per page + + +def create_zoom_session(resource: str, body: dict) -> dict: + """Create a meeting or webinar. `resource` is 'meetings' or 'webinars'.""" + url = f"{ZOOM_API_BASE_PATH}/users/me/{resource}" + headers = get_authenticated_headers_for_zoom() + response = requests.post(url, headers=headers, data=json.dumps(body)) + + if response.status_code == 201: + data = response.json() + create_request_log( + data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" + ) + return data + + create_request_log( + response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" + ) + frappe.throw(f"Failed to create {resource[:-1]} on Zoom: {response.text}") + + +def update_zoom_session(resource: str, session_id: str, body: dict) -> None: + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}" + headers = get_authenticated_headers_for_zoom() + response = requests.patch(url, headers=headers, data=json.dumps(body)) + + if response.status_code != 204: + frappe.throw(f"Failed to update {resource[:-1]} on Zoom: {response.text}") + + +def delete_zoom_session(resource: str, session_id: str) -> bool: + """Returns True if deleted on Zoom, False if it was already gone (code 3001).""" + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}" + headers = get_authenticated_headers_for_zoom() + response = requests.delete(url, headers=headers) + + if response.status_code == 204: + return True + if response.json().get("code") == 3001: + return False + frappe.throw(f"Failed to delete {resource[:-1]} on Zoom: {response.text}") + + +def add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict: + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants" + headers = get_authenticated_headers_for_zoom() + response = requests.post(url, headers=headers, data=json.dumps(body)) + + if response.status_code not in (200, 201): + create_request_log( + response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" + ) + frappe.throw(f"Failed to add registrant on Zoom: {response.text}") + + data = response.json() + create_request_log( + data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" + ) + return data +``` + +- [ ] **Step 5: Run the create test to verify it passes** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` +Expected: PASS + +- [ ] **Step 6: Write failing tests for update, delete, and add_registrant** + +Append to `TestZoomAPI` in `zoom_integration/tests/test_api.py`: + +```python + def test_update_meeting_patches_meeting_endpoint(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.patch.return_value = mock_response(204) + + api.update_zoom_session("meetings", "91234567890", {"topic": "New"}) + + called_url = mock_requests.patch.call_args.args[0] + self.assertTrue(called_url.endswith("/meetings/91234567890")) + + def test_delete_meeting_returns_true_on_204(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.delete.return_value = mock_response(204) + self.assertTrue(api.delete_zoom_session("meetings", "91234567890")) + + def test_delete_meeting_returns_false_when_already_gone(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.delete.return_value = mock_response(404, NOT_FOUND_DELETE_RESPONSE) + self.assertFalse(api.delete_zoom_session("meetings", "91234567890")) + + def test_add_registrant_returns_registrant_id_and_join_url(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.post.return_value = mock_response(201, ADD_MEETING_REGISTRANT_RESPONSE) + + result = api.add_zoom_registrant( + "meetings", "91234567890", {"email": "alice@example.com", "first_name": "Alice"} + ) + + called_url = mock_requests.post.call_args.args[0] + self.assertTrue(called_url.endswith("/meetings/91234567890/registrants")) + self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") + self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) +``` + +- [ ] **Step 7: Run to verify update/delete/registrant tests pass** (the code from Step 4 already covers them) + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` +Expected: PASS (all 5 tests) + +- [ ] **Step 8: Write failing tests for the paginated getters** + +Append to `TestZoomAPI`: + +```python + def test_get_participants_uses_past_meetings_uuid_and_returns_list(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.get.return_value = mock_response(200, MEETING_PARTICIPANTS_RESPONSE) + + result = api.get_zoom_participants("meetings", "aDEFghiJKLmno12PQ==") + + called_url = mock_requests.get.call_args.args[0] + self.assertIn("/past_meetings/", called_url) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["user_email"], "alice@example.com") + + def test_get_registrants_returns_list(self): + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.get.return_value = mock_response(200, MEETING_REGISTRANTS_LIST_RESPONSE) + + result = api.get_zoom_registrants("meetings", "91234567890") + + called_url = mock_requests.get.call_args.args[0] + self.assertIn("/meetings/91234567890/registrants", called_url) + self.assertEqual(result[0]["email"], "alice@example.com") +``` + +- [ ] **Step 9: Run to verify they fail** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` +Expected: FAIL — `AttributeError: module 'zoom_integration.api' has no attribute 'get_zoom_participants'` + +- [ ] **Step 10: Implement the paginated getters in `api.py`** + +Append to `zoom_integration/api.py`: + +```python +def _paginate(url_builder, item_key: str, page_size: int) -> list[dict]: + """Follow Zoom's next_page_token pagination and collect `item_key` items.""" + headers = get_authenticated_headers_for_zoom() + items: list[dict] = [] + next_page_token = None + + while True: + url = url_builder(page_size) + if next_page_token: + url += f"&next_page_token={next_page_token}" + + response = requests.get(url, headers=headers, timeout=30) + if response.status_code != 200: + frappe.throw(f"Failed to fetch {item_key} from Zoom: {response.text}") + + data = response.json() + items.extend(data.get(item_key, [])) + next_page_token = data.get("next_page_token") + if not next_page_token: + break + + return items + + +def get_zoom_registrants(resource: str, session_id: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: + return _paginate( + lambda size: f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants?page_size={size}", + "registrants", + page_size, + ) + + +def get_zoom_participants(resource: str, session_uuid: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: + # ponytail: single-encoding the UUID covers the common case; double-encode + # here if you hit meeting UUIDs that start with "/" or contain "//". + from urllib.parse import quote + + encoded_uuid = quote(session_uuid, safe="") + return _paginate( + lambda size: f"{ZOOM_API_BASE_PATH}/past_{resource}/{encoded_uuid}/participants?page_size={size}", + "participants", + page_size, + ) +``` + +- [ ] **Step 11: Run all api tests to verify they pass** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` +Expected: PASS (7 tests) + +- [ ] **Step 12: Commit** + +```bash +git add zoom_integration/api.py zoom_integration/tests/ +git commit -m "feat: add resource-parameterized Zoom API helpers with mocked tests" +``` + +--- + +### Task 2: `Zoom Meeting` DocType + controller + +**Files:** +- Create: `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json` +- Create: `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py` +- Create: `zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py` +- Test: `zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py` + +**Interfaces:** +- Consumes: `zoom_integration.api.{create_zoom_session, update_zoom_session, delete_zoom_session, add_zoom_registrant}` from Task 1. +- Produces: + - `Zoom Meeting` doctype with fields: `title` (Data, reqd), `agenda` (Small Text), `date` (Date), `start_time` (Time), `duration` (Duration), `timezone` (Autocomplete), `zoom_meeting_id` (Data, read-only), `zoom_meeting_uuid` (Data, read-only), `zoom_link` (Data, read-only), `send_zoom_registration_email` (Check), `attendance_synced` (Check). + - `ZoomMeeting.add_registrant(email, first_name, last_name=None, additional_params=None) -> dict` returning `{"registrant_id", "join_url", ...}` (same shape as `ZoomWebinar.add_registrant` for the non-plus path). + +- [ ] **Step 1: Create the DocType JSON** + +Create `zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py` (empty). + +Create `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json`. Base it on the existing `zoom_webinar.json` but drop the webinar-plus section and use meeting field names. Full content: + +```json +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-07-24 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "agenda", + "timings_section", + "date", + "column_break_time", + "start_time", + "duration", + "column_break_tz", + "timezone", + "meeting_details_section", + "zoom_meeting_id", + "zoom_meeting_uuid", + "column_break_details", + "zoom_link", + "send_zoom_registration_email", + "attendance_synced" + ], + "fields": [ + {"fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1, "in_list_view": 1}, + {"fieldname": "agenda", "fieldtype": "Small Text", "label": "Agenda"}, + {"fieldname": "timings_section", "fieldtype": "Section Break", "label": "Timings"}, + {"fieldname": "date", "fieldtype": "Date", "label": "Date", "reqd": 1}, + {"fieldname": "column_break_time", "fieldtype": "Column Break"}, + {"fieldname": "start_time", "fieldtype": "Time", "label": "Start Time", "reqd": 1}, + {"fieldname": "duration", "fieldtype": "Duration", "label": "Duration"}, + {"fieldname": "column_break_tz", "fieldtype": "Column Break"}, + {"fieldname": "timezone", "fieldtype": "Autocomplete", "label": "Timezone"}, + {"fieldname": "meeting_details_section", "fieldtype": "Section Break", "label": "Meeting Details"}, + {"fieldname": "zoom_meeting_id", "fieldtype": "Data", "label": "Zoom Meeting ID", "read_only": 1}, + {"fieldname": "zoom_meeting_uuid", "fieldtype": "Data", "label": "Zoom Meeting UUID", "read_only": 1}, + {"fieldname": "column_break_details", "fieldtype": "Column Break"}, + {"fieldname": "zoom_link", "fieldtype": "Data", "label": "Join URL", "read_only": 1}, + {"fieldname": "send_zoom_registration_email", "fieldtype": "Check", "label": "Send Zoom Registration Email"}, + {"fieldname": "attendance_synced", "fieldtype": "Check", "label": "Attendance Synced", "read_only": 1} + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-07-24 00:00:00", + "modified_by": "Administrator", + "module": "Zoom Integration", + "name": "Zoom Meeting", + "owner": "Administrator", + "permissions": [ + {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1} + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} +``` + +- [ ] **Step 2: Write the failing controller test** + +Create `zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py`: + +```python +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.tests.zoom_fixtures import ( + ADD_MEETING_REGISTRANT_RESPONSE, + CREATE_MEETING_RESPONSE, +) + +CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + + +class TestZoomMeeting(IntegrationTestCase): + def _new_meeting(self): + return frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Test Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ) + + def test_insert_creates_meeting_on_zoom_and_stores_ids(self): + with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE) as mock_create: + meeting = self._new_meeting().insert() + + resource, body = mock_create.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(body["type"], 2) + self.assertEqual(body["settings"]["approval_type"], 0) + self.assertEqual(meeting.zoom_meeting_id, "91234567890") + self.assertEqual(meeting.zoom_meeting_uuid, "aDEFghiJKLmno12PQ==") + self.assertEqual(meeting.zoom_link, CREATE_MEETING_RESPONSE["join_url"]) + + meeting.delete(ignore_permissions=True, force=True) + + def test_add_registrant_returns_registrant_id_and_join_url(self): + with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + meeting = self._new_meeting().insert() + + with patch( + f"{CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE + ) as mock_add: + result = meeting.add_registrant("alice@example.com", "Alice", "Smith") + + resource, session_id, body = mock_add.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(session_id, "91234567890") + self.assertEqual(body["email"], "alice@example.com") + self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") + self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + + meeting.delete(ignore_permissions=True, force=True) +``` + +- [ ] **Step 3: Run migrate then the test to verify it fails** + +Run: +```bash +bench --site buzz.localhost migrate +bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting +``` +Expected: FAIL — controller has no `create_webinar`/`add_registrant` logic yet (import error or missing storage of ids). + +- [ ] **Step 4: Write the controller** + +Create `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py`: + +```python +# Copyright (c) 2026, Build With Hussain and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import cint, format_datetime, get_time + +from zoom_integration.api import ( + add_zoom_registrant, + create_zoom_session, + delete_zoom_session, + update_zoom_session, +) + +RESOURCE = "meetings" + + +class ZoomMeeting(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + agenda: DF.SmallText | None + attendance_synced: DF.Check + date: DF.Date + duration: DF.Duration + send_zoom_registration_email: DF.Check + start_time: DF.Time + timezone: DF.Autocomplete | None + title: DF.Data + zoom_link: DF.Data | None + zoom_meeting_id: DF.Data | None + zoom_meeting_uuid: DF.Data | None + # end: auto-generated types + + def before_insert(self): + self.create_meeting_on_zoom() + + def create_meeting_on_zoom(self): + if self.zoom_meeting_id: + return + + body = { + "topic": self.title, + "agenda": self.agenda or self.title, + "type": 2, # Scheduled meeting + "duration": cint(self.duration / 60) if self.duration else 60, + "start_time": format_datetime(f"{self.date} {self.start_time}", "yyyy-MM-ddTHH:mm:ss"), + "timezone": self.timezone or "Asia/Calcutta", + "settings": { + "host_video": True, + "participant_video": True, + "join_before_host": False, + "approval_type": 0, # Automatically approve registrants (required for registration) + "registration_type": 1, # Register once and join anytime + "audio": "both", + "auto_recording": "cloud", + "meeting_authentication": False, + }, + "registrants_email_notification": bool(self.send_zoom_registration_email), + } + + data = create_zoom_session(RESOURCE, body) + self.zoom_link = data.get("join_url") + self.zoom_meeting_id = data.get("id") + self.zoom_meeting_uuid = data.get("uuid") + frappe.msgprint(_("Meeting created successfully on Zoom.")) + + def on_update(self): + if not self.zoom_meeting_id: + return + self.update_meeting_on_zoom_if_applicable() + + def update_meeting_on_zoom_if_applicable(self): + if self.flags.in_import or self.flags.in_migration or self.is_new(): + return + + before_save = self.get_doc_before_save() + if not before_save: + return + + unchanged = ( + self.title == before_save.title + and self.agenda == before_save.agenda + and self.duration == before_save.duration + and self.timezone == before_save.timezone + and self.get("date") == str(before_save.get("date")) + and get_time(self.get("start_time")) == get_time(before_save.get("start_time")) + ) + if unchanged: + return + + body = { + "topic": self.title, + "agenda": self.agenda or self.title, + "duration": cint(self.duration / 60) if self.duration else 60, + "start_time": format_datetime(f"{self.date} {self.start_time}", "yyyy-MM-ddTHH:mm:ss"), + "timezone": self.timezone or "Asia/Calcutta", + } + update_zoom_session(RESOURCE, self.zoom_meeting_id, body) + frappe.msgprint(_("Meeting updated successfully on Zoom.")) + + def on_trash(self): + if not self.zoom_meeting_id: + return + deleted = delete_zoom_session(RESOURCE, self.zoom_meeting_id) + if not deleted: + frappe.msgprint(_("Meeting not found on Zoom (already deleted). Clearing local reference.")) + + def add_registrant( + self, email: str, first_name: str, last_name: str | None = None, additional_params: dict | None = None + ) -> dict: + if not self.zoom_meeting_id: + frappe.throw(_("Meeting not created on Zoom yet.")) + + body = { + "email": email, + "first_name": first_name, + "last_name": last_name or "N/A", + **(additional_params or {}), + } + return add_zoom_registrant(RESOURCE, self.zoom_meeting_id, body) +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting` +Expected: PASS (2 tests) + +- [ ] **Step 6: Commit** + +```bash +git add zoom_integration/zoom_integration/doctype/zoom_meeting/ +git commit -m "feat: add Zoom Meeting doctype backed by shared API helpers" +``` + +--- + +### Task 3: Registration doctype supports meetings + +Make `Zoom Webinar Registration` reference either a webinar or a meeting. Additive optional `meeting` link; `webinar` becomes optional; `before_submit` branches. + +**Files:** +- Modify: `zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json` (add `meeting` field, set `webinar` reqd=0) +- Modify: `zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py` +- Test: `zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py` + +**Interfaces:** +- Consumes: `ZoomMeeting.add_registrant(...)` from Task 2. +- Produces: A `Zoom Webinar Registration` with `meeting` set, on submit, calls the meeting's `add_registrant` and stores `join_url` + `registrant_id`. + +- [ ] **Step 1: Add the `meeting` field and relax `webinar` in the JSON** + +In `zoom_webinar_registration.json`: +- Add to `fields` (place after the existing `webinar` field object): + ```json + {"fieldname": "meeting", "fieldtype": "Link", "label": "Meeting", "options": "Zoom Meeting"} + ``` +- Add `"meeting"` into `field_order` immediately after `"webinar"`. +- On the existing `webinar` field object, remove `"reqd": 1` if present (make it optional). + +- [ ] **Step 2: Write the failing test** + +Replace the body of `test_zoom_webinar_registration.py` with: + +```python +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +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" + + +class IntegrationTestZoomWebinarRegistration(IntegrationTestCase): + def test_registration_for_meeting_calls_meeting_add_registrant(self): + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + meeting = frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Reg Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + with patch( + f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE + ): + registration = frappe.get_doc( + { + "doctype": "Zoom Webinar Registration", + "meeting": meeting.name, + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Smith", + } + ).insert() + registration.submit() + + self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") + self.assertEqual(registration.join_url, ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + + registration.cancel() + registration.delete(force=True) + meeting.delete(ignore_permissions=True, force=True) +``` + +- [ ] **Step 3: Run migrate then the test to verify it fails** + +Run: +```bash +bench --site buzz.localhost migrate +bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_webinar_registration.test_zoom_webinar_registration +``` +Expected: FAIL — `before_submit` still hardcodes the webinar path, so `registrant_id` stays empty (AssertionError). + +- [ ] **Step 4: Branch `before_submit` on webinar vs meeting** + +In `zoom_webinar_registration.py`, replace the `before_submit` method: + +```python + def before_submit(self): + if self.synced_from_zoom: + # already synced from zoom, no need to register again + return + + additional_params = {} + if self.additional_params: + additional_params = {param.key: param.value for param in self.additional_params} + + if self.meeting: + session = frappe.get_cached_doc("Zoom Meeting", self.meeting) + elif self.webinar: + session = frappe.get_cached_doc("Zoom Webinar", self.webinar) + else: + frappe.throw(frappe._("Registration must reference a Zoom Meeting or Zoom Webinar.")) + + registration = session.add_registrant( + self.email, self.first_name, self.last_name, additional_params + ) + + self.join_url = registration.get("join_url") + self.registrant_id = registration.get("registrant_id") +``` + +Also add `meeting: DF.Link | None` to the auto-generated types block (alphabetically, near `last_name`). + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_webinar_registration.test_zoom_webinar_registration` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add zoom_integration/zoom_integration/doctype/zoom_webinar_registration/ +git commit -m "feat: allow Zoom Webinar Registration to register against a Zoom Meeting" +``` + +--- + +### Task 4: Meeting attendance sync + +Add a `meeting` link to the attendance record and a `sync_attendance` on `Zoom Meeting` that reuses the shared getters. Mirrors the Webinar flow but simpler (no batching complexity unless needed). + +**Files:** +- Modify: `zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json` (add `meeting` link; set `webinar` reqd=0) +- Modify: `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py` (add `sync_attendance`) +- Test: `zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py` (append) + +**Interfaces:** +- Consumes: `zoom_integration.api.get_zoom_participants` from Task 1. +- Produces: `ZoomMeeting.sync_attendance()` creating `Zoom Webinar Attendance Record` rows keyed by `meeting` + `user_email`. + +- [ ] **Step 1: Add the `meeting` field to the attendance record JSON** + +In `zoom_webinar_attendance_record.json`: +- Add to `fields` after the `webinar` field object: + ```json + {"fieldname": "meeting", "fieldtype": "Link", "label": "Meeting", "options": "Zoom Meeting"} + ``` +- Add `"meeting"` into `field_order` after `"webinar"`. +- Remove `"reqd": 1` from the `webinar` field object if present. + +- [ ] **Step 2: Write the failing test** + +Append to `TestZoomMeeting` in `test_zoom_meeting.py`: + +```python + def test_sync_attendance_creates_records_from_participants(self): + from zoom_integration.tests.zoom_fixtures import MEETING_PARTICIPANTS_RESPONSE + + with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): + meeting = self._new_meeting().insert() + + participants = MEETING_PARTICIPANTS_RESPONSE["participants"] + with patch(f"{CONTROLLER}.get_zoom_participants", return_value=participants): + meeting.sync_attendance() + + records = frappe.get_all( + "Zoom Webinar Attendance Record", + filters={"meeting": meeting.name}, + fields=["user_email", "total_duration"], + ) + emails = {r.user_email for r in records} + self.assertEqual(emails, {"alice@example.com", "bob@example.com"}) + + frappe.db.delete("Zoom Webinar Attendance Record", {"meeting": meeting.name}) + meeting.delete(ignore_permissions=True, force=True) +``` + +- [ ] **Step 3: Run migrate then the test to verify it fails** + +Run: +```bash +bench --site buzz.localhost migrate +bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting +``` +Expected: FAIL — `AttributeError: 'ZoomMeeting' object has no attribute 'sync_attendance'` + +- [ ] **Step 4: Implement `sync_attendance`** + +In `zoom_meeting.py`, add the import `from zoom_integration.api import get_zoom_participants` (extend the existing import line) and add the method to `ZoomMeeting`: + +```python + @frappe.whitelist() + def sync_attendance(self): + if not self.zoom_meeting_uuid: + frappe.throw(_("Meeting has not run yet (no UUID to fetch attendance for).")) + + participants = get_zoom_participants(RESOURCE, self.zoom_meeting_uuid) + + # Same user can join multiple times; sum durations per email. + summary: dict[str, dict] = {} + for participant in participants: + email = participant.get("user_email") + if not email: + continue + entry = summary.setdefault( + email, + {"name": participant.get("name"), "total_duration": 0, "registrant_id": participant.get("registrant_id")}, + ) + entry["total_duration"] += participant.get("duration", 0) + + for email, details in summary.items(): + if frappe.db.exists("Zoom Webinar Attendance Record", {"meeting": self.name, "user_email": email}): + continue + frappe.get_doc( + { + "doctype": "Zoom Webinar Attendance Record", + "meeting": self.name, + "user_email": email, + "full_name": details["name"], + "total_duration": details["total_duration"], + "registrant_id": details["registrant_id"], + "docstatus": 1, + } + ).insert(ignore_permissions=True) + + self.db_set("attendance_synced", 1) + frappe.msgprint(_("Synced attendance for {0} participants.").format(len(summary))) + + @frappe.whitelist() + def sync_attendance_in_background(self): + frappe.enqueue_doc(self.doctype, self.name, "sync_attendance", queue="long") +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting` +Expected: PASS (3 tests) + +- [ ] **Step 6: Run the full app suite** + +Run: `bench --site buzz.localhost run-tests --app zoom_integration` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add zoom_integration/zoom_integration/doctype/zoom_meeting/ zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/ +git commit -m "feat: sync Zoom Meeting attendance into attendance records" +``` + +--- + +## Self-Review Notes + +- **Coverage:** create (T2), update (T2), delete (T2), add registrant (T2/T3), attendance sync (T4), shared helpers (T1). All exercised by mocked tests. +- **Types:** `add_registrant` returns a dict with `registrant_id`/`join_url` in both `ZoomMeeting` (T2) and consumed identically in registration `before_submit` (T3). `create_zoom_session` returns the raw dict used in T2 controller. +- **No migration of existing rows:** `meeting` is additive and optional; `webinar` relaxed to optional. Existing webinar registrations/attendance untouched. +- **Deferred (ponytail):** meeting attendance sync skips the batching/retry/progress machinery the webinar path has — add it only if a meeting ever pulls >300 participants in a way that times out. UUID double-encoding noted inline in `get_zoom_participants`. diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py index 1febb4f..5819dca 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -151,7 +151,9 @@ def sync_attendance(self): entry["total_duration"] += participant.get("duration", 0) for email, details in summary.items(): - if frappe.db.exists("Zoom Webinar Attendance Record", {"meeting": self.name, "user_email": email}): + if frappe.db.exists( + "Zoom Webinar Attendance Record", {"meeting": self.name, "user_email": email} + ): continue frappe.get_doc( { diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py index a16c92d..292d702 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py @@ -25,9 +25,7 @@ def test_registration_for_meeting_calls_meeting_add_registrant(self): } ).insert() - with patch( - f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE - ): + with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE): registration = frappe.get_doc( { "doctype": "Zoom Webinar Registration", diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py index 83cc15c..ebe592e 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py @@ -63,9 +63,7 @@ def before_submit(self): else: frappe.throw(frappe._("Registration must reference a Zoom Meeting or Zoom Webinar.")) - registration = session.add_registrant( - self.email, self.first_name, self.last_name, additional_params - ) + registration = session.add_registrant(self.email, self.first_name, self.last_name, additional_params) self.join_url = registration.get("join_url") self.registrant_id = registration.get("registrant_id") From f6ad0d220806b0314634176e9b217b4ead76745b Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 18:06:16 +0530 Subject: [PATCH 06/20] chore: stop tracking local planning docs --- .gitignore | 3 + .../plans/2026-07-24-zoom-meetings.md | 980 ------------------ 2 files changed, 3 insertions(+), 980 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-24-zoom-meetings.md diff --git a/.gitignore b/.gitignore index b50d332..79351e0 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ jspm_packages/ # Aider AI Chat .aider* + +# Local planning docs +docs/ diff --git a/docs/superpowers/plans/2026-07-24-zoom-meetings.md b/docs/superpowers/plans/2026-07-24-zoom-meetings.md deleted file mode 100644 index 88b87b6..0000000 --- a/docs/superpowers/plans/2026-07-24-zoom-meetings.md +++ /dev/null @@ -1,980 +0,0 @@ -# Zoom Meetings Support (zoom_integration) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add first-class Zoom **Meeting** support alongside the existing Webinar support, so a Zoom Meeting can be created/updated/deleted, registrants added, and attendance synced — reusing shared API helpers instead of duplicating the Webinar controller. - -**Architecture:** Extract the resource-parameterized HTTP calls into a new `zoom_integration/api.py` (functions take `resource="meetings"|"webinars"`). Add a `Zoom Meeting` DocType whose controller delegates to those helpers. Make the existing `Zoom Webinar Registration` and `Zoom Webinar Attendance Record` doctypes reference **either** a webinar or a meeting via an additive optional `meeting` link (no migration of existing rows). The existing `Zoom Webinar` controller is left untouched (working code, out of scope to refactor). - -**Tech Stack:** Frappe Framework (Python), `requests`, `unittest.mock` for patching the Zoom HTTP seam, `frappe.tests.IntegrationTestCase`. - -## Global Constraints - -- Branch: `feat/zoom-meetings` off `main`. All work on this branch. -- Tabs for indentation (match existing files). -- Zoom Server-to-Server OAuth is already configured in `Zoom Settings`; auth is via `zoom_integration.utils.get_authenticated_headers_for_zoom()` — never call the network in tests, always patch this and `requests`. -- Meeting `type` is `2` (scheduled meeting). Registrants require `settings.approval_type: 0` in the create body. -- Meeting **participant** reports use the meeting **UUID** (`past_meetings/{uuid}`), not the numeric id. Create/update/delete/registrants use the numeric id. -- Mock return values must mirror real Zoom API JSON (shapes fixed in Task 1, reused everywhere). -- Run tests with: `bench --site buzz.localhost run-tests --app zoom_integration --module ` (ask user for site if `buzz.localhost` missing). Credentials Administrator/admin. -- After any doctype schema change: `bench --site buzz.localhost migrate`. - ---- - -### Task 1: Shared Zoom API helpers (`api.py`) - -Resource-parameterized functions all controllers call. Fully mockable at `zoom_integration.api.requests` + `zoom_integration.api.get_authenticated_headers_for_zoom`. - -**Files:** -- Create: `zoom_integration/api.py` -- Create/Test: `zoom_integration/tests/__init__.py` (empty), `zoom_integration/tests/test_api.py` -- Create/Test fixture: `zoom_integration/tests/zoom_fixtures.py` (realistic mock responses reused by all tasks) - -**Interfaces:** -- Produces: - - `create_zoom_session(resource: str, body: dict) -> dict` — POST `/users/me/{resource}`, 201 → parsed JSON (has `id`, `uuid`, `join_url`), else `frappe.throw`. - - `update_zoom_session(resource: str, session_id: str, body: dict) -> None` — PATCH `/{resource}/{id}`, expects 204. - - `delete_zoom_session(resource: str, session_id: str) -> bool` — DELETE `/{resource}/{id}`, 204 → `True`; Zoom code 3001 (already gone) → `False`; else throw. - - `add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict` — POST `/{resource}/{id}/registrants`, 200/201 → parsed JSON (has `registrant_id`, `join_url`), else throw. - - `get_zoom_registrants(resource: str, session_id: str, page_size: int = 300) -> list[dict]` — GET `/{resource}/{id}/registrants`, follows `next_page_token`. - - `get_zoom_participants(resource: str, session_uuid: str, page_size: int = 300) -> list[dict]` — GET `/past_{resource}/{uuid}/participants`, follows `next_page_token`. - -- [ ] **Step 1: Write the realistic mock fixtures** - -Create `zoom_integration/tests/__init__.py` (empty file) and `zoom_integration/tests/zoom_fixtures.py`: - -```python -"""Realistic Zoom API response fixtures. Shapes mirror the live Zoom v2 API -so mocks behave like production. Reused by all zoom_integration tests.""" - -CREATE_MEETING_RESPONSE = { - "uuid": "aDEFghiJKLmno12PQ==", - "id": 91234567890, - "host_id": "hostAbc123", - "topic": "Test Meeting", - "type": 2, - "status": "waiting", - "start_time": "2026-08-01T10:00:00Z", - "duration": 60, - "timezone": "Asia/Calcutta", - "created_at": "2026-07-24T09:00:00Z", - "join_url": "https://zoom.us/j/91234567890?pwd=xYzToken", - "start_url": "https://zoom.us/s/91234567890?zak=zakToken", - "password": "aB3dEf", - "settings": {"approval_type": 0, "registration_type": 1, "audio": "both"}, -} - -# PATCH /meetings/{id} and DELETE /meetings/{id} return 204 with an empty body. - -ADD_MEETING_REGISTRANT_RESPONSE = { - "id": 91234567890, - "topic": "Test Meeting", - "registrant_id": "abcDEF12ghIJ", - "start_time": "2026-08-01T10:00:00Z", - "join_url": "https://zoom.us/w/91234567890?tk=regToken&pwd=xYz", -} - -MEETING_REGISTRANTS_LIST_RESPONSE = { - "page_count": 1, - "page_size": 300, - "total_records": 1, - "next_page_token": "", - "registrants": [ - { - "id": "abcDEF12ghIJ", - "email": "alice@example.com", - "first_name": "Alice", - "last_name": "Smith", - "status": "approved", - "create_time": "2026-07-24T09:10:00Z", - "join_url": "https://zoom.us/w/91234567890?tk=regToken", - "custom_questions": [], - } - ], -} - -MEETING_PARTICIPANTS_RESPONSE = { - "page_count": 1, - "page_size": 300, - "total_records": 2, - "next_page_token": "", - "participants": [ - { - "id": "", - "user_id": "16778240", - "name": "Alice", - "user_email": "alice@example.com", - "join_time": "2026-08-01T10:01:00Z", - "leave_time": "2026-08-01T10:55:00Z", - "duration": 3240, - "registrant_id": "abcDEF12ghIJ", - }, - { - "id": "", - "user_id": "16778241", - "name": "Bob", - "user_email": "bob@example.com", - "join_time": "2026-08-01T10:05:00Z", - "leave_time": "2026-08-01T10:50:00Z", - "duration": 2700, - "registrant_id": "klmNOP34qrST", - }, - ], -} - -# Zoom "resource not found on delete" error body. -NOT_FOUND_DELETE_RESPONSE = {"code": 3001, "message": "Meeting does not exist."} - - -def mock_response(status_code, json_data=None, text=""): - """Build a fake requests.Response-like object for patching.""" - from unittest.mock import MagicMock - - resp = MagicMock() - resp.status_code = status_code - resp.json.return_value = json_data if json_data is not None else {} - resp.text = text or "" - resp.headers = {} - return resp -``` - -- [ ] **Step 2: Write the failing test for `create_zoom_session`** - -Create `zoom_integration/tests/test_api.py`: - -```python -from unittest.mock import patch - -from frappe.tests import IntegrationTestCase - -from zoom_integration import api -from zoom_integration.tests.zoom_fixtures import ( - ADD_MEETING_REGISTRANT_RESPONSE, - CREATE_MEETING_RESPONSE, - MEETING_PARTICIPANTS_RESPONSE, - MEETING_REGISTRANTS_LIST_RESPONSE, - NOT_FOUND_DELETE_RESPONSE, - mock_response, -) - -HEADERS = {"Authorization": "Bearer test-token", "content-type": "application/json"} - - -class TestZoomAPI(IntegrationTestCase): - def test_create_meeting_posts_to_meetings_endpoint_and_returns_data(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.post.return_value = mock_response(201, CREATE_MEETING_RESPONSE) - - result = api.create_zoom_session("meetings", {"topic": "Test Meeting", "type": 2}) - - called_url = mock_requests.post.call_args.args[0] - self.assertTrue(called_url.endswith("/users/me/meetings")) - self.assertEqual(result["id"], 91234567890) - self.assertEqual(result["uuid"], "aDEFghiJKLmno12PQ==") - self.assertEqual(result["join_url"], CREATE_MEETING_RESPONSE["join_url"]) -``` - -- [ ] **Step 3: Run test to verify it fails** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` -Expected: FAIL — `ModuleNotFoundError: No module named 'zoom_integration.api'` - -- [ ] **Step 4: Write `api.py` (create/update/delete/registrant)** - -Create `zoom_integration/api.py`: - -```python -import json - -import frappe -import requests -from frappe.integrations.utils import create_request_log - -from zoom_integration.utils import ZOOM_API_BASE_PATH, get_authenticated_headers_for_zoom - -DEFAULT_PAGE_SIZE = 300 # Zoom API allows max 300 per page - - -def create_zoom_session(resource: str, body: dict) -> dict: - """Create a meeting or webinar. `resource` is 'meetings' or 'webinars'.""" - url = f"{ZOOM_API_BASE_PATH}/users/me/{resource}" - headers = get_authenticated_headers_for_zoom() - response = requests.post(url, headers=headers, data=json.dumps(body)) - - if response.status_code == 201: - data = response.json() - create_request_log( - data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" - ) - return data - - create_request_log( - response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" - ) - frappe.throw(f"Failed to create {resource[:-1]} on Zoom: {response.text}") - - -def update_zoom_session(resource: str, session_id: str, body: dict) -> None: - url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}" - headers = get_authenticated_headers_for_zoom() - response = requests.patch(url, headers=headers, data=json.dumps(body)) - - if response.status_code != 204: - frappe.throw(f"Failed to update {resource[:-1]} on Zoom: {response.text}") - - -def delete_zoom_session(resource: str, session_id: str) -> bool: - """Returns True if deleted on Zoom, False if it was already gone (code 3001).""" - url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}" - headers = get_authenticated_headers_for_zoom() - response = requests.delete(url, headers=headers) - - if response.status_code == 204: - return True - if response.json().get("code") == 3001: - return False - frappe.throw(f"Failed to delete {resource[:-1]} on Zoom: {response.text}") - - -def add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict: - url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants" - headers = get_authenticated_headers_for_zoom() - response = requests.post(url, headers=headers, data=json.dumps(body)) - - if response.status_code not in (200, 201): - create_request_log( - response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" - ) - frappe.throw(f"Failed to add registrant on Zoom: {response.text}") - - data = response.json() - create_request_log( - data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" - ) - return data -``` - -- [ ] **Step 5: Run the create test to verify it passes** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` -Expected: PASS - -- [ ] **Step 6: Write failing tests for update, delete, and add_registrant** - -Append to `TestZoomAPI` in `zoom_integration/tests/test_api.py`: - -```python - def test_update_meeting_patches_meeting_endpoint(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.patch.return_value = mock_response(204) - - api.update_zoom_session("meetings", "91234567890", {"topic": "New"}) - - called_url = mock_requests.patch.call_args.args[0] - self.assertTrue(called_url.endswith("/meetings/91234567890")) - - def test_delete_meeting_returns_true_on_204(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.delete.return_value = mock_response(204) - self.assertTrue(api.delete_zoom_session("meetings", "91234567890")) - - def test_delete_meeting_returns_false_when_already_gone(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.delete.return_value = mock_response(404, NOT_FOUND_DELETE_RESPONSE) - self.assertFalse(api.delete_zoom_session("meetings", "91234567890")) - - def test_add_registrant_returns_registrant_id_and_join_url(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.post.return_value = mock_response(201, ADD_MEETING_REGISTRANT_RESPONSE) - - result = api.add_zoom_registrant( - "meetings", "91234567890", {"email": "alice@example.com", "first_name": "Alice"} - ) - - called_url = mock_requests.post.call_args.args[0] - self.assertTrue(called_url.endswith("/meetings/91234567890/registrants")) - self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") - self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) -``` - -- [ ] **Step 7: Run to verify update/delete/registrant tests pass** (the code from Step 4 already covers them) - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` -Expected: PASS (all 5 tests) - -- [ ] **Step 8: Write failing tests for the paginated getters** - -Append to `TestZoomAPI`: - -```python - def test_get_participants_uses_past_meetings_uuid_and_returns_list(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.get.return_value = mock_response(200, MEETING_PARTICIPANTS_RESPONSE) - - result = api.get_zoom_participants("meetings", "aDEFghiJKLmno12PQ==") - - called_url = mock_requests.get.call_args.args[0] - self.assertIn("/past_meetings/", called_url) - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["user_email"], "alice@example.com") - - def test_get_registrants_returns_list(self): - with ( - patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), - patch.object(api, "requests") as mock_requests, - ): - mock_requests.get.return_value = mock_response(200, MEETING_REGISTRANTS_LIST_RESPONSE) - - result = api.get_zoom_registrants("meetings", "91234567890") - - called_url = mock_requests.get.call_args.args[0] - self.assertIn("/meetings/91234567890/registrants", called_url) - self.assertEqual(result[0]["email"], "alice@example.com") -``` - -- [ ] **Step 9: Run to verify they fail** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` -Expected: FAIL — `AttributeError: module 'zoom_integration.api' has no attribute 'get_zoom_participants'` - -- [ ] **Step 10: Implement the paginated getters in `api.py`** - -Append to `zoom_integration/api.py`: - -```python -def _paginate(url_builder, item_key: str, page_size: int) -> list[dict]: - """Follow Zoom's next_page_token pagination and collect `item_key` items.""" - headers = get_authenticated_headers_for_zoom() - items: list[dict] = [] - next_page_token = None - - while True: - url = url_builder(page_size) - if next_page_token: - url += f"&next_page_token={next_page_token}" - - response = requests.get(url, headers=headers, timeout=30) - if response.status_code != 200: - frappe.throw(f"Failed to fetch {item_key} from Zoom: {response.text}") - - data = response.json() - items.extend(data.get(item_key, [])) - next_page_token = data.get("next_page_token") - if not next_page_token: - break - - return items - - -def get_zoom_registrants(resource: str, session_id: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: - return _paginate( - lambda size: f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants?page_size={size}", - "registrants", - page_size, - ) - - -def get_zoom_participants(resource: str, session_uuid: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: - # ponytail: single-encoding the UUID covers the common case; double-encode - # here if you hit meeting UUIDs that start with "/" or contain "//". - from urllib.parse import quote - - encoded_uuid = quote(session_uuid, safe="") - return _paginate( - lambda size: f"{ZOOM_API_BASE_PATH}/past_{resource}/{encoded_uuid}/participants?page_size={size}", - "participants", - page_size, - ) -``` - -- [ ] **Step 11: Run all api tests to verify they pass** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.tests.test_api` -Expected: PASS (7 tests) - -- [ ] **Step 12: Commit** - -```bash -git add zoom_integration/api.py zoom_integration/tests/ -git commit -m "feat: add resource-parameterized Zoom API helpers with mocked tests" -``` - ---- - -### Task 2: `Zoom Meeting` DocType + controller - -**Files:** -- Create: `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json` -- Create: `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py` -- Create: `zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py` -- Test: `zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py` - -**Interfaces:** -- Consumes: `zoom_integration.api.{create_zoom_session, update_zoom_session, delete_zoom_session, add_zoom_registrant}` from Task 1. -- Produces: - - `Zoom Meeting` doctype with fields: `title` (Data, reqd), `agenda` (Small Text), `date` (Date), `start_time` (Time), `duration` (Duration), `timezone` (Autocomplete), `zoom_meeting_id` (Data, read-only), `zoom_meeting_uuid` (Data, read-only), `zoom_link` (Data, read-only), `send_zoom_registration_email` (Check), `attendance_synced` (Check). - - `ZoomMeeting.add_registrant(email, first_name, last_name=None, additional_params=None) -> dict` returning `{"registrant_id", "join_url", ...}` (same shape as `ZoomWebinar.add_registrant` for the non-plus path). - -- [ ] **Step 1: Create the DocType JSON** - -Create `zoom_integration/zoom_integration/doctype/zoom_meeting/__init__.py` (empty). - -Create `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json`. Base it on the existing `zoom_webinar.json` but drop the webinar-plus section and use meeting field names. Full content: - -```json -{ - "actions": [], - "allow_rename": 1, - "creation": "2026-07-24 00:00:00", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "title", - "agenda", - "timings_section", - "date", - "column_break_time", - "start_time", - "duration", - "column_break_tz", - "timezone", - "meeting_details_section", - "zoom_meeting_id", - "zoom_meeting_uuid", - "column_break_details", - "zoom_link", - "send_zoom_registration_email", - "attendance_synced" - ], - "fields": [ - {"fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1, "in_list_view": 1}, - {"fieldname": "agenda", "fieldtype": "Small Text", "label": "Agenda"}, - {"fieldname": "timings_section", "fieldtype": "Section Break", "label": "Timings"}, - {"fieldname": "date", "fieldtype": "Date", "label": "Date", "reqd": 1}, - {"fieldname": "column_break_time", "fieldtype": "Column Break"}, - {"fieldname": "start_time", "fieldtype": "Time", "label": "Start Time", "reqd": 1}, - {"fieldname": "duration", "fieldtype": "Duration", "label": "Duration"}, - {"fieldname": "column_break_tz", "fieldtype": "Column Break"}, - {"fieldname": "timezone", "fieldtype": "Autocomplete", "label": "Timezone"}, - {"fieldname": "meeting_details_section", "fieldtype": "Section Break", "label": "Meeting Details"}, - {"fieldname": "zoom_meeting_id", "fieldtype": "Data", "label": "Zoom Meeting ID", "read_only": 1}, - {"fieldname": "zoom_meeting_uuid", "fieldtype": "Data", "label": "Zoom Meeting UUID", "read_only": 1}, - {"fieldname": "column_break_details", "fieldtype": "Column Break"}, - {"fieldname": "zoom_link", "fieldtype": "Data", "label": "Join URL", "read_only": 1}, - {"fieldname": "send_zoom_registration_email", "fieldtype": "Check", "label": "Send Zoom Registration Email"}, - {"fieldname": "attendance_synced", "fieldtype": "Check", "label": "Attendance Synced", "read_only": 1} - ], - "index_web_pages_for_search": 1, - "links": [], - "modified": "2026-07-24 00:00:00", - "modified_by": "Administrator", - "module": "Zoom Integration", - "name": "Zoom Meeting", - "owner": "Administrator", - "permissions": [ - {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1} - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [] -} -``` - -- [ ] **Step 2: Write the failing controller test** - -Create `zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py`: - -```python -from unittest.mock import patch - -import frappe -from frappe.tests import IntegrationTestCase - -from zoom_integration.tests.zoom_fixtures import ( - ADD_MEETING_REGISTRANT_RESPONSE, - CREATE_MEETING_RESPONSE, -) - -CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" - - -class TestZoomMeeting(IntegrationTestCase): - def _new_meeting(self): - return frappe.get_doc( - { - "doctype": "Zoom Meeting", - "title": "Test Meeting", - "date": "2026-08-01", - "start_time": "10:00:00", - "duration": 3600, - "timezone": "Asia/Calcutta", - } - ) - - def test_insert_creates_meeting_on_zoom_and_stores_ids(self): - with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE) as mock_create: - meeting = self._new_meeting().insert() - - resource, body = mock_create.call_args.args - self.assertEqual(resource, "meetings") - self.assertEqual(body["type"], 2) - self.assertEqual(body["settings"]["approval_type"], 0) - self.assertEqual(meeting.zoom_meeting_id, "91234567890") - self.assertEqual(meeting.zoom_meeting_uuid, "aDEFghiJKLmno12PQ==") - self.assertEqual(meeting.zoom_link, CREATE_MEETING_RESPONSE["join_url"]) - - meeting.delete(ignore_permissions=True, force=True) - - def test_add_registrant_returns_registrant_id_and_join_url(self): - with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - meeting = self._new_meeting().insert() - - with patch( - f"{CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE - ) as mock_add: - result = meeting.add_registrant("alice@example.com", "Alice", "Smith") - - resource, session_id, body = mock_add.call_args.args - self.assertEqual(resource, "meetings") - self.assertEqual(session_id, "91234567890") - self.assertEqual(body["email"], "alice@example.com") - self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") - self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) - - meeting.delete(ignore_permissions=True, force=True) -``` - -- [ ] **Step 3: Run migrate then the test to verify it fails** - -Run: -```bash -bench --site buzz.localhost migrate -bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting -``` -Expected: FAIL — controller has no `create_webinar`/`add_registrant` logic yet (import error or missing storage of ids). - -- [ ] **Step 4: Write the controller** - -Create `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py`: - -```python -# Copyright (c) 2026, Build With Hussain and contributors -# For license information, please see license.txt - -import frappe -from frappe import _ -from frappe.model.document import Document -from frappe.utils import cint, format_datetime, get_time - -from zoom_integration.api import ( - add_zoom_registrant, - create_zoom_session, - delete_zoom_session, - update_zoom_session, -) - -RESOURCE = "meetings" - - -class ZoomMeeting(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - agenda: DF.SmallText | None - attendance_synced: DF.Check - date: DF.Date - duration: DF.Duration - send_zoom_registration_email: DF.Check - start_time: DF.Time - timezone: DF.Autocomplete | None - title: DF.Data - zoom_link: DF.Data | None - zoom_meeting_id: DF.Data | None - zoom_meeting_uuid: DF.Data | None - # end: auto-generated types - - def before_insert(self): - self.create_meeting_on_zoom() - - def create_meeting_on_zoom(self): - if self.zoom_meeting_id: - return - - body = { - "topic": self.title, - "agenda": self.agenda or self.title, - "type": 2, # Scheduled meeting - "duration": cint(self.duration / 60) if self.duration else 60, - "start_time": format_datetime(f"{self.date} {self.start_time}", "yyyy-MM-ddTHH:mm:ss"), - "timezone": self.timezone or "Asia/Calcutta", - "settings": { - "host_video": True, - "participant_video": True, - "join_before_host": False, - "approval_type": 0, # Automatically approve registrants (required for registration) - "registration_type": 1, # Register once and join anytime - "audio": "both", - "auto_recording": "cloud", - "meeting_authentication": False, - }, - "registrants_email_notification": bool(self.send_zoom_registration_email), - } - - data = create_zoom_session(RESOURCE, body) - self.zoom_link = data.get("join_url") - self.zoom_meeting_id = data.get("id") - self.zoom_meeting_uuid = data.get("uuid") - frappe.msgprint(_("Meeting created successfully on Zoom.")) - - def on_update(self): - if not self.zoom_meeting_id: - return - self.update_meeting_on_zoom_if_applicable() - - def update_meeting_on_zoom_if_applicable(self): - if self.flags.in_import or self.flags.in_migration or self.is_new(): - return - - before_save = self.get_doc_before_save() - if not before_save: - return - - unchanged = ( - self.title == before_save.title - and self.agenda == before_save.agenda - and self.duration == before_save.duration - and self.timezone == before_save.timezone - and self.get("date") == str(before_save.get("date")) - and get_time(self.get("start_time")) == get_time(before_save.get("start_time")) - ) - if unchanged: - return - - body = { - "topic": self.title, - "agenda": self.agenda or self.title, - "duration": cint(self.duration / 60) if self.duration else 60, - "start_time": format_datetime(f"{self.date} {self.start_time}", "yyyy-MM-ddTHH:mm:ss"), - "timezone": self.timezone or "Asia/Calcutta", - } - update_zoom_session(RESOURCE, self.zoom_meeting_id, body) - frappe.msgprint(_("Meeting updated successfully on Zoom.")) - - def on_trash(self): - if not self.zoom_meeting_id: - return - deleted = delete_zoom_session(RESOURCE, self.zoom_meeting_id) - if not deleted: - frappe.msgprint(_("Meeting not found on Zoom (already deleted). Clearing local reference.")) - - def add_registrant( - self, email: str, first_name: str, last_name: str | None = None, additional_params: dict | None = None - ) -> dict: - if not self.zoom_meeting_id: - frappe.throw(_("Meeting not created on Zoom yet.")) - - body = { - "email": email, - "first_name": first_name, - "last_name": last_name or "N/A", - **(additional_params or {}), - } - return add_zoom_registrant(RESOURCE, self.zoom_meeting_id, body) -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting` -Expected: PASS (2 tests) - -- [ ] **Step 6: Commit** - -```bash -git add zoom_integration/zoom_integration/doctype/zoom_meeting/ -git commit -m "feat: add Zoom Meeting doctype backed by shared API helpers" -``` - ---- - -### Task 3: Registration doctype supports meetings - -Make `Zoom Webinar Registration` reference either a webinar or a meeting. Additive optional `meeting` link; `webinar` becomes optional; `before_submit` branches. - -**Files:** -- Modify: `zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json` (add `meeting` field, set `webinar` reqd=0) -- Modify: `zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py` -- Test: `zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py` - -**Interfaces:** -- Consumes: `ZoomMeeting.add_registrant(...)` from Task 2. -- Produces: A `Zoom Webinar Registration` with `meeting` set, on submit, calls the meeting's `add_registrant` and stores `join_url` + `registrant_id`. - -- [ ] **Step 1: Add the `meeting` field and relax `webinar` in the JSON** - -In `zoom_webinar_registration.json`: -- Add to `fields` (place after the existing `webinar` field object): - ```json - {"fieldname": "meeting", "fieldtype": "Link", "label": "Meeting", "options": "Zoom Meeting"} - ``` -- Add `"meeting"` into `field_order` immediately after `"webinar"`. -- On the existing `webinar` field object, remove `"reqd": 1` if present (make it optional). - -- [ ] **Step 2: Write the failing test** - -Replace the body of `test_zoom_webinar_registration.py` with: - -```python -from unittest.mock import patch - -import frappe -from frappe.tests import IntegrationTestCase - -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" - - -class IntegrationTestZoomWebinarRegistration(IntegrationTestCase): - def test_registration_for_meeting_calls_meeting_add_registrant(self): - with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - meeting = frappe.get_doc( - { - "doctype": "Zoom Meeting", - "title": "Reg Meeting", - "date": "2026-08-01", - "start_time": "10:00:00", - "duration": 3600, - "timezone": "Asia/Calcutta", - } - ).insert() - - with patch( - f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE - ): - registration = frappe.get_doc( - { - "doctype": "Zoom Webinar Registration", - "meeting": meeting.name, - "email": "alice@example.com", - "first_name": "Alice", - "last_name": "Smith", - } - ).insert() - registration.submit() - - self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") - self.assertEqual(registration.join_url, ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) - - registration.cancel() - registration.delete(force=True) - meeting.delete(ignore_permissions=True, force=True) -``` - -- [ ] **Step 3: Run migrate then the test to verify it fails** - -Run: -```bash -bench --site buzz.localhost migrate -bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_webinar_registration.test_zoom_webinar_registration -``` -Expected: FAIL — `before_submit` still hardcodes the webinar path, so `registrant_id` stays empty (AssertionError). - -- [ ] **Step 4: Branch `before_submit` on webinar vs meeting** - -In `zoom_webinar_registration.py`, replace the `before_submit` method: - -```python - def before_submit(self): - if self.synced_from_zoom: - # already synced from zoom, no need to register again - return - - additional_params = {} - if self.additional_params: - additional_params = {param.key: param.value for param in self.additional_params} - - if self.meeting: - session = frappe.get_cached_doc("Zoom Meeting", self.meeting) - elif self.webinar: - session = frappe.get_cached_doc("Zoom Webinar", self.webinar) - else: - frappe.throw(frappe._("Registration must reference a Zoom Meeting or Zoom Webinar.")) - - registration = session.add_registrant( - self.email, self.first_name, self.last_name, additional_params - ) - - self.join_url = registration.get("join_url") - self.registrant_id = registration.get("registrant_id") -``` - -Also add `meeting: DF.Link | None` to the auto-generated types block (alphabetically, near `last_name`). - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_webinar_registration.test_zoom_webinar_registration` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add zoom_integration/zoom_integration/doctype/zoom_webinar_registration/ -git commit -m "feat: allow Zoom Webinar Registration to register against a Zoom Meeting" -``` - ---- - -### Task 4: Meeting attendance sync - -Add a `meeting` link to the attendance record and a `sync_attendance` on `Zoom Meeting` that reuses the shared getters. Mirrors the Webinar flow but simpler (no batching complexity unless needed). - -**Files:** -- Modify: `zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json` (add `meeting` link; set `webinar` reqd=0) -- Modify: `zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py` (add `sync_attendance`) -- Test: `zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py` (append) - -**Interfaces:** -- Consumes: `zoom_integration.api.get_zoom_participants` from Task 1. -- Produces: `ZoomMeeting.sync_attendance()` creating `Zoom Webinar Attendance Record` rows keyed by `meeting` + `user_email`. - -- [ ] **Step 1: Add the `meeting` field to the attendance record JSON** - -In `zoom_webinar_attendance_record.json`: -- Add to `fields` after the `webinar` field object: - ```json - {"fieldname": "meeting", "fieldtype": "Link", "label": "Meeting", "options": "Zoom Meeting"} - ``` -- Add `"meeting"` into `field_order` after `"webinar"`. -- Remove `"reqd": 1` from the `webinar` field object if present. - -- [ ] **Step 2: Write the failing test** - -Append to `TestZoomMeeting` in `test_zoom_meeting.py`: - -```python - def test_sync_attendance_creates_records_from_participants(self): - from zoom_integration.tests.zoom_fixtures import MEETING_PARTICIPANTS_RESPONSE - - with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - meeting = self._new_meeting().insert() - - participants = MEETING_PARTICIPANTS_RESPONSE["participants"] - with patch(f"{CONTROLLER}.get_zoom_participants", return_value=participants): - meeting.sync_attendance() - - records = frappe.get_all( - "Zoom Webinar Attendance Record", - filters={"meeting": meeting.name}, - fields=["user_email", "total_duration"], - ) - emails = {r.user_email for r in records} - self.assertEqual(emails, {"alice@example.com", "bob@example.com"}) - - frappe.db.delete("Zoom Webinar Attendance Record", {"meeting": meeting.name}) - meeting.delete(ignore_permissions=True, force=True) -``` - -- [ ] **Step 3: Run migrate then the test to verify it fails** - -Run: -```bash -bench --site buzz.localhost migrate -bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting -``` -Expected: FAIL — `AttributeError: 'ZoomMeeting' object has no attribute 'sync_attendance'` - -- [ ] **Step 4: Implement `sync_attendance`** - -In `zoom_meeting.py`, add the import `from zoom_integration.api import get_zoom_participants` (extend the existing import line) and add the method to `ZoomMeeting`: - -```python - @frappe.whitelist() - def sync_attendance(self): - if not self.zoom_meeting_uuid: - frappe.throw(_("Meeting has not run yet (no UUID to fetch attendance for).")) - - participants = get_zoom_participants(RESOURCE, self.zoom_meeting_uuid) - - # Same user can join multiple times; sum durations per email. - summary: dict[str, dict] = {} - for participant in participants: - email = participant.get("user_email") - if not email: - continue - entry = summary.setdefault( - email, - {"name": participant.get("name"), "total_duration": 0, "registrant_id": participant.get("registrant_id")}, - ) - entry["total_duration"] += participant.get("duration", 0) - - for email, details in summary.items(): - if frappe.db.exists("Zoom Webinar Attendance Record", {"meeting": self.name, "user_email": email}): - continue - frappe.get_doc( - { - "doctype": "Zoom Webinar Attendance Record", - "meeting": self.name, - "user_email": email, - "full_name": details["name"], - "total_duration": details["total_duration"], - "registrant_id": details["registrant_id"], - "docstatus": 1, - } - ).insert(ignore_permissions=True) - - self.db_set("attendance_synced", 1) - frappe.msgprint(_("Synced attendance for {0} participants.").format(len(summary))) - - @frappe.whitelist() - def sync_attendance_in_background(self): - frappe.enqueue_doc(self.doctype, self.name, "sync_attendance", queue="long") -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration --module zoom_integration.zoom_integration.doctype.zoom_meeting.test_zoom_meeting` -Expected: PASS (3 tests) - -- [ ] **Step 6: Run the full app suite** - -Run: `bench --site buzz.localhost run-tests --app zoom_integration` -Expected: PASS - -- [ ] **Step 7: Commit** - -```bash -git add zoom_integration/zoom_integration/doctype/zoom_meeting/ zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/ -git commit -m "feat: sync Zoom Meeting attendance into attendance records" -``` - ---- - -## Self-Review Notes - -- **Coverage:** create (T2), update (T2), delete (T2), add registrant (T2/T3), attendance sync (T4), shared helpers (T1). All exercised by mocked tests. -- **Types:** `add_registrant` returns a dict with `registrant_id`/`join_url` in both `ZoomMeeting` (T2) and consumed identically in registration `before_submit` (T3). `create_zoom_session` returns the raw dict used in T2 controller. -- **No migration of existing rows:** `meeting` is additive and optional; `webinar` relaxed to optional. Existing webinar registrations/attendance untouched. -- **Deferred (ponytail):** meeting attendance sync skips the batching/retry/progress machinery the webinar path has — add it only if a meeting ever pulls >300 participants in a way that times out. UUID double-encoding noted inline in `get_zoom_participants`. From e4d59b1b4970b7aefd81c48441554b21e9f3b33c Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Fri, 24 Jul 2026 18:07:37 +0530 Subject: [PATCH 07/20] feat: enhance Zoom Meeting doctype with new fields and permissions - Added 'send_zoom_registration_email' and 'column_break_narx' fields to the Zoom Meeting JSON configuration. - Updated field definitions for better clarity and structure. - Modified permissions to include 'Zoom Manager' role. - Adjusted 'duration' field to allow None values in the Python class. --- .../doctype/zoom_meeting/zoom_meeting.json | 139 +++++++++++++++--- .../doctype/zoom_meeting/zoom_meeting.py | 2 +- 2 files changed, 121 insertions(+), 20 deletions(-) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json index 41e168b..a53a681 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json @@ -7,6 +7,8 @@ "field_order": [ "title", "agenda", + "column_break_narx", + "send_zoom_registration_email", "timings_section", "date", "column_break_time", @@ -19,37 +21,136 @@ "zoom_meeting_uuid", "column_break_details", "zoom_link", - "send_zoom_registration_email", "attendance_synced" ], "fields": [ - {"fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1, "in_list_view": 1}, - {"fieldname": "agenda", "fieldtype": "Small Text", "label": "Agenda"}, - {"fieldname": "timings_section", "fieldtype": "Section Break", "label": "Timings"}, - {"fieldname": "date", "fieldtype": "Date", "label": "Date", "reqd": 1}, - {"fieldname": "column_break_time", "fieldtype": "Column Break"}, - {"fieldname": "start_time", "fieldtype": "Time", "label": "Start Time", "reqd": 1}, - {"fieldname": "duration", "fieldtype": "Duration", "hide_days": 1, "label": "Duration"}, - {"fieldname": "column_break_tz", "fieldtype": "Column Break"}, - {"fieldname": "timezone", "fieldtype": "Autocomplete", "label": "Timezone"}, - {"fieldname": "meeting_details_section", "fieldtype": "Section Break", "label": "Meeting Details"}, - {"fieldname": "zoom_meeting_id", "fieldtype": "Data", "label": "Zoom Meeting ID", "read_only": 1}, - {"fieldname": "zoom_meeting_uuid", "fieldtype": "Data", "label": "Zoom Meeting UUID", "read_only": 1}, - {"fieldname": "column_break_details", "fieldtype": "Column Break"}, - {"fieldname": "zoom_link", "fieldtype": "Data", "label": "Join URL", "read_only": 1}, - {"fieldname": "send_zoom_registration_email", "fieldtype": "Check", "label": "Send Zoom Registration Email"}, - {"fieldname": "attendance_synced", "fieldtype": "Check", "label": "Attendance Synced", "read_only": 1} + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "agenda", + "fieldtype": "Small Text", + "label": "Agenda" + }, + { + "fieldname": "timings_section", + "fieldtype": "Section Break", + "label": "Timings" + }, + { + "fieldname": "date", + "fieldtype": "Date", + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "column_break_time", + "fieldtype": "Column Break" + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "label": "Start Time", + "reqd": 1 + }, + { + "fieldname": "duration", + "fieldtype": "Duration", + "hide_days": 1, + "label": "Duration" + }, + { + "fieldname": "column_break_tz", + "fieldtype": "Column Break" + }, + { + "fieldname": "timezone", + "fieldtype": "Autocomplete", + "label": "Timezone" + }, + { + "fieldname": "meeting_details_section", + "fieldtype": "Section Break", + "label": "Meeting Details" + }, + { + "fieldname": "zoom_meeting_id", + "fieldtype": "Data", + "label": "Zoom Meeting ID", + "read_only": 1 + }, + { + "fieldname": "zoom_meeting_uuid", + "fieldtype": "Data", + "label": "Zoom Meeting UUID", + "read_only": 1 + }, + { + "fieldname": "column_break_details", + "fieldtype": "Column Break" + }, + { + "fieldname": "zoom_link", + "fieldtype": "Data", + "label": "Zoon Join URL", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "send_zoom_registration_email", + "fieldtype": "Check", + "label": "Send Zoom Registration Email" + }, + { + "default": "0", + "fieldname": "attendance_synced", + "fieldtype": "Check", + "label": "Attendance Synced", + "read_only": 1 + }, + { + "fieldname": "column_break_narx", + "fieldtype": "Column Break" + } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-07-24 00:00:00", + "modified": "2026-07-24 18:07:28.488733", "modified_by": "Administrator", "module": "Zoom Integration", "name": "Zoom Meeting", "owner": "Administrator", "permissions": [ - {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1} + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Zoom Manager", + "share": 1, + "write": 1 + } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [] diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py index 5819dca..7207fad 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -29,7 +29,7 @@ class ZoomMeeting(Document): agenda: DF.SmallText | None attendance_synced: DF.Check date: DF.Date - duration: DF.Duration + duration: DF.Duration | None send_zoom_registration_email: DF.Check start_time: DF.Time timezone: DF.Autocomplete | None From 6800ef86d372b0231498112b0c0770831971b353 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sun, 26 Jul 2026 20:28:00 +0530 Subject: [PATCH 08/20] refactor!: rename webinar doctypes to session Zoom Webinar Registration and Zoom Webinar Attendance Record now hold rows for Zoom Meetings as well as Zoom Webinars, so their names no longer match what they store. Rename both to the "session" vocabulary already used by the resource-parameterized helpers in api.py: Zoom Webinar Registration -> Zoom Session Registration Zoom Webinar Attendance Record -> Zoom Session Attendance Record The pre_model_sync patch does the move with frappe.rename_doc, which renames the tables and repoints Link options, property setters, report ref_doctype, attachments and dynamic links. It has to run before the model sync, otherwise migrate creates the new names as empty tables first and the rename collides. Both controllers also gain a validate() guard requiring exactly one of webinar/meeting. Dropping reqd on `webinar` for the meeting support left it possible to insert a record linked to neither, and the attendance controller had no validation at all. BREAKING CHANGE: /api/resource/Zoom%20Webinar%20Registration and /api/resource/Zoom%20Webinar%20Attendance%20Record are gone; use the Zoom Session paths. Python imports of the old module paths break. Server Scripts, Client Scripts and Notification conditions containing the old doctype names are not rewritten by the patch and must be updated by hand. Co-Authored-By: Claude Opus 5 --- zoom_integration/patches.txt | 1 + .../__init__.py | 0 .../rename_webinar_doctypes_to_session.py | 18 ++++++++++++++++ .../doctype/zoom_meeting/test_zoom_meeting.py | 2 +- .../doctype/zoom_meeting/zoom_meeting.py | 4 ++-- .../__init__.py | 0 .../test_zoom_session_attendance_record.py} | 4 ++-- .../zoom_session_attendance_record.js} | 2 +- .../zoom_session_attendance_record.json} | 6 +++--- .../zoom_session_attendance_record.py} | 12 +++++++---- .../zoom_session_registration/__init__.py | 0 .../test_zoom_session_registration.py} | 4 ++-- .../zoom_session_registration.js} | 2 +- .../zoom_session_registration.json} | 4 ++-- .../zoom_session_registration.py} | 21 +++++++++++-------- .../doctype/zoom_webinar/zoom_webinar.json | 4 ++-- .../doctype/zoom_webinar/zoom_webinar.py | 12 +++++------ .../consolidated_webinar_attendance.json | 2 +- .../zoom_integration/zoom_integration.json | 2 +- 19 files changed, 63 insertions(+), 37 deletions(-) rename zoom_integration/{zoom_integration/doctype/zoom_webinar_attendance_record => patches}/__init__.py (100%) create mode 100644 zoom_integration/patches/rename_webinar_doctypes_to_session.py rename zoom_integration/zoom_integration/doctype/{zoom_webinar_registration => zoom_session_attendance_record}/__init__.py (100%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_attendance_record/test_zoom_webinar_attendance_record.py => zoom_session_attendance_record/test_zoom_session_attendance_record.py} (81%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_attendance_record/zoom_webinar_attendance_record.js => zoom_session_attendance_record/zoom_session_attendance_record.js} (71%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_attendance_record/zoom_webinar_attendance_record.json => zoom_session_attendance_record/zoom_session_attendance_record.json} (94%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_attendance_record/zoom_webinar_attendance_record.py => zoom_session_attendance_record/zoom_session_attendance_record.py} (67%) create mode 100644 zoom_integration/zoom_integration/doctype/zoom_session_registration/__init__.py rename zoom_integration/zoom_integration/doctype/{zoom_webinar_registration/test_zoom_webinar_registration.py => zoom_session_registration/test_zoom_session_registration.py} (91%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_registration/zoom_webinar_registration.js => zoom_session_registration/zoom_session_registration.js} (73%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_registration/zoom_webinar_registration.json => zoom_session_registration/zoom_session_registration.json} (97%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_registration/zoom_webinar_registration.py => zoom_session_registration/zoom_session_registration.py} (78%) diff --git a/zoom_integration/patches.txt b/zoom_integration/patches.txt index f15c3a9..8706a5d 100644 --- a/zoom_integration/patches.txt +++ b/zoom_integration/patches.txt @@ -1,6 +1,7 @@ [pre_model_sync] # Patches added in this section will be executed before doctypes are migrated # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations +zoom_integration.patches.rename_webinar_doctypes_to_session [post_model_sync] # Patches added in this section will be executed after doctypes are migrated \ No newline at end of file diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/__init__.py b/zoom_integration/patches/__init__.py similarity index 100% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/__init__.py rename to zoom_integration/patches/__init__.py diff --git a/zoom_integration/patches/rename_webinar_doctypes_to_session.py b/zoom_integration/patches/rename_webinar_doctypes_to_session.py new file mode 100644 index 0000000..405f3b9 --- /dev/null +++ b/zoom_integration/patches/rename_webinar_doctypes_to_session.py @@ -0,0 +1,18 @@ +import frappe + +DOCTYPES = { + "Zoom Webinar Registration": "Zoom Session Registration", + "Zoom Webinar Attendance Record": "Zoom Session Attendance Record", +} + + +def execute(): + """Both doctypes now hold meeting rows as well as webinar rows, so drop `Webinar` + from their names. + + Runs pre_model_sync: after the model sync the new names already exist as empty + tables built from the renamed JSON, and the rename would fail on a name clash. + """ + for old, new in DOCTYPES.items(): + if frappe.db.exists("DocType", old) and not frappe.db.exists("DocType", new): + frappe.rename_doc("DocType", old, new, force=True, show_alert=False) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py index 981e006..a18af71 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py @@ -63,7 +63,7 @@ def test_sync_attendance_creates_records_from_participants(self): meeting.sync_attendance() records = frappe.get_all( - "Zoom Webinar Attendance Record", + "Zoom Session Attendance Record", filters={"meeting": meeting.name}, fields=["user_email", "total_duration"], ) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py index 7207fad..ed2f67c 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -152,12 +152,12 @@ def sync_attendance(self): for email, details in summary.items(): if frappe.db.exists( - "Zoom Webinar Attendance Record", {"meeting": self.name, "user_email": email} + "Zoom Session Attendance Record", {"meeting": self.name, "user_email": email} ): continue frappe.get_doc( { - "doctype": "Zoom Webinar Attendance Record", + "doctype": "Zoom Session Attendance Record", "meeting": self.name, "user_email": email, "full_name": details["name"], diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/__init__.py similarity index 100% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_registration/__init__.py rename to zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/__init__.py diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/test_zoom_webinar_attendance_record.py b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py similarity index 81% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/test_zoom_webinar_attendance_record.py rename to zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py index 8bf04cf..1db98b0 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/test_zoom_webinar_attendance_record.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py @@ -11,9 +11,9 @@ IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] -class IntegrationTestZoomWebinarAttendanceRecord(IntegrationTestCase): +class IntegrationTestZoomSessionAttendanceRecord(IntegrationTestCase): """ - Integration tests for ZoomWebinarAttendanceRecord. + Integration tests for ZoomSessionAttendanceRecord. Use this class for testing interactions between multiple components. """ diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.js b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.js similarity index 71% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.js rename to zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.js index 4901cee..48a8d1e 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.js +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.js @@ -1,7 +1,7 @@ // Copyright (c) 2025, Build With Hussain and contributors // For license information, please see license.txt -// frappe.ui.form.on("Zoom Webinar Attendance Record", { +// frappe.ui.form.on("Zoom Session Attendance Record", { // refresh(frm) { // }, diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json similarity index 94% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json rename to zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json index bff5bb5..d741790 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.json +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json @@ -27,7 +27,7 @@ "fieldtype": "Link", "label": "Amended From", "no_copy": 1, - "options": "Zoom Webinar Attendance Record", + "options": "Zoom Session Attendance Record", "print_hide": 1, "read_only": 1, "search_index": 1 @@ -51,7 +51,7 @@ "fieldname": "registration", "fieldtype": "Link", "label": "Registration", - "options": "Zoom Webinar Registration", + "options": "Zoom Session Registration", "search_index": 1 }, { @@ -91,7 +91,7 @@ "modified": "2026-05-14 21:33:32.754713", "modified_by": "Administrator", "module": "Zoom Integration", - "name": "Zoom Webinar Attendance Record", + "name": "Zoom Session Attendance Record", "naming_rule": "Random", "owner": "Administrator", "permissions": [ diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.py b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py similarity index 67% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.py rename to zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py index 597ecea..53c6925 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/zoom_webinar_attendance_record.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py @@ -1,11 +1,12 @@ # Copyright (c) 2025, Build With Hussain and contributors # For license information, please see license.txt -# import frappe +import frappe +from frappe import _ from frappe.model.document import Document -class ZoomWebinarAttendanceRecord(Document): +class ZoomSessionAttendanceRecord(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -16,11 +17,14 @@ class ZoomWebinarAttendanceRecord(Document): amended_from: DF.Link | None full_name: DF.Data | None + meeting: DF.Link | None registrant_id: DF.Data | None registration: DF.Link | None total_duration: DF.Duration user_email: DF.Data - webinar: DF.Link + webinar: DF.Link | None # end: auto-generated types - pass + def validate(self): + if bool(self.webinar) == bool(self.meeting): + frappe.throw(_("Set exactly one of Webinar or Meeting.")) diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py similarity index 91% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py rename to zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py index 292d702..5ec8ddf 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py @@ -11,7 +11,7 @@ MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" -class IntegrationTestZoomWebinarRegistration(IntegrationTestCase): +class IntegrationTestZoomSessionRegistration(IntegrationTestCase): def test_registration_for_meeting_calls_meeting_add_registrant(self): with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): meeting = frappe.get_doc( @@ -28,7 +28,7 @@ def test_registration_for_meeting_calls_meeting_add_registrant(self): with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE): registration = frappe.get_doc( { - "doctype": "Zoom Webinar Registration", + "doctype": "Zoom Session Registration", "meeting": meeting.name, "email": "alice@example.com", "first_name": "Alice", diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.js b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.js similarity index 73% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.js rename to zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.js index af3f36a..0d59146 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.js +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.js @@ -1,7 +1,7 @@ // Copyright (c) 2025, Build With Hussain and contributors // For license information, please see license.txt -// frappe.ui.form.on("Zoom Webinar Registration", { +// frappe.ui.form.on("Zoom Session Registration", { // refresh(frm) { // }, diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json similarity index 97% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json rename to zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json index 235651a..7ef840d 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.json +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json @@ -35,7 +35,7 @@ "fieldtype": "Link", "label": "Amended From", "no_copy": 1, - "options": "Zoom Webinar Registration", + "options": "Zoom Session Registration", "print_hide": 1, "read_only": 1, "search_index": 1 @@ -153,7 +153,7 @@ "modified": "2025-12-09 13:13:19.795660", "modified_by": "Administrator", "module": "Zoom Integration", - "name": "Zoom Webinar Registration", + "name": "Zoom Session Registration", "naming_rule": "Random", "owner": "Administrator", "permissions": [ diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py similarity index 78% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py rename to zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py index ebe592e..deb9a2a 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/zoom_webinar_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py @@ -2,10 +2,11 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.model.document import Document -class ZoomWebinarRegistration(Document): +class ZoomSessionRegistration(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -28,9 +29,13 @@ class ZoomWebinarRegistration(Document): registrant_id: DF.Data | None synced_from_zoom: DF.Check user: DF.Link | None - webinar: DF.Link + webinar: DF.Link | None # end: auto-generated types + def validate(self): + if bool(self.webinar) == bool(self.meeting): + frappe.throw(_("Set exactly one of Webinar or Meeting.")) + def before_insert(self): if not (self.user or self.email): self.user = frappe.session.user @@ -40,7 +45,7 @@ def before_insert(self): self.user = user_exists if self.user == "Guest": - frappe.throw("Guest user cannot register for webinar") + frappe.throw(_("Guest user cannot register for a Zoom session")) elif self.user and not self.email: user_doc = frappe.get_cached_doc("User", self.user) self.email = user_doc.email @@ -56,12 +61,10 @@ def before_submit(self): if self.additional_params: additional_params = {param.key: param.value for param in self.additional_params} - if self.meeting: - session = frappe.get_cached_doc("Zoom Meeting", self.meeting) - elif self.webinar: - session = frappe.get_cached_doc("Zoom Webinar", self.webinar) - else: - frappe.throw(frappe._("Registration must reference a Zoom Meeting or Zoom Webinar.")) + # validate() guarantees exactly one of meeting/webinar is set + session = frappe.get_cached_doc( + "Zoom Meeting" if self.meeting else "Zoom Webinar", self.meeting or self.webinar + ) registration = session.add_registrant(self.email, self.first_name, self.last_name, additional_params) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json index 3a4f702..4481187 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json @@ -161,11 +161,11 @@ "index_web_pages_for_search": 1, "links": [ { - "link_doctype": "Zoom Webinar Registration", + "link_doctype": "Zoom Session Registration", "link_fieldname": "webinar" }, { - "link_doctype": "Zoom Webinar Attendance Record", + "link_doctype": "Zoom Session Attendance Record", "link_fieldname": "webinar" } ], diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py index 893f657..ed0e52d 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py @@ -165,7 +165,7 @@ def on_trash(self): if self.zoom_webinar_id: self.delete_webinar_on_zoom() - # frappe.db.delete("Zoom Webinar Registration", {"webinar": self.name}) + # frappe.db.delete("Zoom Session Registration", {"webinar": self.name}) def delete_webinar_on_zoom(self): url = f"{ZOOM_API_BASE_PATH}/webinars/{self.zoom_webinar_id}" @@ -281,13 +281,13 @@ def sync_attendance(self): continue if frappe.db.exists( - "Zoom Webinar Attendance Record", + "Zoom Session Attendance Record", {"webinar": self.name, "user_email": user_email}, ): continue registration = frappe.db.get_value( - "Zoom Webinar Registration", + "Zoom Session Registration", {"email": user_email}, "name", ) @@ -295,7 +295,7 @@ def sync_attendance(self): try: doc = frappe.get_doc( { - "doctype": "Zoom Webinar Attendance Record", + "doctype": "Zoom Session Attendance Record", "registration": registration, "webinar": self.name, "user_email": user_email, @@ -375,7 +375,7 @@ def sync_registrations_from_zoom(self): for registrant in batch: try: if frappe.db.exists( - "Zoom Webinar Registration", + "Zoom Session Registration", { "registrant_id": registrant.get("id"), "webinar": self.name, @@ -384,7 +384,7 @@ def sync_registrations_from_zoom(self): continue doc = frappe.get_doc( { - "doctype": "Zoom Webinar Registration", + "doctype": "Zoom Session Registration", "registrant_id": registrant.get("id"), "join_url": registrant.get("join_url"), "webinar": self.name, diff --git a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json b/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json index d23c33d..8596c2e 100644 --- a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json +++ b/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json @@ -16,7 +16,7 @@ "name": "Consolidated Webinar Attendance", "owner": "Administrator", "prepared_report": 0, - "ref_doctype": "Zoom Webinar Attendance Record", + "ref_doctype": "Zoom Session Attendance Record", "report_name": "Consolidated Webinar Attendance", "report_type": "Script Report", "roles": [ diff --git a/zoom_integration/zoom_integration/workspace/zoom_integration/zoom_integration.json b/zoom_integration/zoom_integration/workspace/zoom_integration/zoom_integration.json index 33427a9..0df9dcf 100644 --- a/zoom_integration/zoom_integration/workspace/zoom_integration/zoom_integration.json +++ b/zoom_integration/zoom_integration/workspace/zoom_integration/zoom_integration.json @@ -46,7 +46,7 @@ "color": "Orange", "doc_view": "List", "label": "View Attendance Data", - "link_to": "Zoom Webinar Attendance Record", + "link_to": "Zoom Session Attendance Record", "stats_filter": "[]", "type": "DocType" }, From 75ff21c82a7328c60b73339ea17874ce04b1d31a Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 15:59:10 +0530 Subject: [PATCH 09/20] fix: name Zoom Meeting by its Zoom meeting ID Zoom Meeting fell back to hash naming, so the desk URL and every link carried an opaque ID with no relation to the meeting on Zoom. Name it after zoom_meeting_id instead: before_insert stores the ID from the create call, and Frappe runs before_insert ahead of set_new_name, so the value is there when the name is assigned. The names are only unique because the Zoom IDs are, which broke the tests: IntegrationTestCase rolls back per class, not per test, so repeated inserts of the same mocked create response collided on the primary key. Tests now take their meeting ID from create_meeting_response and pass a distinct one each. Also cover additional_params reaching Zoom, which had no test, before the child doctype behind it gets renamed. Co-Authored-By: Claude Opus 5 --- zoom_integration/tests/zoom_fixtures.py | 6 ++ .../doctype/zoom_meeting/test_zoom_meeting.py | 68 +++++++++++-------- .../doctype/zoom_meeting/zoom_meeting.json | 2 + .../test_zoom_session_registration.py | 37 ++++++++-- 4 files changed, 79 insertions(+), 34 deletions(-) diff --git a/zoom_integration/tests/zoom_fixtures.py b/zoom_integration/tests/zoom_fixtures.py index de256e2..ea8be4e 100644 --- a/zoom_integration/tests/zoom_fixtures.py +++ b/zoom_integration/tests/zoom_fixtures.py @@ -18,6 +18,12 @@ "settings": {"approval_type": 0, "registration_type": 1, "audio": "both"}, } + +def create_meeting_response(meeting_id): + """Zoom Meeting is named after its Zoom ID, so tests sharing a class need distinct IDs.""" + return {**CREATE_MEETING_RESPONSE, "id": int(meeting_id)} + + # PATCH /meetings/{id} and DELETE /meetings/{id} return 204 with an empty body. ADD_MEETING_REGISTRANT_RESPONSE = { diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py index a18af71..9f9540c 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py @@ -6,57 +6,65 @@ from zoom_integration.tests.zoom_fixtures import ( ADD_MEETING_REGISTRANT_RESPONSE, CREATE_MEETING_RESPONSE, + create_meeting_response, ) CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" class TestZoomMeeting(IntegrationTestCase): - def _new_meeting(self): - return frappe.get_doc( - { - "doctype": "Zoom Meeting", - "title": "Test Meeting", - "date": "2026-08-01", - "start_time": "10:00:00", - "duration": 3600, - "timezone": "Asia/Calcutta", - } - ) + def _insert_meeting(self, meeting_id): + with patch( + f"{CONTROLLER}.create_zoom_session", return_value=create_meeting_response(meeting_id) + ) as mock_create: + meeting = frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Test Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + return meeting, mock_create def test_insert_creates_meeting_on_zoom_and_stores_ids(self): - with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE) as mock_create: - meeting = self._new_meeting().insert() + meeting, mock_create = self._insert_meeting("91234567890") + + resource, body = mock_create.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(body["type"], 2) + self.assertEqual(body["settings"]["approval_type"], 0) + self.assertEqual(meeting.zoom_meeting_id, "91234567890") + self.assertEqual(meeting.zoom_meeting_uuid, "aDEFghiJKLmno12PQ==") + self.assertEqual(meeting.zoom_link, CREATE_MEETING_RESPONSE["join_url"]) + + def test_meeting_is_named_by_its_zoom_meeting_id(self): + meeting, _ = self._insert_meeting("91234567891") - resource, body = mock_create.call_args.args - self.assertEqual(resource, "meetings") - self.assertEqual(body["type"], 2) - self.assertEqual(body["settings"]["approval_type"], 0) - self.assertEqual(meeting.zoom_meeting_id, "91234567890") - self.assertEqual(meeting.zoom_meeting_uuid, "aDEFghiJKLmno12PQ==") - self.assertEqual(meeting.zoom_link, CREATE_MEETING_RESPONSE["join_url"]) + self.assertEqual(meeting.name, "91234567891") def test_add_registrant_returns_registrant_id_and_join_url(self): - with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - meeting = self._new_meeting().insert() + meeting, _ = self._insert_meeting("91234567892") with patch( f"{CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE ) as mock_add: result = meeting.add_registrant("alice@example.com", "Alice", "Smith") - resource, session_id, body = mock_add.call_args.args - self.assertEqual(resource, "meetings") - self.assertEqual(session_id, "91234567890") - self.assertEqual(body["email"], "alice@example.com") - self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") - self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + resource, session_id, body = mock_add.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(session_id, "91234567892") + self.assertEqual(body["email"], "alice@example.com") + self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") + self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) def test_sync_attendance_creates_records_from_participants(self): from zoom_integration.tests.zoom_fixtures import MEETING_PARTICIPANTS_RESPONSE - with patch(f"{CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - meeting = self._new_meeting().insert() + meeting, _ = self._insert_meeting("91234567893") participants = MEETING_PARTICIPANTS_RESPONSE["participants"] with patch(f"{CONTROLLER}.get_zoom_participants", return_value=participants): diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json index a53a681..c70c49e 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json @@ -1,6 +1,7 @@ { "actions": [], "allow_rename": 1, + "autoname": "field:zoom_meeting_id", "creation": "2026-07-24 00:00:00", "doctype": "DocType", "engine": "InnoDB", @@ -123,6 +124,7 @@ "modified_by": "Administrator", "module": "Zoom Integration", "name": "Zoom Meeting", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py index 5ec8ddf..b3e69fc 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py @@ -6,15 +6,20 @@ import frappe from frappe.tests import IntegrationTestCase -from zoom_integration.tests.zoom_fixtures import ADD_MEETING_REGISTRANT_RESPONSE, CREATE_MEETING_RESPONSE +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" class IntegrationTestZoomSessionRegistration(IntegrationTestCase): - def test_registration_for_meeting_calls_meeting_add_registrant(self): - with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=CREATE_MEETING_RESPONSE): - meeting = frappe.get_doc( + def _insert_meeting(self, meeting_id): + with patch( + f"{MEETING_CONTROLLER}.create_zoom_session", return_value=create_meeting_response(meeting_id) + ): + return frappe.get_doc( { "doctype": "Zoom Meeting", "title": "Reg Meeting", @@ -25,6 +30,9 @@ def test_registration_for_meeting_calls_meeting_add_registrant(self): } ).insert() + def test_registration_for_meeting_calls_meeting_add_registrant(self): + meeting = self._insert_meeting("92234567890") + with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE): registration = frappe.get_doc( { @@ -39,3 +47,24 @@ def test_registration_for_meeting_calls_meeting_add_registrant(self): self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") self.assertEqual(registration.join_url, ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + + def test_additional_params_are_forwarded_to_zoom(self): + meeting = self._insert_meeting("92234567891") + response = {**ADD_MEETING_REGISTRANT_RESPONSE, "registrant_id": "uvwXYZ56abCD"} + + registration = frappe.get_doc( + { + "doctype": "Zoom Session Registration", + "meeting": meeting.name, + "email": "bob@example.com", + "first_name": "Bob", + "last_name": "Jones", + "additional_params": [{"key": "org", "value": "Frappe"}], + } + ).insert() + + with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=response) as mock_add: + registration.submit() + + body = mock_add.call_args.args[2] + self.assertEqual(body["org"], "Frappe") From 4c8c8063db3cacc3f5e78d9653c505c9ab03c5b9 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 15:59:21 +0530 Subject: [PATCH 10/20] refactor!: complete the webinar-to-session rename Zoom Webinar Additional Param was the last doctype left on the webinar vocabulary. It is the additional_params child table of Zoom Session Registration, which now holds meeting rows too, so the name no longer matches what it stores. Add it to the existing pre_model_sync rename patch. frappe.rename_doc handles the istable case: update_options_for_fieldtype repoints the parent's Table field, and the child rows' parent/parenttype still name Zoom Session Registration, which did not change. Also drop the Consolidated Webinar Attendance report. It was scaffolding that never got written -- get_columns returns "Column 1"/"Column 2" and get_data returns two literal rows -- with no workspace link and no caller. Its ref_doctype was updated by the earlier rename while its own name was not, so deleting it removes the inconsistency rather than renaming dead code. Migrate's orphan-report sweep removes the stale Report doc, so the patch needs no extra step. BREAKING CHANGE: /api/resource/Zoom%20Webinar%20Additional%20Param is gone; use the Zoom Session path. Python imports of the old module path break. The Consolidated Webinar Attendance report is removed. Co-Authored-By: Claude Opus 5 --- .../rename_webinar_doctypes_to_session.py | 3 +- zoom_integration/tests/test_session_rename.py | 21 ++++++++ .../__init__.py | 0 .../zoom_session_additional_param.json} | 2 +- .../zoom_session_additional_param.py} | 2 +- .../zoom_session_registration.json | 2 +- .../zoom_session_registration.py | 6 +-- .../__init__.py | 0 .../consolidated_webinar_attendance.js | 13 ----- .../consolidated_webinar_attendance.json | 31 ------------ .../consolidated_webinar_attendance.py | 48 ------------------- 11 files changed, 29 insertions(+), 99 deletions(-) create mode 100644 zoom_integration/tests/test_session_rename.py rename zoom_integration/zoom_integration/doctype/{zoom_webinar_additional_param => zoom_session_additional_param}/__init__.py (100%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_additional_param/zoom_webinar_additional_param.json => zoom_session_additional_param/zoom_session_additional_param.json} (95%) rename zoom_integration/zoom_integration/doctype/{zoom_webinar_additional_param/zoom_webinar_additional_param.py => zoom_session_additional_param/zoom_session_additional_param.py} (91%) delete mode 100644 zoom_integration/zoom_integration/report/consolidated_webinar_attendance/__init__.py delete mode 100644 zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.js delete mode 100644 zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json delete mode 100644 zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.py diff --git a/zoom_integration/patches/rename_webinar_doctypes_to_session.py b/zoom_integration/patches/rename_webinar_doctypes_to_session.py index 405f3b9..0019f82 100644 --- a/zoom_integration/patches/rename_webinar_doctypes_to_session.py +++ b/zoom_integration/patches/rename_webinar_doctypes_to_session.py @@ -3,11 +3,12 @@ DOCTYPES = { "Zoom Webinar Registration": "Zoom Session Registration", "Zoom Webinar Attendance Record": "Zoom Session Attendance Record", + "Zoom Webinar Additional Param": "Zoom Session Additional Param", } def execute(): - """Both doctypes now hold meeting rows as well as webinar rows, so drop `Webinar` + """These doctypes now hold meeting rows as well as webinar rows, so drop `Webinar` from their names. Runs pre_model_sync: after the model sync the new names already exist as empty diff --git a/zoom_integration/tests/test_session_rename.py b/zoom_integration/tests/test_session_rename.py new file mode 100644 index 0000000..f01dde3 --- /dev/null +++ b/zoom_integration/tests/test_session_rename.py @@ -0,0 +1,21 @@ +import frappe +from frappe.tests import IntegrationTestCase + +OLD_DOCTYPES = ( + "Zoom Webinar Registration", + "Zoom Webinar Attendance Record", + "Zoom Webinar Additional Param", +) + + +class TestSessionRename(IntegrationTestCase): + def test_additional_params_point_at_session_child_doctype(self): + field = frappe.get_meta("Zoom Session Registration").get_field("additional_params") + self.assertEqual(field.options, "Zoom Session Additional Param") + + def test_old_webinar_doctypes_are_gone(self): + for doctype in OLD_DOCTYPES: + self.assertFalse(frappe.db.exists("DocType", doctype), doctype) + + def test_stub_report_is_removed(self): + self.assertFalse(frappe.db.exists("Report", "Consolidated Webinar Attendance")) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_session_additional_param/__init__.py similarity index 100% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/__init__.py rename to zoom_integration/zoom_integration/doctype/zoom_session_additional_param/__init__.py diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/zoom_webinar_additional_param.json b/zoom_integration/zoom_integration/doctype/zoom_session_additional_param/zoom_session_additional_param.json similarity index 95% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/zoom_webinar_additional_param.json rename to zoom_integration/zoom_integration/doctype/zoom_session_additional_param/zoom_session_additional_param.json index 34c151b..5e0f4dd 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/zoom_webinar_additional_param.json +++ b/zoom_integration/zoom_integration/doctype/zoom_session_additional_param/zoom_session_additional_param.json @@ -37,7 +37,7 @@ "modified": "2025-10-27 17:09:35.168116", "modified_by": "Administrator", "module": "Zoom Integration", - "name": "Zoom Webinar Additional Param", + "name": "Zoom Session Additional Param", "owner": "Administrator", "permissions": [], "row_format": "Dynamic", diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/zoom_webinar_additional_param.py b/zoom_integration/zoom_integration/doctype/zoom_session_additional_param/zoom_session_additional_param.py similarity index 91% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/zoom_webinar_additional_param.py rename to zoom_integration/zoom_integration/doctype/zoom_session_additional_param/zoom_session_additional_param.py index fa5a3c3..c9be683 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/zoom_webinar_additional_param.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_additional_param/zoom_session_additional_param.py @@ -5,7 +5,7 @@ from frappe.model.document import Document -class ZoomWebinarAdditionalParam(Document): +class ZoomSessionAdditionalParam(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json index 7ef840d..5f792c7 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json @@ -103,7 +103,7 @@ "fieldname": "additional_params", "fieldtype": "Table", "label": "Additional Params", - "options": "Zoom Webinar Additional Param" + "options": "Zoom Session Additional Param" }, { "fetch_from": "user.email", diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py index deb9a2a..225f16c 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py @@ -15,11 +15,11 @@ class ZoomSessionRegistration(Document): if TYPE_CHECKING: from frappe.types import DF - from zoom_integration.zoom_integration.doctype.zoom_webinar_additional_param.zoom_webinar_additional_param import ( - ZoomWebinarAdditionalParam, + from zoom_integration.zoom_integration.doctype.zoom_session_additional_param.zoom_session_additional_param import ( + ZoomSessionAdditionalParam, ) - additional_params: DF.Table[ZoomWebinarAdditionalParam] + additional_params: DF.Table[ZoomSessionAdditionalParam] amended_from: DF.Link | None email: DF.Data | None first_name: DF.Data | None diff --git a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/__init__.py b/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.js b/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.js deleted file mode 100644 index 8aa3bc9..0000000 --- a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.js +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2025, Build With Hussain and contributors -// For license information, please see license.txt - -frappe.query_reports["Consolidated Webinar Attendance"] = { - filters: [ - // { - // "fieldname": "my_filter", - // "label": __("My Filter"), - // "fieldtype": "Data", - // "reqd": 1, - // }, - ], -}; diff --git a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json b/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json deleted file mode 100644 index 8596c2e..0000000 --- a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "add_total_row": 0, - "add_translate_data": 0, - "columns": [], - "creation": "2025-11-10 18:36:10.887906", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "filters": [], - "idx": 0, - "is_standard": "Yes", - "letterhead": null, - "modified": "2025-11-10 18:36:18.175857", - "modified_by": "Administrator", - "module": "Zoom Integration", - "name": "Consolidated Webinar Attendance", - "owner": "Administrator", - "prepared_report": 0, - "ref_doctype": "Zoom Session Attendance Record", - "report_name": "Consolidated Webinar Attendance", - "report_type": "Script Report", - "roles": [ - { - "role": "Zoom Manager" - }, - { - "role": "System Manager" - } - ], - "timeout": 0 -} diff --git a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.py b/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.py deleted file mode 100644 index 90a197a..0000000 --- a/zoom_integration/zoom_integration/report/consolidated_webinar_attendance/consolidated_webinar_attendance.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2025, Build With Hussain and contributors -# For license information, please see license.txt - -# import frappe -from frappe import _ - - -def execute(filters: dict | None = None): - """Return columns and data for the report. - - This is the main entry point for the report. It accepts the filters as a - dictionary and should return columns and data. It is called by the framework - every time the report is refreshed or a filter is updated. - """ - columns = get_columns() - data = get_data() - - return columns, data - - -def get_columns() -> list[dict]: - """Return columns for the report. - - One field definition per column, just like a DocType field definition. - """ - return [ - { - "label": _("Column 1"), - "fieldname": "column_1", - "fieldtype": "Data", - }, - { - "label": _("Column 2"), - "fieldname": "column_2", - "fieldtype": "Int", - }, - ] - - -def get_data() -> list[list]: - """Return data for the report. - - The report data is a list of rows, with each row being a list of cell values. - """ - return [ - ["Row 1", 1], - ["Row 2", 2], - ] From 883d7ce6dc4f006fe8d8b2e6c930ef917ce411fe Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 15:59:21 +0530 Subject: [PATCH 11/20] docs: describe meetings alongside webinars Co-Authored-By: Claude Opus 5 --- README.md | 2 +- zoom_integration/hooks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3864a3f..09e1334 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ### Zoom Integration -Zoom Integration for Frappe. Create and manage webinars. +Zoom Integration for Frappe. Create and manage meetings and webinars. ### Setup diff --git a/zoom_integration/hooks.py b/zoom_integration/hooks.py index 1eb8f97..9add8b0 100644 --- a/zoom_integration/hooks.py +++ b/zoom_integration/hooks.py @@ -1,7 +1,7 @@ app_name = "zoom_integration" app_title = "Zoom Integration" app_publisher = "Build With Hussain" -app_description = "Zoom Integration for Frappe. Create and manage webinars." +app_description = "Zoom Integration for Frappe. Create and manage meetings and webinars." app_email = "developer@buildwithhussain.com" app_license = "agpl-3.0" From 25c2ee428d72c02a18b873214e5e420b0f5b8e7b Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 16:31:59 +0530 Subject: [PATCH 12/20] fix: rename Zoom Meetings created before ID-based naming Naming Zoom Meeting by zoom_meeting_id only applied to new docs, so meetings created earlier kept their hash name while zoom_meeting_id held the real Zoom ID. Anything building a Zoom URL from the docname was wrong for those. rename_doc repoints Buzz Event.zoom_meeting and the dynamic reference_name on the session registration and attendance records. Zoom Meeting also gains the Connections it never had, matching Zoom Webinar. Co-Authored-By: Claude Opus 5 --- .../patches/rename_meetings_to_zoom_id.py | 23 +++++++++++++++++++ .../tests/test_rename_meetings_to_zoom_id.py | 20 ++++++++++++++++ .../doctype/zoom_meeting/zoom_meeting.json | 11 ++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 zoom_integration/patches/rename_meetings_to_zoom_id.py create mode 100644 zoom_integration/tests/test_rename_meetings_to_zoom_id.py diff --git a/zoom_integration/patches/rename_meetings_to_zoom_id.py b/zoom_integration/patches/rename_meetings_to_zoom_id.py new file mode 100644 index 0000000..87c7809 --- /dev/null +++ b/zoom_integration/patches/rename_meetings_to_zoom_id.py @@ -0,0 +1,23 @@ +import frappe + + +def execute(): + """Rename Zoom Meetings created before the doctype was named by zoom_meeting_id. + + rename_doc repoints Link fields (Buzz Event.zoom_meeting) and Dynamic Links + (reference_name on the session registration and attendance records). + """ + meetings = frappe.get_all( + "Zoom Meeting", fields=["name", "zoom_meeting_id"], filters={"zoom_meeting_id": ["is", "set"]} + ) + + for meeting in meetings: + if meeting.name == meeting.zoom_meeting_id: + continue + if frappe.db.exists("Zoom Meeting", meeting.zoom_meeting_id): + frappe.log_error( + f"Cannot rename Zoom Meeting {meeting.name}: {meeting.zoom_meeting_id} already exists" + ) + continue + + frappe.rename_doc("Zoom Meeting", meeting.name, meeting.zoom_meeting_id, force=True, show_alert=False) diff --git a/zoom_integration/tests/test_rename_meetings_to_zoom_id.py b/zoom_integration/tests/test_rename_meetings_to_zoom_id.py new file mode 100644 index 0000000..58184b5 --- /dev/null +++ b/zoom_integration/tests/test_rename_meetings_to_zoom_id.py @@ -0,0 +1,20 @@ +import frappe +from frappe.tests import IntegrationTestCase + + +class TestRenameMeetingsToZoomID(IntegrationTestCase): + def test_every_meeting_is_named_after_its_zoom_meeting_id(self): + stale = frappe.get_all( + "Zoom Meeting", + fields=["name", "zoom_meeting_id"], + filters={"zoom_meeting_id": ["is", "set"]}, + ) + mismatched = [row.name for row in stale if row.name != row.zoom_meeting_id] + + self.assertEqual(mismatched, []) + + def test_events_point_at_meetings_that_exist(self): + linked = frappe.get_all("Buzz Event", filters={"zoom_meeting": ["is", "set"]}, pluck="zoom_meeting") + missing = [name for name in linked if not frappe.db.exists("Zoom Meeting", name)] + + self.assertEqual(missing, []) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json index c70c49e..fbef8d7 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json @@ -119,7 +119,16 @@ } ], "index_web_pages_for_search": 1, - "links": [], + "links": [ + { + "link_doctype": "Zoom Session Registration", + "link_fieldname": "reference_name" + }, + { + "link_doctype": "Zoom Session Attendance Record", + "link_fieldname": "reference_name" + } + ], "modified": "2026-07-24 18:07:28.488733", "modified_by": "Administrator", "module": "Zoom Integration", From 17fe1569e6f279dae7c5871b8c19c485c6542098 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 16:32:13 +0530 Subject: [PATCH 13/20] refactor!: replace webinar/meeting field pairs with a dynamic reference Zoom Session Registration and Zoom Session Attendance Record each carried a `webinar` Link and a `meeting` Link, exactly one of which was meant to be set. Nothing in the schema enforced that, every read had to branch on which one was populated, and adding a third session type would have meant a third column. Both now carry reference_doctype + reference_name instead. Selecting the registration's session collapses from frappe.get_cached_doc( "Zoom Meeting" if self.meeting else "Zoom Webinar", self.meeting or self.webinar ) to a plain get_cached_doc(self.reference_doctype, self.reference_name), and `reqd` on both fields replaces the hand-written "exactly one of" guard. reference_doctype carries link_filters so the desk only offers the two session doctypes, but those apply client-side, so validate_session_reference keeps the check on the server where it counts. The backfill fills empty references only. The legacy columns are left for `bench trim-tables` and are not kept in step afterwards, so overwriting a populated reference would undo a later rename. Connections on Zoom Webinar now match on reference_name. DocType Link has no dynamic filter field, so the panel no longer restricts by session type; both doctypes are named from the same Zoom ID space, so a collision needs two sessions sharing an ID. Test IDs are drawn fresh per call rather than fixed: sessions are named by Zoom ID and registrant_id is unique, and rows outlive a run, so fixed IDs collided with their own leftovers on a rerun. BREAKING CHANGE: the `webinar` and `meeting` fields are gone from Zoom Session Registration and Zoom Session Attendance Record; read reference_doctype and reference_name. API clients filtering on the old fieldnames must be updated. Co-Authored-By: Claude Opus 5 --- zoom_integration/patches.txt | 4 +- .../patches/backfill_session_reference.py | 27 +++++ .../tests/test_backfill_session_reference.py | 35 ++++++ zoom_integration/tests/zoom_fixtures.py | 85 +++++++++++++- zoom_integration/utils.py | 11 ++ .../doctype/zoom_meeting/test_zoom_meeting.py | 63 ++++++---- .../doctype/zoom_meeting/zoom_meeting.py | 6 +- .../test_zoom_session_attendance_record.py | 66 +++++++++-- .../zoom_session_attendance_record.json | 24 ++-- .../zoom_session_attendance_record.py | 11 +- .../test_zoom_session_registration.py | 111 +++++++++++++----- .../zoom_session_registration.json | 21 ++-- .../zoom_session_registration.py | 14 +-- .../doctype/zoom_webinar/test_zoom_webinar.py | 65 ++++++++-- .../doctype/zoom_webinar/zoom_webinar.json | 4 +- .../doctype/zoom_webinar/zoom_webinar.py | 20 +++- 16 files changed, 445 insertions(+), 122 deletions(-) create mode 100644 zoom_integration/patches/backfill_session_reference.py create mode 100644 zoom_integration/tests/test_backfill_session_reference.py diff --git a/zoom_integration/patches.txt b/zoom_integration/patches.txt index 8706a5d..f48699e 100644 --- a/zoom_integration/patches.txt +++ b/zoom_integration/patches.txt @@ -4,4 +4,6 @@ zoom_integration.patches.rename_webinar_doctypes_to_session [post_model_sync] -# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file +# Patches added in this section will be executed after doctypes are migrated +zoom_integration.patches.backfill_session_reference +zoom_integration.patches.rename_meetings_to_zoom_id \ No newline at end of file diff --git a/zoom_integration/patches/backfill_session_reference.py b/zoom_integration/patches/backfill_session_reference.py new file mode 100644 index 0000000..f31f826 --- /dev/null +++ b/zoom_integration/patches/backfill_session_reference.py @@ -0,0 +1,27 @@ +import frappe + +DOCTYPES = ("Zoom Session Registration", "Zoom Session Attendance Record") +LEGACY_FIELDS = {"webinar": "Zoom Webinar", "meeting": "Zoom Meeting"} + + +def execute(): + """Fold the separate `webinar` and `meeting` links into one dynamic reference. + + post_model_sync, so the new columns exist. Old ones stay for `bench trim-tables`, + which is why this only fills empty references: the legacy columns are not kept in + step afterwards, so overwriting a set reference would undo any later rename. + """ + for doctype in DOCTYPES: + for legacy_field, reference_doctype in LEGACY_FIELDS.items(): + if not frappe.db.has_column(doctype, legacy_field): + continue + + table = frappe.qb.DocType(doctype) + legacy_column = getattr(table, legacy_field) + frappe.qb.update(table).set(table.reference_doctype, reference_doctype).set( + table.reference_name, legacy_column + ).where( + legacy_column.notnull() + & (legacy_column != "") + & (table.reference_name.isnull() | (table.reference_name == "")) + ).run() diff --git a/zoom_integration/tests/test_backfill_session_reference.py b/zoom_integration/tests/test_backfill_session_reference.py new file mode 100644 index 0000000..2206439 --- /dev/null +++ b/zoom_integration/tests/test_backfill_session_reference.py @@ -0,0 +1,35 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.patches.backfill_session_reference import DOCTYPES, LEGACY_FIELDS, execute + + +class TestBackfillSessionReference(IntegrationTestCase): + def test_legacy_columns_map_to_their_reference_doctype(self): + self.assertEqual(LEGACY_FIELDS, {"webinar": "Zoom Webinar", "meeting": "Zoom Meeting"}) + + def test_every_row_carries_a_supported_reference_doctype(self): + for doctype in DOCTYPES: + stale = frappe.get_all( + doctype, + filters={"reference_doctype": ["not in", list(LEGACY_FIELDS.values())]}, + pluck="name", + limit=5, + ) + self.assertEqual(stale, [], f"{doctype} rows not on a Zoom session: {stale}") + + def test_rerunning_the_backfill_leaves_references_untouched(self): + before = { + doctype: frappe.get_all( + doctype, fields=["name", "reference_doctype", "reference_name"], order_by="name" + ) + for doctype in DOCTYPES + } + + execute() + + for doctype in DOCTYPES: + after = frappe.get_all( + doctype, fields=["name", "reference_doctype", "reference_name"], order_by="name" + ) + self.assertEqual(after, before[doctype], doctype) diff --git a/zoom_integration/tests/zoom_fixtures.py b/zoom_integration/tests/zoom_fixtures.py index ea8be4e..f0be5c7 100644 --- a/zoom_integration/tests/zoom_fixtures.py +++ b/zoom_integration/tests/zoom_fixtures.py @@ -1,6 +1,9 @@ """Realistic Zoom API response fixtures. Shapes mirror the live Zoom v2 API so mocks behave like production. Reused by all zoom_integration tests.""" +import random +import string + CREATE_MEETING_RESPONSE = { "uuid": "aDEFghiJKLmno12PQ==", "id": 91234567890, @@ -19,9 +22,20 @@ } -def create_meeting_response(meeting_id): - """Zoom Meeting is named after its Zoom ID, so tests sharing a class need distinct IDs.""" - return {**CREATE_MEETING_RESPONSE, "id": int(meeting_id)} +CREATE_WEBINAR_RESPONSE = { + "uuid": "bXYZabcDEFghi34RS==", + "id": 81234567890, + "host_id": "hostAbc123", + "topic": "Test Webinar", + "type": 5, + "start_time": "2026-08-01T10:00:00Z", + "duration": 60, + "timezone": "Asia/Calcutta", + "created_at": "2026-07-24T09:00:00Z", + "join_url": "https://zoom.us/w/81234567890?pwd=wEbToken", + "start_url": "https://zoom.us/s/81234567890?zak=zakToken", + "settings": {"approval_type": 0, "registration_type": 1, "audio": "both"}, +} # PATCH /meetings/{id} and DELETE /meetings/{id} return 204 with an empty body. @@ -34,6 +48,40 @@ def create_meeting_response(meeting_id): "join_url": "https://zoom.us/w/91234567890?tk=regToken&pwd=xYz", } +ADD_WEBINAR_REGISTRANT_RESPONSE = { + "id": 81234567890, + "topic": "Test Webinar", + "registrant_id": "webREG123456", + "start_time": "2026-08-01T10:00:00Z", + "join_url": "https://zoom.us/w/81234567890?tk=webRegToken", +} + +WEBINAR_PARTICIPANTS = [ + { + "name": "Carol", + "user_email": "carol@example.com", + "total_duration": 3100, + "registrant_id": "webREG123456", + }, + { + "name": "Dan", + "user_email": "dan@example.com", + "total_duration": 2400, + "registrant_id": "webREG789012", + }, +] + +WEBINAR_REGISTRANTS = [ + { + "id": "webREG123456", + "email": "carol@example.com", + "first_name": "Carol", + "last_name": "Diaz", + "join_url": "https://zoom.us/w/81234567890?tk=webRegToken", + "custom_questions": [], + } +] + MEETING_REGISTRANTS_LIST_RESPONSE = { "page_count": 1, "page_size": 300, @@ -86,6 +134,37 @@ def create_meeting_response(meeting_id): NOT_FOUND_DELETE_RESPONSE = {"code": 3001, "message": "Meeting does not exist."} +# Sessions are named by Zoom ID and registrant_id is unique, but test rows survive a run, +# so fixed IDs collide with their own leftovers. Assertions read these back off the +# response, so drawing them fresh costs no reproducibility. +def _zoom_id(prefix: int) -> int: + return int(f"{prefix}{random.randrange(10**9):09d}") + + +def _registrant_id() -> str: + return "".join(random.choices(string.ascii_letters + string.digits, k=12)) + + +def create_meeting_response(meeting_id=None): + return {**CREATE_MEETING_RESPONSE, "id": meeting_id or _zoom_id(9)} + + +def create_webinar_response(webinar_id=None): + return {**CREATE_WEBINAR_RESPONSE, "id": webinar_id or _zoom_id(8)} + + +def add_meeting_registrant_response(registrant_id=None): + return {**ADD_MEETING_REGISTRANT_RESPONSE, "registrant_id": registrant_id or _registrant_id()} + + +def add_webinar_registrant_response(registrant_id=None): + return {**ADD_WEBINAR_REGISTRANT_RESPONSE, "registrant_id": registrant_id or _registrant_id()} + + +def webinar_registrants(registrant_id=None): + return [{**WEBINAR_REGISTRANTS[0], "id": registrant_id or _registrant_id()}] + + def mock_response(status_code, json_data=None, text=""): """Build a fake requests.Response-like object for patching.""" from unittest.mock import MagicMock diff --git a/zoom_integration/utils.py b/zoom_integration/utils.py index 0ef13a3..aa7ebf7 100644 --- a/zoom_integration/utils.py +++ b/zoom_integration/utils.py @@ -6,6 +6,17 @@ ZOOM_API_BASE_PATH = "https://api.zoom.us/v2" +SESSION_DOCTYPES = ("Zoom Meeting", "Zoom Webinar") + + +def validate_session_reference(doc): + """link_filters narrows the desk picker client-side only, so guard it here too.""" + if doc.reference_doctype not in SESSION_DOCTYPES: + frappe.throw( + _("Session Type must be one of {0}.").format(", ".join(SESSION_DOCTYPES)), + title=_("Invalid Session Type"), + ) + def authenticate(): zoom = frappe.get_single("Zoom Settings") diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py index 9f9540c..65ede11 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py @@ -4,8 +4,9 @@ from frappe.tests import IntegrationTestCase from zoom_integration.tests.zoom_fixtures import ( - ADD_MEETING_REGISTRANT_RESPONSE, CREATE_MEETING_RESPONSE, + MEETING_PARTICIPANTS_RESPONSE, + add_meeting_registrant_response, create_meeting_response, ) @@ -13,10 +14,9 @@ class TestZoomMeeting(IntegrationTestCase): - def _insert_meeting(self, meeting_id): - with patch( - f"{CONTROLLER}.create_zoom_session", return_value=create_meeting_response(meeting_id) - ) as mock_create: + def _insert_meeting(self): + response = create_meeting_response() + with patch(f"{CONTROLLER}.create_zoom_session", return_value=response) as mock_create: meeting = frappe.get_doc( { "doctype": "Zoom Meeting", @@ -28,43 +28,40 @@ def _insert_meeting(self, meeting_id): } ).insert() - return meeting, mock_create + return meeting, response, mock_create def test_insert_creates_meeting_on_zoom_and_stores_ids(self): - meeting, mock_create = self._insert_meeting("91234567890") + meeting, response, mock_create = self._insert_meeting() resource, body = mock_create.call_args.args self.assertEqual(resource, "meetings") self.assertEqual(body["type"], 2) self.assertEqual(body["settings"]["approval_type"], 0) - self.assertEqual(meeting.zoom_meeting_id, "91234567890") - self.assertEqual(meeting.zoom_meeting_uuid, "aDEFghiJKLmno12PQ==") + self.assertEqual(meeting.zoom_meeting_id, str(response["id"])) + self.assertEqual(meeting.zoom_meeting_uuid, CREATE_MEETING_RESPONSE["uuid"]) self.assertEqual(meeting.zoom_link, CREATE_MEETING_RESPONSE["join_url"]) def test_meeting_is_named_by_its_zoom_meeting_id(self): - meeting, _ = self._insert_meeting("91234567891") + meeting, response, _ = self._insert_meeting() - self.assertEqual(meeting.name, "91234567891") + self.assertEqual(meeting.name, str(response["id"])) def test_add_registrant_returns_registrant_id_and_join_url(self): - meeting, _ = self._insert_meeting("91234567892") + meeting, response, _ = self._insert_meeting() + registrant = add_meeting_registrant_response() - with patch( - f"{CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE - ) as mock_add: + with patch(f"{CONTROLLER}.add_zoom_registrant", return_value=registrant) as mock_add: result = meeting.add_registrant("alice@example.com", "Alice", "Smith") resource, session_id, body = mock_add.call_args.args self.assertEqual(resource, "meetings") - self.assertEqual(session_id, "91234567892") + self.assertEqual(session_id, str(response["id"])) self.assertEqual(body["email"], "alice@example.com") - self.assertEqual(result["registrant_id"], "abcDEF12ghIJ") - self.assertEqual(result["join_url"], ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) + self.assertEqual(result["registrant_id"], registrant["registrant_id"]) + self.assertEqual(result["join_url"], registrant["join_url"]) - def test_sync_attendance_creates_records_from_participants(self): - from zoom_integration.tests.zoom_fixtures import MEETING_PARTICIPANTS_RESPONSE - - meeting, _ = self._insert_meeting("91234567893") + def test_sync_attendance_creates_records_against_the_meeting(self): + meeting, _, _ = self._insert_meeting() participants = MEETING_PARTICIPANTS_RESPONSE["participants"] with patch(f"{CONTROLLER}.get_zoom_participants", return_value=participants): @@ -72,8 +69,24 @@ def test_sync_attendance_creates_records_from_participants(self): records = frappe.get_all( "Zoom Session Attendance Record", - filters={"meeting": meeting.name}, + filters={"reference_doctype": "Zoom Meeting", "reference_name": meeting.name}, fields=["user_email", "total_duration"], ) - emails = {r.user_email for r in records} - self.assertEqual(emails, {"alice@example.com", "bob@example.com"}) + self.assertEqual({r.user_email for r in records}, {"alice@example.com", "bob@example.com"}) + + def test_sync_attendance_sums_duration_across_rejoins(self): + meeting, _, _ = self._insert_meeting() + + participants = [ + {"user_email": "alice@example.com", "name": "Alice", "duration": 600}, + {"user_email": "alice@example.com", "name": "Alice", "duration": 900}, + ] + with patch(f"{CONTROLLER}.get_zoom_participants", return_value=participants): + meeting.sync_attendance() + + total_duration = frappe.db.get_value( + "Zoom Session Attendance Record", + {"reference_name": meeting.name, "user_email": "alice@example.com"}, + "total_duration", + ) + self.assertEqual(total_duration, 1500) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py index ed2f67c..afc41ba 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -152,13 +152,15 @@ def sync_attendance(self): for email, details in summary.items(): if frappe.db.exists( - "Zoom Session Attendance Record", {"meeting": self.name, "user_email": email} + "Zoom Session Attendance Record", + {"reference_doctype": self.doctype, "reference_name": self.name, "user_email": email}, ): continue frappe.get_doc( { "doctype": "Zoom Session Attendance Record", - "meeting": self.name, + "reference_doctype": self.doctype, + "reference_name": self.name, "user_email": email, "full_name": details["name"], "total_duration": details["total_duration"], diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py index 1db98b0..bbfd26a 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py @@ -1,20 +1,64 @@ # Copyright (c) 2025, Build With Hussain and Contributors # See license.txt -# import frappe +from unittest.mock import patch + +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 zoom_integration.tests.zoom_fixtures import create_meeting_response + +MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" class IntegrationTestZoomSessionAttendanceRecord(IntegrationTestCase): - """ - Integration tests for ZoomSessionAttendanceRecord. - Use this class for testing interactions between multiple components. - """ + def _insert_meeting(self): + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=create_meeting_response()): + return frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Attendance Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + def _new_record(self, reference_doctype, reference_name, **kwargs): + return frappe.get_doc( + { + "doctype": "Zoom Session Attendance Record", + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "user_email": "alice@example.com", + "full_name": "Alice Smith", + "total_duration": 3200, + **kwargs, + } + ) + + def test_record_stores_the_session_it_belongs_to(self): + meeting = self._insert_meeting() + + record = self._new_record("Zoom Meeting", meeting.name).insert() + + self.assertEqual(record.reference_doctype, "Zoom Meeting") + self.assertEqual(record.reference_name, meeting.name) + + def test_reference_doctype_outside_zoom_sessions_is_rejected(self): + record = self._new_record("User", "Administrator") + + self.assertRaises(frappe.ValidationError, record.insert) + + def test_reference_name_is_mandatory(self): + record = frappe.get_doc( + { + "doctype": "Zoom Session Attendance Record", + "reference_doctype": "Zoom Meeting", + "user_email": "alice@example.com", + "total_duration": 3200, + } + ) - pass + self.assertRaises(frappe.MandatoryError, record.insert) diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json index d741790..b8673e7 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.json @@ -6,8 +6,8 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "webinar", - "meeting", + "reference_doctype", + "reference_name", "total_duration", "full_name", "column_break_bwzj", @@ -33,18 +33,24 @@ "search_index": 1 }, { - "fieldname": "webinar", + "fieldname": "reference_doctype", "fieldtype": "Link", "in_list_view": 1, - "label": "Webinar", - "options": "Zoom Webinar", + "in_standard_filter": 1, + "label": "Session Type", + "link_filters": "[[\"DocType\",\"name\",\"in\",[\"Zoom Meeting\",\"Zoom Webinar\"]]]", + "options": "DocType", + "reqd": 1, "search_index": 1 }, { - "fieldname": "meeting", - "fieldtype": "Link", - "label": "Meeting", - "options": "Zoom Meeting", + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Session", + "options": "reference_doctype", + "reqd": 1, "search_index": 1 }, { diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py index 53c6925..53d371a 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/zoom_session_attendance_record.py @@ -1,10 +1,10 @@ # Copyright (c) 2025, Build With Hussain and contributors # For license information, please see license.txt -import frappe -from frappe import _ from frappe.model.document import Document +from zoom_integration.utils import validate_session_reference + class ZoomSessionAttendanceRecord(Document): # begin: auto-generated types @@ -17,14 +17,13 @@ class ZoomSessionAttendanceRecord(Document): amended_from: DF.Link | None full_name: DF.Data | None - meeting: DF.Link | None + reference_doctype: DF.Link + reference_name: DF.DynamicLink registrant_id: DF.Data | None registration: DF.Link | None total_duration: DF.Duration user_email: DF.Data - webinar: DF.Link | None # end: auto-generated types def validate(self): - if bool(self.webinar) == bool(self.meeting): - frappe.throw(_("Set exactly one of Webinar or Meeting.")) + validate_session_reference(self) diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py index b3e69fc..1bff1c5 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py @@ -7,18 +7,20 @@ from frappe.tests import IntegrationTestCase from zoom_integration.tests.zoom_fixtures import ( - ADD_MEETING_REGISTRANT_RESPONSE, + add_meeting_registrant_response, + add_webinar_registrant_response, create_meeting_response, + create_webinar_response, + mock_response, ) MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" +WEBINAR_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar" class IntegrationTestZoomSessionRegistration(IntegrationTestCase): - def _insert_meeting(self, meeting_id): - with patch( - f"{MEETING_CONTROLLER}.create_zoom_session", return_value=create_meeting_response(meeting_id) - ): + def _insert_meeting(self): + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=create_meeting_response()): return frappe.get_doc( { "doctype": "Zoom Meeting", @@ -30,41 +32,90 @@ def _insert_meeting(self, meeting_id): } ).insert() - def test_registration_for_meeting_calls_meeting_add_registrant(self): - meeting = self._insert_meeting("92234567890") - - with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=ADD_MEETING_REGISTRANT_RESPONSE): - registration = frappe.get_doc( + def _insert_webinar(self): + with patch(f"{WEBINAR_CONTROLLER}.requests") as mock_requests: + mock_requests.post.return_value = mock_response(201, create_webinar_response()) + return frappe.get_doc( { - "doctype": "Zoom Session Registration", - "meeting": meeting.name, - "email": "alice@example.com", - "first_name": "Alice", - "last_name": "Smith", + "doctype": "Zoom Webinar", + "title": "Reg Webinar", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", } ).insert() - registration.submit() - self.assertEqual(registration.registrant_id, "abcDEF12ghIJ") - self.assertEqual(registration.join_url, ADD_MEETING_REGISTRANT_RESPONSE["join_url"]) - - def test_additional_params_are_forwarded_to_zoom(self): - meeting = self._insert_meeting("92234567891") - response = {**ADD_MEETING_REGISTRANT_RESPONSE, "registrant_id": "uvwXYZ56abCD"} - - registration = frappe.get_doc( + def _new_registration(self, session, **kwargs): + return frappe.get_doc( { "doctype": "Zoom Session Registration", - "meeting": meeting.name, - "email": "bob@example.com", - "first_name": "Bob", - "last_name": "Jones", - "additional_params": [{"key": "org", "value": "Frappe"}], + "reference_doctype": session.doctype, + "reference_name": session.name, + "email": "alice@example.com", + "first_name": "Alice", + "last_name": "Smith", + **kwargs, } + ) + + def test_registration_for_meeting_calls_meeting_add_registrant(self): + meeting = self._insert_meeting() + registration = self._new_registration(meeting).insert() + registrant = add_meeting_registrant_response() + + with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=registrant) as mock_add: + registration.submit() + + resource, session_id, _ = mock_add.call_args.args + self.assertEqual(resource, "meetings") + self.assertEqual(session_id, meeting.zoom_meeting_id) + self.assertEqual(registration.registrant_id, registrant["registrant_id"]) + self.assertEqual(registration.join_url, registrant["join_url"]) + + def test_registration_for_webinar_calls_webinar_add_registrant(self): + webinar = self._insert_webinar() + registration = self._new_registration(webinar, email="carol@example.com", first_name="Carol").insert() + registrant = add_webinar_registrant_response() + + with patch(f"{WEBINAR_CONTROLLER}.requests") as mock_requests: + mock_requests.post.return_value = mock_response(200, registrant) + registration.submit() + + called_url = mock_requests.post.call_args.args[0] + self.assertTrue(called_url.endswith(f"/webinars/{webinar.zoom_webinar_id}/registrants")) + self.assertEqual(registration.registrant_id, registrant["registrant_id"]) + self.assertEqual(registration.join_url, registrant["join_url"]) + + def test_additional_params_are_forwarded_to_zoom(self): + meeting = self._insert_meeting() + registration = self._new_registration( + meeting, + email="bob@example.com", + additional_params=[{"key": "org", "value": "Frappe"}], ).insert() - with patch(f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=response) as mock_add: + with patch( + f"{MEETING_CONTROLLER}.add_zoom_registrant", return_value=add_meeting_registrant_response() + ) as mock_add: registration.submit() body = mock_add.call_args.args[2] self.assertEqual(body["org"], "Frappe") + + def test_reference_doctype_outside_zoom_sessions_is_rejected(self): + registration = self._new_registration(frappe.get_doc("User", "Administrator")) + + self.assertRaises(frappe.ValidationError, registration.insert) + + def test_reference_name_is_mandatory(self): + registration = frappe.get_doc( + { + "doctype": "Zoom Session Registration", + "reference_doctype": "Zoom Meeting", + "email": "alice@example.com", + "first_name": "Alice", + } + ) + + self.assertRaises(frappe.MandatoryError, registration.insert) diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json index 5f792c7..ee55f7d 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.json @@ -6,8 +6,8 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "webinar", - "meeting", + "reference_doctype", + "reference_name", "column_break_baez", "user", "synced_from_zoom", @@ -41,21 +41,24 @@ "search_index": 1 }, { - "fieldname": "webinar", + "fieldname": "reference_doctype", "fieldtype": "Link", "in_list_view": 1, "in_standard_filter": 1, - "label": "Webinar", - "options": "Zoom Webinar", + "label": "Session Type", + "link_filters": "[[\"DocType\",\"name\",\"in\",[\"Zoom Meeting\",\"Zoom Webinar\"]]]", + "options": "DocType", + "reqd": 1, "search_index": 1 }, { - "fieldname": "meeting", - "fieldtype": "Link", + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", "in_list_view": 1, "in_standard_filter": 1, - "label": "Meeting", - "options": "Zoom Meeting", + "label": "Session", + "options": "reference_doctype", + "reqd": 1, "search_index": 1 }, { diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py index 225f16c..53a1b50 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/zoom_session_registration.py @@ -5,6 +5,8 @@ from frappe import _ from frappe.model.document import Document +from zoom_integration.utils import validate_session_reference + class ZoomSessionRegistration(Document): # begin: auto-generated types @@ -25,16 +27,15 @@ class ZoomSessionRegistration(Document): first_name: DF.Data | None join_url: DF.Data | None last_name: DF.Data | None - meeting: DF.Link | None + reference_doctype: DF.Link + reference_name: DF.DynamicLink registrant_id: DF.Data | None synced_from_zoom: DF.Check user: DF.Link | None - webinar: DF.Link | None # end: auto-generated types def validate(self): - if bool(self.webinar) == bool(self.meeting): - frappe.throw(_("Set exactly one of Webinar or Meeting.")) + validate_session_reference(self) def before_insert(self): if not (self.user or self.email): @@ -61,10 +62,7 @@ def before_submit(self): if self.additional_params: additional_params = {param.key: param.value for param in self.additional_params} - # validate() guarantees exactly one of meeting/webinar is set - session = frappe.get_cached_doc( - "Zoom Meeting" if self.meeting else "Zoom Webinar", self.meeting or self.webinar - ) + session = frappe.get_cached_doc(self.reference_doctype, self.reference_name) registration = session.add_registrant(self.email, self.first_name, self.last_name, additional_params) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py index db6a738..1971aba 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py @@ -1,20 +1,63 @@ # Copyright (c) 2025, Build With Hussain and Contributors # See license.txt -# import frappe +from unittest.mock import patch + +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 zoom_integration.tests.zoom_fixtures import ( + WEBINAR_PARTICIPANTS, + create_webinar_response, + mock_response, + webinar_registrants, +) + +CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_webinar.zoom_webinar" class IntegrationTestZoomWebinar(IntegrationTestCase): - """ - Integration tests for ZoomWebinar. - Use this class for testing interactions between multiple components. - """ + def _insert_webinar(self, title="Test Webinar"): + with patch(f"{CONTROLLER}.requests") as mock_requests: + mock_requests.post.return_value = mock_response(201, create_webinar_response()) + return frappe.get_doc( + { + "doctype": "Zoom Webinar", + "title": title, + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + def test_sync_registrations_creates_registrations_against_the_webinar(self): + webinar = self._insert_webinar("Registrations Webinar") + registrants = webinar_registrants() + + with patch(f"{CONTROLLER}.get_webinar_registrant_details", return_value=registrants): + webinar.sync_registrations_from_zoom() + + registrations = frappe.get_all( + "Zoom Session Registration", + filters={"reference_doctype": "Zoom Webinar", "reference_name": webinar.name}, + fields=["email", "registrant_id"], + ) + self.assertEqual([r.email for r in registrations], ["carol@example.com"]) + self.assertEqual(registrations[0].registrant_id, registrants[0]["id"]) + + def test_sync_attendance_creates_records_against_the_webinar(self): + webinar = self._insert_webinar("Attendance Webinar") + + with ( + patch(f"{CONTROLLER}.get_webinar_attendance_details", return_value=WEBINAR_PARTICIPANTS), + patch(f"{CONTROLLER}.get_webinar_registrant_details", return_value=[]), + ): + webinar.sync_attendance() - pass + records = frappe.get_all( + "Zoom Session Attendance Record", + filters={"reference_doctype": "Zoom Webinar", "reference_name": webinar.name}, + fields=["user_email", "total_duration"], + ) + self.assertEqual({r.user_email for r in records}, {"carol@example.com", "dan@example.com"}) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json index 4481187..27a68aa 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json @@ -162,11 +162,11 @@ "links": [ { "link_doctype": "Zoom Session Registration", - "link_fieldname": "webinar" + "link_fieldname": "reference_name" }, { "link_doctype": "Zoom Session Attendance Record", - "link_fieldname": "webinar" + "link_fieldname": "reference_name" } ], "modified": "2026-03-11 10:25:34.016507", diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py index ed0e52d..7ee7b6a 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py @@ -165,7 +165,10 @@ def on_trash(self): if self.zoom_webinar_id: self.delete_webinar_on_zoom() - # frappe.db.delete("Zoom Session Registration", {"webinar": self.name}) + # frappe.db.delete( + # "Zoom Session Registration", + # {"reference_doctype": self.doctype, "reference_name": self.name}, + # ) def delete_webinar_on_zoom(self): url = f"{ZOOM_API_BASE_PATH}/webinars/{self.zoom_webinar_id}" @@ -282,7 +285,11 @@ def sync_attendance(self): if frappe.db.exists( "Zoom Session Attendance Record", - {"webinar": self.name, "user_email": user_email}, + { + "reference_doctype": self.doctype, + "reference_name": self.name, + "user_email": user_email, + }, ): continue @@ -297,7 +304,8 @@ def sync_attendance(self): { "doctype": "Zoom Session Attendance Record", "registration": registration, - "webinar": self.name, + "reference_doctype": self.doctype, + "reference_name": self.name, "user_email": user_email, "full_name": attendance.get("name"), "total_duration": attendance.get("total_duration"), @@ -378,7 +386,8 @@ def sync_registrations_from_zoom(self): "Zoom Session Registration", { "registrant_id": registrant.get("id"), - "webinar": self.name, + "reference_doctype": self.doctype, + "reference_name": self.name, }, ): continue @@ -387,7 +396,8 @@ def sync_registrations_from_zoom(self): "doctype": "Zoom Session Registration", "registrant_id": registrant.get("id"), "join_url": registrant.get("join_url"), - "webinar": self.name, + "reference_doctype": self.doctype, + "reference_name": self.name, "email": registrant.get("email"), "first_name": registrant.get("first_name"), "last_name": registrant.get("last_name"), From 66bfe4b774513c2eeb0bccff8460c7ab00c119d2 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 17:49:36 +0530 Subject: [PATCH 14/20] test: stop depending on a live Zoom token and on buzz Both failures only showed up in CI, because the dev bench has buzz installed and real Zoom credentials configured. Zoom Webinar posts through `requests` and fetches its own token rather than going via the api.py helpers, so patching `requests` alone left the token call live: it hit zoom.us on every run and threw "Zoom Settings Incomplete" wherever credentials were absent. mock_zoom_post patches both. The meeting rename test also queried Buzz Event, which this app does not depend on and CI does not install. The remaining assertion, that every meeting is named after its zoom_meeting_id, covers the patch on its own. Verified by making authenticate() raise and confirming the suite still passes, so nothing reaches live auth. Co-Authored-By: Claude Opus 5 --- .../tests/test_rename_meetings_to_zoom_id.py | 6 ------ zoom_integration/tests/zoom_fixtures.py | 18 ++++++++++++++++++ .../test_zoom_session_registration.py | 8 +++----- .../doctype/zoom_webinar/test_zoom_webinar.py | 5 ++--- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/zoom_integration/tests/test_rename_meetings_to_zoom_id.py b/zoom_integration/tests/test_rename_meetings_to_zoom_id.py index 58184b5..f2a408b 100644 --- a/zoom_integration/tests/test_rename_meetings_to_zoom_id.py +++ b/zoom_integration/tests/test_rename_meetings_to_zoom_id.py @@ -12,9 +12,3 @@ def test_every_meeting_is_named_after_its_zoom_meeting_id(self): mismatched = [row.name for row in stale if row.name != row.zoom_meeting_id] self.assertEqual(mismatched, []) - - def test_events_point_at_meetings_that_exist(self): - linked = frappe.get_all("Buzz Event", filters={"zoom_meeting": ["is", "set"]}, pluck="zoom_meeting") - missing = [name for name in linked if not frappe.db.exists("Zoom Meeting", name)] - - self.assertEqual(missing, []) diff --git a/zoom_integration/tests/zoom_fixtures.py b/zoom_integration/tests/zoom_fixtures.py index f0be5c7..1d094fb 100644 --- a/zoom_integration/tests/zoom_fixtures.py +++ b/zoom_integration/tests/zoom_fixtures.py @@ -3,6 +3,8 @@ import random import string +from contextlib import contextmanager +from unittest.mock import patch CREATE_MEETING_RESPONSE = { "uuid": "aDEFghiJKLmno12PQ==", @@ -175,3 +177,19 @@ def mock_response(status_code, json_data=None, text=""): resp.text = text or "" resp.headers = {} return resp + + +TEST_ZOOM_HEADERS = {"Authorization": "Bearer test-token", "content-type": "application/json"} + + +@contextmanager +def mock_zoom_post(controller, status_code, json_data): + """Zoom Webinar posts through `requests` and fetches its own token, so both need + patching -- otherwise the token call hits Zoom for real and needs live credentials. + """ + with ( + patch(f"{controller}.requests") as mock_requests, + patch(f"{controller}.get_authenticated_headers_for_zoom", return_value=TEST_ZOOM_HEADERS), + ): + mock_requests.post.return_value = mock_response(status_code, json_data) + yield mock_requests diff --git a/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py index 1bff1c5..9cee4ea 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py @@ -11,7 +11,7 @@ add_webinar_registrant_response, create_meeting_response, create_webinar_response, - mock_response, + mock_zoom_post, ) MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" @@ -33,8 +33,7 @@ def _insert_meeting(self): ).insert() def _insert_webinar(self): - with patch(f"{WEBINAR_CONTROLLER}.requests") as mock_requests: - mock_requests.post.return_value = mock_response(201, create_webinar_response()) + with mock_zoom_post(WEBINAR_CONTROLLER, 201, create_webinar_response()): return frappe.get_doc( { "doctype": "Zoom Webinar", @@ -78,8 +77,7 @@ def test_registration_for_webinar_calls_webinar_add_registrant(self): registration = self._new_registration(webinar, email="carol@example.com", first_name="Carol").insert() registrant = add_webinar_registrant_response() - with patch(f"{WEBINAR_CONTROLLER}.requests") as mock_requests: - mock_requests.post.return_value = mock_response(200, registrant) + with mock_zoom_post(WEBINAR_CONTROLLER, 200, registrant) as mock_requests: registration.submit() called_url = mock_requests.post.call_args.args[0] diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py index 1971aba..9271331 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py @@ -9,7 +9,7 @@ from zoom_integration.tests.zoom_fixtures import ( WEBINAR_PARTICIPANTS, create_webinar_response, - mock_response, + mock_zoom_post, webinar_registrants, ) @@ -18,8 +18,7 @@ class IntegrationTestZoomWebinar(IntegrationTestCase): def _insert_webinar(self, title="Test Webinar"): - with patch(f"{CONTROLLER}.requests") as mock_requests: - mock_requests.post.return_value = mock_response(201, create_webinar_response()) + with mock_zoom_post(CONTROLLER, 201, create_webinar_response()): return frappe.get_doc( { "doctype": "Zoom Webinar", From a29353e92894c741fa89b30c9845e82d61efbd81 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:19:36 +0530 Subject: [PATCH 15/20] fix: only push to Zoom when session details actually changed The change guard compared a datetime.date to str(date), which is never equal, so it never fired and every save sent a PATCH to Zoom. Editing a Buzz Event cascades into a meeting save, so ordinary edits were hitting the API. Both controllers carried it -- the meeting one was copied from the webinar one. getdate() on both sides fixes each. The tests missed it because they saved the doc returned by insert(), which still holds `date` as the string it was given, so the comparison matched by accident. The new tests reload from the database first, which is what the desk and the Buzz Event hook do. Also drops a backfill assertion that compared a literal to itself. Co-Authored-By: Claude Opus 5 --- .../tests/test_backfill_session_reference.py | 3 --- .../doctype/zoom_meeting/test_zoom_meeting.py | 21 +++++++++++++++++++ .../doctype/zoom_meeting/zoom_meeting.py | 4 ++-- .../doctype/zoom_webinar/test_zoom_webinar.py | 11 ++++++++++ .../doctype/zoom_webinar/zoom_webinar.py | 6 +++--- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/zoom_integration/tests/test_backfill_session_reference.py b/zoom_integration/tests/test_backfill_session_reference.py index 2206439..44ceb88 100644 --- a/zoom_integration/tests/test_backfill_session_reference.py +++ b/zoom_integration/tests/test_backfill_session_reference.py @@ -5,9 +5,6 @@ class TestBackfillSessionReference(IntegrationTestCase): - def test_legacy_columns_map_to_their_reference_doctype(self): - self.assertEqual(LEGACY_FIELDS, {"webinar": "Zoom Webinar", "meeting": "Zoom Meeting"}) - def test_every_row_carries_a_supported_reference_doctype(self): for doctype in DOCTYPES: stale = frappe.get_all( diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py index 65ede11..cd8d65f 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py @@ -90,3 +90,24 @@ def test_sync_attendance_sums_duration_across_rejoins(self): "total_duration", ) self.assertEqual(total_duration, 1500) + + def test_saving_without_changes_does_not_call_zoom(self): + meeting, _, _ = self._insert_meeting() + # reload: a freshly inserted doc still holds `date` as the string it was given, + # so only a DB-loaded doc exercises the real comparison + reloaded = frappe.get_doc("Zoom Meeting", meeting.name) + + with patch(f"{CONTROLLER}.update_zoom_session") as mock_update: + reloaded.save() + + mock_update.assert_not_called() + + def test_saving_a_changed_date_calls_zoom(self): + meeting, _, _ = self._insert_meeting() + reloaded = frappe.get_doc("Zoom Meeting", meeting.name) + + with patch(f"{CONTROLLER}.update_zoom_session") as mock_update: + reloaded.date = "2026-09-02" + reloaded.save() + + mock_update.assert_called_once() diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py index afc41ba..87de997 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -4,7 +4,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, format_datetime, get_time +from frappe.utils import cint, format_datetime, get_time, getdate from zoom_integration.api import ( add_zoom_registrant, @@ -90,7 +90,7 @@ def update_meeting_on_zoom_if_applicable(self): and self.agenda == before_save.agenda and self.duration == before_save.duration and self.timezone == before_save.timezone - and self.get("date") == str(before_save.get("date")) + and getdate(self.get("date")) == getdate(before_save.get("date")) and get_time(self.get("start_time")) == get_time(before_save.get("start_time")) ) if unchanged: diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py index 9271331..4e11b08 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py @@ -9,6 +9,7 @@ from zoom_integration.tests.zoom_fixtures import ( WEBINAR_PARTICIPANTS, create_webinar_response, + mock_response, mock_zoom_post, webinar_registrants, ) @@ -60,3 +61,13 @@ def test_sync_attendance_creates_records_against_the_webinar(self): fields=["user_email", "total_duration"], ) self.assertEqual({r.user_email for r in records}, {"carol@example.com", "dan@example.com"}) + + def test_saving_without_changes_does_not_call_zoom(self): + webinar = self._insert_webinar("Unchanged Webinar") + reloaded = frappe.get_doc("Zoom Webinar", webinar.name) + + with mock_zoom_post(CONTROLLER, 204, {}) as mock_requests: + mock_requests.patch.return_value = mock_response(204, {}) + reloaded.save() + + mock_requests.patch.assert_not_called() diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py index 7ee7b6a..23c9d97 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py @@ -9,7 +9,7 @@ from frappe import _ from frappe.integrations.utils import create_request_log from frappe.model.document import Document -from frappe.utils import cint, format_datetime +from frappe.utils import cint, format_datetime, getdate from frappe.utils.data import convert_utc_to_timezone, get_datetime, get_time from zoom_integration.utils import ZOOM_API_BASE_PATH, get_authenticated_headers_for_zoom @@ -113,8 +113,8 @@ def update_webinar_on_zoom_if_applicable(self): current_start = get_time(self.get("start_time")) previous_start = get_time(before_save.get("start_time")) - current_date = self.get("date") - previous_date = str(before_save.get("date")) + current_date = getdate(self.get("date")) + previous_date = getdate(before_save.get("date")) zoom_related_field_not_updated = ( self.title == before_save.title From 0bf5a86907c231f110fe6e8d57eb875fa30eef1f Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:21:01 +0530 Subject: [PATCH 16/20] fix: scope webinar attendance to its own registrations The registration lookup filtered on email alone, so it returned any registration for that address. Once one table started holding meetings as well as webinars, a meeting registration could be attached to a webinar attendance record. Co-Authored-By: Claude Opus 5 --- .../doctype/zoom_webinar/test_zoom_webinar.py | 30 +++++++++++++++++++ .../doctype/zoom_webinar/zoom_webinar.py | 6 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py index 4e11b08..e8b3bca 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/test_zoom_webinar.py @@ -71,3 +71,33 @@ def test_saving_without_changes_does_not_call_zoom(self): reloaded.save() mock_requests.patch.assert_not_called() + + def test_attendance_does_not_borrow_another_sessions_registration(self): + webinar = self._insert_webinar("Scoped Webinar") + other = self._insert_webinar("Other Webinar") + + # same attendee, registered only on the other webinar + frappe.get_doc( + { + "doctype": "Zoom Session Registration", + "reference_doctype": "Zoom Webinar", + "reference_name": other.name, + "email": "carol@example.com", + "first_name": "Carol", + "registrant_id": frappe.generate_hash(length=12), + "synced_from_zoom": 1, + } + ).insert() + + with ( + patch(f"{CONTROLLER}.get_webinar_attendance_details", return_value=WEBINAR_PARTICIPANTS[:1]), + patch(f"{CONTROLLER}.get_webinar_registrant_details", return_value=[]), + ): + webinar.sync_attendance() + + linked = frappe.db.get_value( + "Zoom Session Attendance Record", + {"reference_name": webinar.name, "user_email": "carol@example.com"}, + "registration", + ) + self.assertIsNone(linked) diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py index 23c9d97..8452387 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py @@ -295,7 +295,11 @@ def sync_attendance(self): registration = frappe.db.get_value( "Zoom Session Registration", - {"email": user_email}, + { + "reference_doctype": self.doctype, + "reference_name": self.name, + "email": user_email, + }, "name", ) From c418ebc7870ded5224fc726a4911d0359f87120e Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:22:14 +0530 Subject: [PATCH 17/20] fix: stop writing Zoom access tokens into request logs create_request_log stores request_headers verbatim on the Integration Request, so every logged call persisted its Authorization bearer token. Integration Request is readable by any System Manager. Frappe has no redaction helper and nothing in frappe or the payments app passes request_headers at all -- Stripe, Razorpay, PayPal, Braintree, GoCardless, Paytm, Mpesa and Paymob all omit it. Our headers only ever hold the token and a constant content-type, so dropping the argument loses no debugging value. Co-Authored-By: Claude Opus 5 --- zoom_integration/api.py | 16 +++------- .../tests/test_request_logging.py | 32 +++++++++++++++++++ .../doctype/zoom_webinar/zoom_webinar.py | 10 ++---- 3 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 zoom_integration/tests/test_request_logging.py diff --git a/zoom_integration/api.py b/zoom_integration/api.py index f19f55f..90ad570 100644 --- a/zoom_integration/api.py +++ b/zoom_integration/api.py @@ -17,14 +17,10 @@ def create_zoom_session(resource: str, body: dict) -> dict: if response.status_code == 201: data = response.json() - create_request_log( - data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" - ) + create_request_log(data, is_remote_request=1, service_name="Zoom", status="Completed") return data - create_request_log( - response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" - ) + create_request_log(response.text, is_remote_request=1, service_name="Zoom", status="Failed") frappe.throw(f"Failed to create {resource[:-1]} on Zoom: {response.text}") @@ -56,15 +52,11 @@ def add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict: response = requests.post(url, headers=headers, data=json.dumps(body)) if response.status_code not in (200, 201): - create_request_log( - response.text, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Failed" - ) + create_request_log(response.text, is_remote_request=1, service_name="Zoom", status="Failed") frappe.throw(f"Failed to add registrant on Zoom: {response.text}") data = response.json() - create_request_log( - data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" - ) + create_request_log(data, is_remote_request=1, service_name="Zoom", status="Completed") return data diff --git a/zoom_integration/tests/test_request_logging.py b/zoom_integration/tests/test_request_logging.py new file mode 100644 index 0000000..bf0d684 --- /dev/null +++ b/zoom_integration/tests/test_request_logging.py @@ -0,0 +1,32 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration import api +from zoom_integration.tests.zoom_fixtures import ( + CREATE_MEETING_RESPONSE, + TEST_ZOOM_HEADERS, + mock_response, +) + + +class TestRequestLogging(IntegrationTestCase): + def test_access_token_is_never_written_to_the_request_log(self): + existing = set(frappe.get_all("Integration Request", pluck="name")) + + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=TEST_ZOOM_HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.post.return_value = mock_response(201, CREATE_MEETING_RESPONSE) + api.create_zoom_session("meetings", {"topic": "Logging Test"}) + + logged = frappe.get_all( + "Integration Request", + filters={"name": ["not in", list(existing)]}, + fields=["name", "request_headers"], + ) + self.assertTrue(logged, "expected the call to be logged") + for row in logged: + self.assertNotIn("Authorization", row.request_headers or "") diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py index 8452387..2c32589 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.py @@ -81,15 +81,12 @@ def create_webinar_on_zoom(self): self.zoom_link = data.get("join_url") self.zoom_webinar_id = data.get("id") frappe.msgprint(_("Webinar created successfully on Zoom.")) - create_request_log( - data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" - ) + create_request_log(data, is_remote_request=1, service_name="Zoom", status="Completed") else: create_request_log( response.text, is_remote_request=1, service_name="Zoom", - request_headers=headers, status="Failed", ) frappe.throw("Failed to create webinar on Zoom: {0}".format(response.text)) @@ -234,15 +231,12 @@ def add_registrant( response.text, is_remote_request=1, service_name="Zoom", - request_headers=headers, status="Failed", ) frappe.throw(frappe._(f"Failed to add registrant: {response.text}")) data = response.json() - create_request_log( - data, is_remote_request=1, service_name="Zoom", request_headers=headers, status="Completed" - ) + create_request_log(data, is_remote_request=1, service_name="Zoom", status="Completed") if is_webinar_plus: ticket = data.get("tickets", [{}])[0] From 733f4a15aeb4249d709e19774b061717ab2f06af Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:23:55 +0530 Subject: [PATCH 18/20] refactor: share one Zoom timezone list between session forms The 135-entry list Zoom accepts was duplicated byte for byte in both session form scripts, 138 of the meeting script's 163 lines. It now lives in public/js and loads once via app_include_js. A test asserts neither form inlines it again, since nothing else would catch the two copies drifting apart. Co-Authored-By: Claude Opus 5 --- zoom_integration/hooks.py | 2 +- zoom_integration/public/js/zoom_timezones.js | 143 ++++++++++++++++++ zoom_integration/tests/test_form_assets.py | 18 +++ .../doctype/zoom_meeting/zoom_meeting.js | 142 +---------------- .../doctype/zoom_webinar/zoom_webinar.js | 142 +---------------- 5 files changed, 164 insertions(+), 283 deletions(-) create mode 100644 zoom_integration/public/js/zoom_timezones.js create mode 100644 zoom_integration/tests/test_form_assets.py diff --git a/zoom_integration/hooks.py b/zoom_integration/hooks.py index 9add8b0..e6b24b7 100644 --- a/zoom_integration/hooks.py +++ b/zoom_integration/hooks.py @@ -26,7 +26,7 @@ # include js, css files in header of desk.html # app_include_css = "/assets/zoom_integration/css/zoom_integration.css" -# app_include_js = "/assets/zoom_integration/js/zoom_integration.js" +app_include_js = "/assets/zoom_integration/js/zoom_timezones.js" # include js, css files in header of web template # web_include_css = "/assets/zoom_integration/css/zoom_integration.css" diff --git a/zoom_integration/public/js/zoom_timezones.js b/zoom_integration/public/js/zoom_timezones.js new file mode 100644 index 0000000..c7b166c --- /dev/null +++ b/zoom_integration/public/js/zoom_timezones.js @@ -0,0 +1,143 @@ +// Copyright (c) 2026, Build With Hussain and contributors +// For license information, please see license.txt + +// Timezones Zoom accepts on meeting and webinar payloads. Shared by both session forms. +frappe.provide("zoom_integration"); + +zoom_integration.timezones = [ + "Pacific/Midway", + "Pacific/Pago_Pago", + "Pacific/Honolulu", + "America/Anchorage", + "America/Vancouver", + "America/Los_Angeles", + "America/Tijuana", + "America/Edmonton", + "America/Denver", + "America/Phoenix", + "America/Mazatlan", + "America/Winnipeg", + "America/Regina", + "America/Chicago", + "America/Mexico_City", + "America/Guatemala", + "America/El_Salvador", + "America/Managua", + "America/Costa_Rica", + "America/Montreal", + "America/New_York", + "America/Indianapolis", + "America/Panama", + "America/Bogota", + "America/Lima", + "America/Halifax", + "America/Puerto_Rico", + "America/Caracas", + "America/Santiago", + "America/St_Johns", + "America/Montevideo", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Godthab", + "America/Sao_Paulo", + "Atlantic/Azores", + "Canada/Atlantic", + "Atlantic/Cape_Verde", + "UTC", + "Etc/Greenwich", + "Europe/Belgrade", + "CET", + "Atlantic/Reykjavik", + "Europe/Dublin", + "Europe/London", + "Europe/Lisbon", + "Africa/Casablanca", + "Africa/Nouakchott", + "Europe/Oslo", + "Europe/Copenhagen", + "Europe/Brussels", + "Europe/Berlin", + "Europe/Helsinki", + "Europe/Amsterdam", + "Europe/Rome", + "Europe/Stockholm", + "Europe/Vienna", + "Europe/Luxembourg", + "Europe/Paris", + "Europe/Zurich", + "Europe/Madrid", + "Africa/Bangui", + "Africa/Algiers", + "Africa/Tunis", + "Africa/Harare", + "Africa/Nairobi", + "Europe/Warsaw", + "Europe/Prague", + "Europe/Budapest", + "Europe/Sofia", + "Europe/Istanbul", + "Europe/Athens", + "Europe/Bucharest", + "Asia/Nicosia", + "Asia/Beirut", + "Asia/Damascus", + "Asia/Jerusalem", + "Asia/Amman", + "Africa/Tripoli", + "Africa/Cairo", + "Africa/Johannesburg", + "Europe/Moscow", + "Asia/Baghdad", + "Asia/Kuwait", + "Asia/Riyadh", + "Asia/Bahrain", + "Asia/Qatar", + "Asia/Aden", + "Asia/Tehran", + "Africa/Khartoum", + "Africa/Djibouti", + "Africa/Mogadishu", + "Asia/Dubai", + "Asia/Muscat", + "Asia/Baku", + "Asia/Kabul", + "Asia/Yekaterinburg", + "Asia/Tashkent", + "Asia/Calcutta", + "Asia/Kathmandu", + "Asia/Novosibirsk", + "Asia/Almaty", + "Asia/Dacca", + "Asia/Krasnoyarsk", + "Asia/Dhaka", + "Asia/Bangkok", + "Asia/Saigon", + "Asia/Jakarta", + "Asia/Irkutsk", + "Asia/Shanghai", + "Asia/Hong_Kong", + "Asia/Taipei", + "Asia/Kuala_Lumpur", + "Asia/Singapore", + "Australia/Perth", + "Asia/Yakutsk", + "Asia/Seoul", + "Asia/Tokyo", + "Australia/Darwin", + "Australia/Adelaide", + "Asia/Vladivostok", + "Pacific/Port_Moresby", + "Australia/Brisbane", + "Australia/Sydney", + "Australia/Hobart", + "Asia/Magadan", + "SST", + "Pacific/Noumea", + "Asia/Kamchatka", + "Pacific/Fiji", + "Pacific/Auckland", + "Asia/Kolkata", + "Europe/Kiev", + "America/Tegucigalpa", + "Pacific/Apia", + ]; diff --git a/zoom_integration/tests/test_form_assets.py b/zoom_integration/tests/test_form_assets.py new file mode 100644 index 0000000..59ce8b5 --- /dev/null +++ b/zoom_integration/tests/test_form_assets.py @@ -0,0 +1,18 @@ +import pathlib + +from frappe.tests import UnitTestCase + +APP_ROOT = pathlib.Path(__file__).resolve().parents[1] +SESSION_FORMS = ("zoom_meeting", "zoom_webinar") + + +class TestFormAssets(UnitTestCase): + def test_session_forms_do_not_inline_the_timezone_list(self): + for doctype in SESSION_FORMS: + script = (APP_ROOT / "zoom_integration" / "doctype" / doctype / f"{doctype}.js").read_text() + self.assertNotIn("Pacific/Midway", script, doctype) + + def test_shared_timezone_list_is_the_single_source(self): + shared = (APP_ROOT / "public" / "js" / "zoom_timezones.js").read_text() + self.assertIn("Pacific/Midway", shared) + self.assertIn("Asia/Calcutta", shared) diff --git a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js index 8e4b0f4..457d2b4 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Zoom Meeting", { refresh(frm) { - frm.fields_dict.timezone.set_data(getSupportedTimezones()); + frm.fields_dict.timezone.set_data(zoom_integration.timezones); if (!frm.doc.__islocal) { frm.add_custom_button(__("Sync Attendance"), () => { frm.call({ @@ -21,143 +21,3 @@ frappe.ui.form.on("Zoom Meeting", { } }, }); - -function getSupportedTimezones() { - return [ - "Pacific/Midway", - "Pacific/Pago_Pago", - "Pacific/Honolulu", - "America/Anchorage", - "America/Vancouver", - "America/Los_Angeles", - "America/Tijuana", - "America/Edmonton", - "America/Denver", - "America/Phoenix", - "America/Mazatlan", - "America/Winnipeg", - "America/Regina", - "America/Chicago", - "America/Mexico_City", - "America/Guatemala", - "America/El_Salvador", - "America/Managua", - "America/Costa_Rica", - "America/Montreal", - "America/New_York", - "America/Indianapolis", - "America/Panama", - "America/Bogota", - "America/Lima", - "America/Halifax", - "America/Puerto_Rico", - "America/Caracas", - "America/Santiago", - "America/St_Johns", - "America/Montevideo", - "America/Araguaina", - "America/Argentina/Buenos_Aires", - "America/Godthab", - "America/Sao_Paulo", - "Atlantic/Azores", - "Canada/Atlantic", - "Atlantic/Cape_Verde", - "UTC", - "Etc/Greenwich", - "Europe/Belgrade", - "CET", - "Atlantic/Reykjavik", - "Europe/Dublin", - "Europe/London", - "Europe/Lisbon", - "Africa/Casablanca", - "Africa/Nouakchott", - "Europe/Oslo", - "Europe/Copenhagen", - "Europe/Brussels", - "Europe/Berlin", - "Europe/Helsinki", - "Europe/Amsterdam", - "Europe/Rome", - "Europe/Stockholm", - "Europe/Vienna", - "Europe/Luxembourg", - "Europe/Paris", - "Europe/Zurich", - "Europe/Madrid", - "Africa/Bangui", - "Africa/Algiers", - "Africa/Tunis", - "Africa/Harare", - "Africa/Nairobi", - "Europe/Warsaw", - "Europe/Prague", - "Europe/Budapest", - "Europe/Sofia", - "Europe/Istanbul", - "Europe/Athens", - "Europe/Bucharest", - "Asia/Nicosia", - "Asia/Beirut", - "Asia/Damascus", - "Asia/Jerusalem", - "Asia/Amman", - "Africa/Tripoli", - "Africa/Cairo", - "Africa/Johannesburg", - "Europe/Moscow", - "Asia/Baghdad", - "Asia/Kuwait", - "Asia/Riyadh", - "Asia/Bahrain", - "Asia/Qatar", - "Asia/Aden", - "Asia/Tehran", - "Africa/Khartoum", - "Africa/Djibouti", - "Africa/Mogadishu", - "Asia/Dubai", - "Asia/Muscat", - "Asia/Baku", - "Asia/Kabul", - "Asia/Yekaterinburg", - "Asia/Tashkent", - "Asia/Calcutta", - "Asia/Kathmandu", - "Asia/Novosibirsk", - "Asia/Almaty", - "Asia/Dacca", - "Asia/Krasnoyarsk", - "Asia/Dhaka", - "Asia/Bangkok", - "Asia/Saigon", - "Asia/Jakarta", - "Asia/Irkutsk", - "Asia/Shanghai", - "Asia/Hong_Kong", - "Asia/Taipei", - "Asia/Kuala_Lumpur", - "Asia/Singapore", - "Australia/Perth", - "Asia/Yakutsk", - "Asia/Seoul", - "Asia/Tokyo", - "Australia/Darwin", - "Australia/Adelaide", - "Asia/Vladivostok", - "Pacific/Port_Moresby", - "Australia/Brisbane", - "Australia/Sydney", - "Australia/Hobart", - "Asia/Magadan", - "SST", - "Pacific/Noumea", - "Asia/Kamchatka", - "Pacific/Fiji", - "Pacific/Auckland", - "Asia/Kolkata", - "Europe/Kiev", - "America/Tegucigalpa", - "Pacific/Apia", - ]; -} diff --git a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.js b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.js index f5e5a9c..bea58a9 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.js +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Zoom Webinar", { refresh(frm) { - frm.fields_dict.timezone.set_data(getSupportedTimezones()); + frm.fields_dict.timezone.set_data(zoom_integration.timezones); if (!frm.doc.__islocal) { frm.add_custom_button(__("Sync Attendance"), () => { frm.call({ @@ -34,143 +34,3 @@ frappe.ui.form.on("Zoom Webinar", { } }, }); - -function getSupportedTimezones() { - return [ - "Pacific/Midway", - "Pacific/Pago_Pago", - "Pacific/Honolulu", - "America/Anchorage", - "America/Vancouver", - "America/Los_Angeles", - "America/Tijuana", - "America/Edmonton", - "America/Denver", - "America/Phoenix", - "America/Mazatlan", - "America/Winnipeg", - "America/Regina", - "America/Chicago", - "America/Mexico_City", - "America/Guatemala", - "America/El_Salvador", - "America/Managua", - "America/Costa_Rica", - "America/Montreal", - "America/New_York", - "America/Indianapolis", - "America/Panama", - "America/Bogota", - "America/Lima", - "America/Halifax", - "America/Puerto_Rico", - "America/Caracas", - "America/Santiago", - "America/St_Johns", - "America/Montevideo", - "America/Araguaina", - "America/Argentina/Buenos_Aires", - "America/Godthab", - "America/Sao_Paulo", - "Atlantic/Azores", - "Canada/Atlantic", - "Atlantic/Cape_Verde", - "UTC", - "Etc/Greenwich", - "Europe/Belgrade", - "CET", - "Atlantic/Reykjavik", - "Europe/Dublin", - "Europe/London", - "Europe/Lisbon", - "Africa/Casablanca", - "Africa/Nouakchott", - "Europe/Oslo", - "Europe/Copenhagen", - "Europe/Brussels", - "Europe/Berlin", - "Europe/Helsinki", - "Europe/Amsterdam", - "Europe/Rome", - "Europe/Stockholm", - "Europe/Vienna", - "Europe/Luxembourg", - "Europe/Paris", - "Europe/Zurich", - "Europe/Madrid", - "Africa/Bangui", - "Africa/Algiers", - "Africa/Tunis", - "Africa/Harare", - "Africa/Nairobi", - "Europe/Warsaw", - "Europe/Prague", - "Europe/Budapest", - "Europe/Sofia", - "Europe/Istanbul", - "Europe/Athens", - "Europe/Bucharest", - "Asia/Nicosia", - "Asia/Beirut", - "Asia/Damascus", - "Asia/Jerusalem", - "Asia/Amman", - "Africa/Tripoli", - "Africa/Cairo", - "Africa/Johannesburg", - "Europe/Moscow", - "Asia/Baghdad", - "Asia/Kuwait", - "Asia/Riyadh", - "Asia/Bahrain", - "Asia/Qatar", - "Asia/Aden", - "Asia/Tehran", - "Africa/Khartoum", - "Africa/Djibouti", - "Africa/Mogadishu", - "Asia/Dubai", - "Asia/Muscat", - "Asia/Baku", - "Asia/Kabul", - "Asia/Yekaterinburg", - "Asia/Tashkent", - "Asia/Calcutta", - "Asia/Kathmandu", - "Asia/Novosibirsk", - "Asia/Almaty", - "Asia/Dacca", - "Asia/Krasnoyarsk", - "Asia/Dhaka", - "Asia/Bangkok", - "Asia/Saigon", - "Asia/Jakarta", - "Asia/Irkutsk", - "Asia/Shanghai", - "Asia/Hong_Kong", - "Asia/Taipei", - "Asia/Kuala_Lumpur", - "Asia/Singapore", - "Australia/Perth", - "Asia/Yakutsk", - "Asia/Seoul", - "Asia/Tokyo", - "Australia/Darwin", - "Australia/Adelaide", - "Asia/Vladivostok", - "Pacific/Port_Moresby", - "Australia/Brisbane", - "Australia/Sydney", - "Australia/Hobart", - "Asia/Magadan", - "SST", - "Pacific/Noumea", - "Asia/Kamchatka", - "Pacific/Fiji", - "Pacific/Auckland", - "Asia/Kolkata", - "Europe/Kiev", - "America/Tegucigalpa", - "Pacific/Apia", - ]; -} From 884aa50311493aef2596f350cd6860020a5794f7 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:25:39 +0530 Subject: [PATCH 19/20] refactor: build pagination URLs without a callback _paginate took a url_builder callback and a page_size nobody overrode, so callers wrapped a one-line f-string in a lambda. They now pass the URL. Pagination had no multi-page test at all -- both existing ones used single-page fixtures, leaving the loop unexercised. The new test covers three pages, which is the point: with two pages an appending implementation still produces one token and looks correct. Verified by writing the appending version and watching the third request carry two tokens. Co-Authored-By: Claude Opus 5 --- zoom_integration/api.py | 30 +++++++++++------------------- zoom_integration/tests/test_api.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/zoom_integration/api.py b/zoom_integration/api.py index 90ad570..43c30a5 100644 --- a/zoom_integration/api.py +++ b/zoom_integration/api.py @@ -1,4 +1,5 @@ import json +from urllib.parse import quote import frappe import requests @@ -60,18 +61,17 @@ def add_zoom_registrant(resource: str, session_id: str, body: dict) -> dict: return data -def _paginate(url_builder, item_key: str, page_size: int) -> list[dict]: +def _paginate(url: str, item_key: str) -> list[dict]: """Follow Zoom's next_page_token pagination and collect `item_key` items.""" headers = get_authenticated_headers_for_zoom() items: list[dict] = [] next_page_token = None while True: - url = url_builder(page_size) - if next_page_token: - url += f"&next_page_token={next_page_token}" + # rebuild from `url` each pass so the token replaces rather than stacks + page_url = f"{url}&next_page_token={next_page_token}" if next_page_token else url - response = requests.get(url, headers=headers, timeout=30) + response = requests.get(page_url, headers=headers, timeout=30) if response.status_code != 200: frappe.throw(f"Failed to fetch {item_key} from Zoom: {response.text}") @@ -84,22 +84,14 @@ def _paginate(url_builder, item_key: str, page_size: int) -> list[dict]: return items -def get_zoom_registrants(resource: str, session_id: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: - return _paginate( - lambda size: f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants?page_size={size}", - "registrants", - page_size, - ) +def get_zoom_registrants(resource: str, session_id: str) -> list[dict]: + url = f"{ZOOM_API_BASE_PATH}/{resource}/{session_id}/registrants?page_size={DEFAULT_PAGE_SIZE}" + return _paginate(url, "registrants") -def get_zoom_participants(resource: str, session_uuid: str, page_size: int = DEFAULT_PAGE_SIZE) -> list[dict]: +def get_zoom_participants(resource: str, session_uuid: str) -> list[dict]: # ponytail: single-encoding the UUID covers the common case; double-encode # here if you hit meeting UUIDs that start with "/" or contain "//". - from urllib.parse import quote - encoded_uuid = quote(session_uuid, safe="") - return _paginate( - lambda size: f"{ZOOM_API_BASE_PATH}/past_{resource}/{encoded_uuid}/participants?page_size={size}", - "participants", - page_size, - ) + url = f"{ZOOM_API_BASE_PATH}/past_{resource}/{encoded_uuid}/participants?page_size={DEFAULT_PAGE_SIZE}" + return _paginate(url, "participants") diff --git a/zoom_integration/tests/test_api.py b/zoom_integration/tests/test_api.py index 0e6f76c..3d843a9 100644 --- a/zoom_integration/tests/test_api.py +++ b/zoom_integration/tests/test_api.py @@ -101,3 +101,33 @@ def test_get_registrants_returns_list(self): called_url = mock_requests.get.call_args.args[0] self.assertIn("/meetings/91234567890/registrants", called_url) self.assertEqual(result[0]["email"], "alice@example.com") + + def test_pagination_follows_next_page_token_without_stacking_it(self): + # three pages: stacking only shows from the third request onwards + page_one = {"registrants": [{"email": "one@example.com"}], "next_page_token": "TOKEN1"} + page_two = {"registrants": [{"email": "two@example.com"}], "next_page_token": "TOKEN2"} + page_three = {"registrants": [{"email": "three@example.com"}], "next_page_token": ""} + + with ( + patch.object(api, "get_authenticated_headers_for_zoom", return_value=HEADERS), + patch.object(api, "requests") as mock_requests, + ): + mock_requests.get.side_effect = [ + mock_response(200, page_one), + mock_response(200, page_two), + mock_response(200, page_three), + ] + + result = api.get_zoom_registrants("meetings", "91234567890") + + self.assertEqual( + [r["email"] for r in result], + ["one@example.com", "two@example.com", "three@example.com"], + ) + + first_url, second_url, third_url = (call.args[0] for call in mock_requests.get.call_args_list) + self.assertNotIn("next_page_token", first_url) + self.assertIn("next_page_token=TOKEN1", second_url) + self.assertEqual(second_url.count("next_page_token"), 1) + self.assertIn("next_page_token=TOKEN2", third_url) + self.assertEqual(third_url.count("next_page_token"), 1) From a7cc4a33689ac00afcd17cc769536fe991635fa9 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Mon, 27 Jul 2026 18:28:57 +0530 Subject: [PATCH 20/20] style: format the shared timezone list and declare its global pre-commit was never installed in this clone, so prettier and eslint had not run on any commit here. The shared list is the first JS this app has added in a while, which is what surfaced it. zoom_integration is a namespace frappe.provide creates at runtime, so eslint needs it declaring alongside frappe. Co-Authored-By: Claude Opus 5 --- .eslintrc | 1 + zoom_integration/public/js/zoom_timezones.js | 272 +++++++++---------- 2 files changed, 137 insertions(+), 136 deletions(-) diff --git a/.eslintrc b/.eslintrc index c5e7d68..fc1dd7b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -26,6 +26,7 @@ "root": true, "globals": { "frappe": true, + "zoom_integration": true, "Vue": true, "SetVueGlobals": true, "__": true, diff --git a/zoom_integration/public/js/zoom_timezones.js b/zoom_integration/public/js/zoom_timezones.js index c7b166c..8efdc32 100644 --- a/zoom_integration/public/js/zoom_timezones.js +++ b/zoom_integration/public/js/zoom_timezones.js @@ -5,139 +5,139 @@ frappe.provide("zoom_integration"); zoom_integration.timezones = [ - "Pacific/Midway", - "Pacific/Pago_Pago", - "Pacific/Honolulu", - "America/Anchorage", - "America/Vancouver", - "America/Los_Angeles", - "America/Tijuana", - "America/Edmonton", - "America/Denver", - "America/Phoenix", - "America/Mazatlan", - "America/Winnipeg", - "America/Regina", - "America/Chicago", - "America/Mexico_City", - "America/Guatemala", - "America/El_Salvador", - "America/Managua", - "America/Costa_Rica", - "America/Montreal", - "America/New_York", - "America/Indianapolis", - "America/Panama", - "America/Bogota", - "America/Lima", - "America/Halifax", - "America/Puerto_Rico", - "America/Caracas", - "America/Santiago", - "America/St_Johns", - "America/Montevideo", - "America/Araguaina", - "America/Argentina/Buenos_Aires", - "America/Godthab", - "America/Sao_Paulo", - "Atlantic/Azores", - "Canada/Atlantic", - "Atlantic/Cape_Verde", - "UTC", - "Etc/Greenwich", - "Europe/Belgrade", - "CET", - "Atlantic/Reykjavik", - "Europe/Dublin", - "Europe/London", - "Europe/Lisbon", - "Africa/Casablanca", - "Africa/Nouakchott", - "Europe/Oslo", - "Europe/Copenhagen", - "Europe/Brussels", - "Europe/Berlin", - "Europe/Helsinki", - "Europe/Amsterdam", - "Europe/Rome", - "Europe/Stockholm", - "Europe/Vienna", - "Europe/Luxembourg", - "Europe/Paris", - "Europe/Zurich", - "Europe/Madrid", - "Africa/Bangui", - "Africa/Algiers", - "Africa/Tunis", - "Africa/Harare", - "Africa/Nairobi", - "Europe/Warsaw", - "Europe/Prague", - "Europe/Budapest", - "Europe/Sofia", - "Europe/Istanbul", - "Europe/Athens", - "Europe/Bucharest", - "Asia/Nicosia", - "Asia/Beirut", - "Asia/Damascus", - "Asia/Jerusalem", - "Asia/Amman", - "Africa/Tripoli", - "Africa/Cairo", - "Africa/Johannesburg", - "Europe/Moscow", - "Asia/Baghdad", - "Asia/Kuwait", - "Asia/Riyadh", - "Asia/Bahrain", - "Asia/Qatar", - "Asia/Aden", - "Asia/Tehran", - "Africa/Khartoum", - "Africa/Djibouti", - "Africa/Mogadishu", - "Asia/Dubai", - "Asia/Muscat", - "Asia/Baku", - "Asia/Kabul", - "Asia/Yekaterinburg", - "Asia/Tashkent", - "Asia/Calcutta", - "Asia/Kathmandu", - "Asia/Novosibirsk", - "Asia/Almaty", - "Asia/Dacca", - "Asia/Krasnoyarsk", - "Asia/Dhaka", - "Asia/Bangkok", - "Asia/Saigon", - "Asia/Jakarta", - "Asia/Irkutsk", - "Asia/Shanghai", - "Asia/Hong_Kong", - "Asia/Taipei", - "Asia/Kuala_Lumpur", - "Asia/Singapore", - "Australia/Perth", - "Asia/Yakutsk", - "Asia/Seoul", - "Asia/Tokyo", - "Australia/Darwin", - "Australia/Adelaide", - "Asia/Vladivostok", - "Pacific/Port_Moresby", - "Australia/Brisbane", - "Australia/Sydney", - "Australia/Hobart", - "Asia/Magadan", - "SST", - "Pacific/Noumea", - "Asia/Kamchatka", - "Pacific/Fiji", - "Pacific/Auckland", - "Asia/Kolkata", - "Europe/Kiev", - "America/Tegucigalpa", - "Pacific/Apia", - ]; + "Pacific/Midway", + "Pacific/Pago_Pago", + "Pacific/Honolulu", + "America/Anchorage", + "America/Vancouver", + "America/Los_Angeles", + "America/Tijuana", + "America/Edmonton", + "America/Denver", + "America/Phoenix", + "America/Mazatlan", + "America/Winnipeg", + "America/Regina", + "America/Chicago", + "America/Mexico_City", + "America/Guatemala", + "America/El_Salvador", + "America/Managua", + "America/Costa_Rica", + "America/Montreal", + "America/New_York", + "America/Indianapolis", + "America/Panama", + "America/Bogota", + "America/Lima", + "America/Halifax", + "America/Puerto_Rico", + "America/Caracas", + "America/Santiago", + "America/St_Johns", + "America/Montevideo", + "America/Araguaina", + "America/Argentina/Buenos_Aires", + "America/Godthab", + "America/Sao_Paulo", + "Atlantic/Azores", + "Canada/Atlantic", + "Atlantic/Cape_Verde", + "UTC", + "Etc/Greenwich", + "Europe/Belgrade", + "CET", + "Atlantic/Reykjavik", + "Europe/Dublin", + "Europe/London", + "Europe/Lisbon", + "Africa/Casablanca", + "Africa/Nouakchott", + "Europe/Oslo", + "Europe/Copenhagen", + "Europe/Brussels", + "Europe/Berlin", + "Europe/Helsinki", + "Europe/Amsterdam", + "Europe/Rome", + "Europe/Stockholm", + "Europe/Vienna", + "Europe/Luxembourg", + "Europe/Paris", + "Europe/Zurich", + "Europe/Madrid", + "Africa/Bangui", + "Africa/Algiers", + "Africa/Tunis", + "Africa/Harare", + "Africa/Nairobi", + "Europe/Warsaw", + "Europe/Prague", + "Europe/Budapest", + "Europe/Sofia", + "Europe/Istanbul", + "Europe/Athens", + "Europe/Bucharest", + "Asia/Nicosia", + "Asia/Beirut", + "Asia/Damascus", + "Asia/Jerusalem", + "Asia/Amman", + "Africa/Tripoli", + "Africa/Cairo", + "Africa/Johannesburg", + "Europe/Moscow", + "Asia/Baghdad", + "Asia/Kuwait", + "Asia/Riyadh", + "Asia/Bahrain", + "Asia/Qatar", + "Asia/Aden", + "Asia/Tehran", + "Africa/Khartoum", + "Africa/Djibouti", + "Africa/Mogadishu", + "Asia/Dubai", + "Asia/Muscat", + "Asia/Baku", + "Asia/Kabul", + "Asia/Yekaterinburg", + "Asia/Tashkent", + "Asia/Calcutta", + "Asia/Kathmandu", + "Asia/Novosibirsk", + "Asia/Almaty", + "Asia/Dacca", + "Asia/Krasnoyarsk", + "Asia/Dhaka", + "Asia/Bangkok", + "Asia/Saigon", + "Asia/Jakarta", + "Asia/Irkutsk", + "Asia/Shanghai", + "Asia/Hong_Kong", + "Asia/Taipei", + "Asia/Kuala_Lumpur", + "Asia/Singapore", + "Australia/Perth", + "Asia/Yakutsk", + "Asia/Seoul", + "Asia/Tokyo", + "Australia/Darwin", + "Australia/Adelaide", + "Asia/Vladivostok", + "Pacific/Port_Moresby", + "Australia/Brisbane", + "Australia/Sydney", + "Australia/Hobart", + "Asia/Magadan", + "SST", + "Pacific/Noumea", + "Asia/Kamchatka", + "Pacific/Fiji", + "Pacific/Auckland", + "Asia/Kolkata", + "Europe/Kiev", + "America/Tegucigalpa", + "Pacific/Apia", +];