Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
abb5664
feat: add resource-parameterized Zoom API helpers with mocked tests
harshtandiya Jul 24, 2026
be834b9
feat: add Zoom Meeting doctype with create, registrant, attendance sync
harshtandiya Jul 24, 2026
04f5eb8
feat: allow Zoom Webinar Registration to register against a Zoom Meeting
harshtandiya Jul 24, 2026
0dd8521
fix: timezone dropdown + hide days on duration for Zoom Meeting
harshtandiya Jul 24, 2026
0b7d608
style: apply ruff formatting
harshtandiya Jul 24, 2026
f6ad0d2
chore: stop tracking local planning docs
harshtandiya Jul 24, 2026
e4d59b1
feat: enhance Zoom Meeting doctype with new fields and permissions
harshtandiya Jul 24, 2026
6800ef8
refactor!: rename webinar doctypes to session
harshtandiya Jul 26, 2026
75ff21c
fix: name Zoom Meeting by its Zoom meeting ID
harshtandiya Jul 27, 2026
4c8c806
refactor!: complete the webinar-to-session rename
harshtandiya Jul 27, 2026
883d7ce
docs: describe meetings alongside webinars
harshtandiya Jul 27, 2026
25c2ee4
fix: rename Zoom Meetings created before ID-based naming
harshtandiya Jul 27, 2026
17fe156
refactor!: replace webinar/meeting field pairs with a dynamic reference
harshtandiya Jul 27, 2026
66bfe4b
test: stop depending on a live Zoom token and on buzz
harshtandiya Jul 27, 2026
a29353e
fix: only push to Zoom when session details actually changed
harshtandiya Jul 27, 2026
0bf5a86
fix: scope webinar attendance to its own registrations
harshtandiya Jul 27, 2026
c418ebc
fix: stop writing Zoom access tokens into request logs
harshtandiya Jul 27, 2026
733f4a1
refactor: share one Zoom timezone list between session forms
harshtandiya Jul 27, 2026
884aa50
refactor: build pagination URLs without a callback
harshtandiya Jul 27, 2026
a7cc4a3
style: format the shared timezone list and declare its global
harshtandiya Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"root": true,
"globals": {
"frappe": true,
"zoom_integration": true,
"Vue": true,
"SetVueGlobals": true,
"__": true,
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ jspm_packages/

# Aider AI Chat
.aider*

# Local planning docs
docs/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
97 changes: 97 additions & 0 deletions zoom_integration/api.py
Original file line number Diff line number Diff line change
@@ -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")
4 changes: 2 additions & 2 deletions zoom_integration/hooks.py
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion zoom_integration/patches.txt
Original file line number Diff line number Diff line change
@@ -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
# 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
27 changes: 27 additions & 0 deletions zoom_integration/patches/backfill_session_reference.py
Original file line number Diff line number Diff line change
@@ -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()
23 changes: 23 additions & 0 deletions zoom_integration/patches/rename_meetings_to_zoom_id.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 19 additions & 0 deletions zoom_integration/patches/rename_webinar_doctypes_to_session.py
Original file line number Diff line number Diff line change
@@ -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)
143 changes: 143 additions & 0 deletions zoom_integration/public/js/zoom_timezones.js
Original file line number Diff line number Diff line change
@@ -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",
];
Loading
Loading