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/.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/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/api.py b/zoom_integration/api.py new file mode 100644 index 0000000..43c30a5 --- /dev/null +++ b/zoom_integration/api.py @@ -0,0 +1,97 @@ +import json +from urllib.parse import quote + +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", status="Completed") + return data + + 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}") + + +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", 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", status="Completed") + return data + + +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: + # 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(page_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) -> 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) -> list[dict]: + # ponytail: single-encoding the UUID covers the common case; double-encode + # here if you hit meeting UUIDs that start with "/" or contain "//". + encoded_uuid = quote(session_uuid, safe="") + 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/hooks.py b/zoom_integration/hooks.py index 1eb8f97..e6b24b7 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" @@ -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/patches.txt b/zoom_integration/patches.txt index f15c3a9..f48699e 100644 --- a/zoom_integration/patches.txt +++ b/zoom_integration/patches.txt @@ -1,6 +1,9 @@ [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 +# 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/zoom_integration/doctype/zoom_webinar_additional_param/__init__.py b/zoom_integration/patches/__init__.py similarity index 100% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_additional_param/__init__.py rename to zoom_integration/patches/__init__.py 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/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/patches/rename_webinar_doctypes_to_session.py b/zoom_integration/patches/rename_webinar_doctypes_to_session.py new file mode 100644 index 0000000..0019f82 --- /dev/null +++ b/zoom_integration/patches/rename_webinar_doctypes_to_session.py @@ -0,0 +1,19 @@ +import frappe + +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(): + """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 + 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/public/js/zoom_timezones.js b/zoom_integration/public/js/zoom_timezones.js new file mode 100644 index 0000000..8efdc32 --- /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/zoom_integration/doctype/zoom_webinar_attendance_record/__init__.py b/zoom_integration/tests/__init__.py similarity index 100% rename from zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/__init__.py rename to zoom_integration/tests/__init__.py diff --git a/zoom_integration/tests/test_api.py b/zoom_integration/tests/test_api.py new file mode 100644 index 0000000..3d843a9 --- /dev/null +++ b/zoom_integration/tests/test_api.py @@ -0,0 +1,133 @@ +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") + + 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) 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..44ceb88 --- /dev/null +++ b/zoom_integration/tests/test_backfill_session_reference.py @@ -0,0 +1,32 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.patches.backfill_session_reference import DOCTYPES, LEGACY_FIELDS, execute + + +class TestBackfillSessionReference(IntegrationTestCase): + 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/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/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..f2a408b --- /dev/null +++ b/zoom_integration/tests/test_rename_meetings_to_zoom_id.py @@ -0,0 +1,14 @@ +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, []) 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/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/tests/zoom_fixtures.py b/zoom_integration/tests/zoom_fixtures.py new file mode 100644 index 0000000..1d094fb --- /dev/null +++ b/zoom_integration/tests/zoom_fixtures.py @@ -0,0 +1,195 @@ +"""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 +from contextlib import contextmanager +from unittest.mock import patch + +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"}, +} + + +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. + +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", +} + +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, + "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."} + + +# 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 + + 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 + + +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/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_webinar_registration/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_meeting/__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_meeting/__init__.py 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..cd8d65f --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/test_zoom_meeting.py @@ -0,0 +1,113 @@ +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.tests.zoom_fixtures import ( + CREATE_MEETING_RESPONSE, + MEETING_PARTICIPANTS_RESPONSE, + add_meeting_registrant_response, + create_meeting_response, +) + +CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + + +class TestZoomMeeting(IntegrationTestCase): + 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", + "title": "Test Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + return meeting, response, mock_create + + def test_insert_creates_meeting_on_zoom_and_stores_ids(self): + 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, 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, response, _ = self._insert_meeting() + + self.assertEqual(meeting.name, str(response["id"])) + + def test_add_registrant_returns_registrant_id_and_join_url(self): + meeting, response, _ = self._insert_meeting() + registrant = add_meeting_registrant_response() + + 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, str(response["id"])) + self.assertEqual(body["email"], "alice@example.com") + self.assertEqual(result["registrant_id"], registrant["registrant_id"]) + self.assertEqual(result["join_url"], registrant["join_url"]) + + 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): + meeting.sync_attendance() + + records = frappe.get_all( + "Zoom Session Attendance Record", + filters={"reference_doctype": "Zoom Meeting", "reference_name": meeting.name}, + fields=["user_email", "total_duration"], + ) + 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) + + 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.js b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js new file mode 100644 index 0000000..457d2b4 --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.js @@ -0,0 +1,23 @@ +// 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(zoom_integration.timezones); + 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", + }); + }); + }); + } + }, +}); 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..fbef8d7 --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.json @@ -0,0 +1,168 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:zoom_meeting_id", + "creation": "2026-07-24 00:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "agenda", + "column_break_narx", + "send_zoom_registration_email", + "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", + "attendance_synced" + ], + "fields": [ + { + "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": [ + { + "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", + "name": "Zoom Meeting", + "naming_rule": "By fieldname", + "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": "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 new file mode 100644 index 0000000..87de997 --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_meeting/zoom_meeting.py @@ -0,0 +1,177 @@ +# 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, getdate + +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 | None + 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 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: + 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 Session Attendance Record", + {"reference_doctype": self.doctype, "reference_name": self.name, "user_email": email}, + ): + continue + frappe.get_doc( + { + "doctype": "Zoom Session Attendance Record", + "reference_doctype": self.doctype, + "reference_name": 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/report/consolidated_webinar_attendance/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_session_additional_param/__init__.py similarity index 100% rename from zoom_integration/zoom_integration/report/consolidated_webinar_attendance/__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_attendance_record/__init__.py b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..bbfd26a --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_session_attendance_record/test_zoom_session_attendance_record.py @@ -0,0 +1,64 @@ +# Copyright (c) 2025, Build With Hussain and Contributors +# See license.txt + +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.tests.zoom_fixtures import create_meeting_response + +MEETING_CONTROLLER = "zoom_integration.zoom_integration.doctype.zoom_meeting.zoom_meeting" + + +class IntegrationTestZoomSessionAttendanceRecord(IntegrationTestCase): + 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, + } + ) + + self.assertRaises(frappe.MandatoryError, record.insert) 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 77% 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 3d9a404..b8673e7 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 @@ -6,7 +6,8 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "webinar", + "reference_doctype", + "reference_name", "total_duration", "full_name", "column_break_bwzj", @@ -26,17 +27,29 @@ "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 }, { - "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": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Session", + "options": "reference_doctype", "reqd": 1, "search_index": 1 }, @@ -44,7 +57,7 @@ "fieldname": "registration", "fieldtype": "Link", "label": "Registration", - "options": "Zoom Webinar Registration", + "options": "Zoom Session Registration", "search_index": 1 }, { @@ -84,7 +97,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 70% 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..53d371a 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 from frappe.model.document import Document +from zoom_integration.utils import validate_session_reference -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,13 @@ class ZoomWebinarAttendanceRecord(Document): amended_from: DF.Link | None full_name: DF.Data | 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 # end: auto-generated types - pass + def validate(self): + validate_session_reference(self) 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_session_registration/test_zoom_session_registration.py b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py new file mode 100644 index 0000000..9cee4ea --- /dev/null +++ b/zoom_integration/zoom_integration/doctype/zoom_session_registration/test_zoom_session_registration.py @@ -0,0 +1,119 @@ +# Copyright (c) 2025, Build With Hussain and Contributors +# See license.txt + +from unittest.mock import patch + +import frappe +from frappe.tests import IntegrationTestCase + +from zoom_integration.tests.zoom_fixtures import ( + add_meeting_registrant_response, + add_webinar_registrant_response, + create_meeting_response, + create_webinar_response, + mock_zoom_post, +) + +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): + with patch(f"{MEETING_CONTROLLER}.create_zoom_session", return_value=create_meeting_response()): + return frappe.get_doc( + { + "doctype": "Zoom Meeting", + "title": "Reg Meeting", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + def _insert_webinar(self): + with mock_zoom_post(WEBINAR_CONTROLLER, 201, create_webinar_response()): + return frappe.get_doc( + { + "doctype": "Zoom Webinar", + "title": "Reg Webinar", + "date": "2026-08-01", + "start_time": "10:00:00", + "duration": 3600, + "timezone": "Asia/Calcutta", + } + ).insert() + + def _new_registration(self, session, **kwargs): + return frappe.get_doc( + { + "doctype": "Zoom Session Registration", + "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 mock_zoom_post(WEBINAR_CONTROLLER, 200, registrant) as mock_requests: + 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=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_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 85% 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 fe0d66c..ee55f7d 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 @@ -6,7 +6,8 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "webinar", + "reference_doctype", + "reference_name", "column_break_baez", "user", "synced_from_zoom", @@ -34,18 +35,29 @@ "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 }, { - "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": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Session", + "options": "reference_doctype", "reqd": 1, "search_index": 1 }, @@ -94,7 +106,7 @@ "fieldname": "additional_params", "fieldtype": "Table", "label": "Additional Params", - "options": "Zoom Webinar Additional Param" + "options": "Zoom Session Additional Param" }, { "fetch_from": "user.email", @@ -144,7 +156,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 66% 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 4c78eb1..53a1b50 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,13 @@ # 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 ZoomWebinarRegistration(Document): + +class ZoomSessionRegistration(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -14,22 +17,26 @@ class ZoomWebinarRegistration(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 join_url: DF.Data | None last_name: DF.Data | 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 # end: auto-generated types + def validate(self): + validate_session_reference(self) + def before_insert(self): if not (self.user or self.email): self.user = frappe.session.user @@ -39,7 +46,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 @@ -55,9 +62,9 @@ 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( - self.email, self.first_name, self.last_name, additional_params - ) + 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) self.join_url = registration.get("join_url") self.registrant_id = registration.get("registrant_id") 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..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 @@ -1,20 +1,103 @@ # 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, + mock_zoom_post, + 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 mock_zoom_post(CONTROLLER, 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() + + 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"}) + + 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() + + 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() - pass + 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.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", - ]; -} 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..27a68aa 100644 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json +++ b/zoom_integration/zoom_integration/doctype/zoom_webinar/zoom_webinar.json @@ -161,12 +161,12 @@ "index_web_pages_for_search": 1, "links": [ { - "link_doctype": "Zoom Webinar Registration", - "link_fieldname": "webinar" + "link_doctype": "Zoom Session Registration", + "link_fieldname": "reference_name" }, { - "link_doctype": "Zoom Webinar Attendance Record", - "link_fieldname": "webinar" + "link_doctype": "Zoom Session Attendance Record", + "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 893f657..2c32589 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 @@ -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)) @@ -113,8 +110,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 @@ -165,7 +162,10 @@ 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", + # {"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}" @@ -231,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] @@ -281,23 +278,32 @@ def sync_attendance(self): continue if frappe.db.exists( - "Zoom Webinar Attendance Record", - {"webinar": self.name, "user_email": user_email}, + "Zoom Session Attendance Record", + { + "reference_doctype": self.doctype, + "reference_name": self.name, + "user_email": user_email, + }, ): continue registration = frappe.db.get_value( - "Zoom Webinar Registration", - {"email": user_email}, + "Zoom Session Registration", + { + "reference_doctype": self.doctype, + "reference_name": self.name, + "email": user_email, + }, "name", ) try: doc = frappe.get_doc( { - "doctype": "Zoom Webinar Attendance Record", + "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"), @@ -375,19 +381,21 @@ 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, + "reference_doctype": self.doctype, + "reference_name": self.name, }, ): 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, + "reference_doctype": self.doctype, + "reference_name": self.name, "email": registrant.get("email"), "first_name": registrant.get("first_name"), "last_name": registrant.get("last_name"), 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_webinar_attendance_record/test_zoom_webinar_attendance_record.py deleted file mode 100644 index 8bf04cf..0000000 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_attendance_record/test_zoom_webinar_attendance_record.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2025, Build With Hussain and Contributors -# See license.txt - -# 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"] - - -class IntegrationTestZoomWebinarAttendanceRecord(IntegrationTestCase): - """ - Integration tests for ZoomWebinarAttendanceRecord. - Use this class for testing interactions between multiple components. - """ - - pass 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 deleted file mode 100644 index 8028602..0000000 --- a/zoom_integration/zoom_integration/doctype/zoom_webinar_registration/test_zoom_webinar_registration.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright (c) 2025, Build With Hussain and Contributors -# See license.txt - -# 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"] - - -class IntegrationTestZoomWebinarRegistration(IntegrationTestCase): - """ - Integration tests for ZoomWebinarRegistration. - Use this class for testing interactions between multiple components. - """ - - pass 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 d23c33d..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 Webinar 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], - ] 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" },