Merge pull request #2122 from ColoredCow/feature/google-meet

feat: Google Meet integration for Live Classes
This commit is contained in:
Jannat Patel
2026-03-04 11:00:13 +05:30
committed by GitHub
20 changed files with 1381 additions and 59 deletions
+16
View File
@@ -36,7 +36,9 @@
"medium",
"confirmation_email_template",
"column_break_flwy",
"conferencing_provider",
"zoom_account",
"google_meet_account",
"notification_sent",
"section_break_jedp",
"video_link",
@@ -361,11 +363,25 @@
"label": "Certification"
},
{
"fieldname": "conferencing_provider",
"fieldtype": "Select",
"label": "Conferencing Provider",
"options": "\nZoom\nGoogle Meet"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
},
{
"depends_on": "eval:doc.conferencing_provider=='Google Meet'",
"fieldname": "google_meet_account",
"fieldtype": "Link",
"label": "Google Meet Account",
"options": "LMS Google Meet Settings"
},
{
"fieldname": "video_link",
"fieldtype": "Attach",
+69
View File
@@ -36,6 +36,7 @@ class LMSBatch(Document):
self.validate_duplicate_assessments()
self.validate_timetable()
self.validate_evaluation_end_date()
self.validate_conferencing_provider()
def on_update(self):
if self.has_value_changed("published") and self.published:
@@ -126,6 +127,31 @@ class LMSBatch(Document):
if schedule.date < self.start_date or schedule.date > self.end_date:
frappe.throw(_("Row #{0} Date cannot be outside the batch duration.").format(schedule.idx))
def validate_conferencing_provider(self):
if self.is_new() or not self.conferencing_provider:
return
if self.conferencing_provider == "Google Meet":
if not self.google_meet_account:
frappe.throw(_("Please select a Google Meet account for this batch."))
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", self.google_meet_account)
if not google_meet_settings.enabled:
frappe.throw(
_(
"The selected Google Meet account is disabled. Please enable it or select another account."
)
)
if not google_meet_settings.google_calendar:
frappe.throw(
_("The selected Google Meet account does not have a Google Calendar configured.")
)
elif self.conferencing_provider == "Zoom":
if not self.zoom_account:
frappe.throw(_("Please select a Zoom account for this batch."))
def on_payment_authorized(self, payment_status):
if payment_status in ["Authorized", "Completed"]:
update_payment_record("LMS Batch", self.name)
@@ -262,6 +288,49 @@ def create_live_class(
frappe.throw(_("Error creating live class. Please try again. {0}").format(response.text))
@frappe.whitelist()
def create_google_meet_live_class(
batch_name: str,
google_meet_account: str,
title: str,
duration: int,
date: str,
time: str,
timezone: str,
description: str = None,
):
frappe.only_for(["Moderator", "Batch Evaluator"])
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", google_meet_account)
if not google_meet_settings.enabled:
frappe.throw(_("Please enable the Google Meet account to use this feature."))
if not google_meet_settings.google_calendar:
frappe.throw(
_(
"The Google Meet account does not have a Google Calendar configured. Please set up a Google Calendar first."
)
)
class_details = frappe.get_doc(
{
"doctype": "LMS Live Class",
"title": title,
"host": frappe.session.user,
"date": date,
"time": time,
"duration": duration,
"timezone": timezone,
"description": description,
"batch_name": batch_name,
"conferencing_provider": "Google Meet",
"google_meet_account": google_meet_account,
}
)
class_details.save()
return class_details
def authenticate(zoom_account):
zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
if not zoom.enabled:
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Google Meet Settings", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,123 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:account_name",
"creation": "2026-02-04 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enabled",
"section_break_xfow",
"account_name",
"member",
"member_name",
"member_image",
"column_break_fxxg",
"google_calendar"
],
"fields": [
{
"default": "0",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "section_break_xfow",
"fieldtype": "Section Break"
},
{
"fieldname": "account_name",
"fieldtype": "Data",
"label": "Account Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"label": "Member Name"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
},
{
"fieldname": "column_break_fxxg",
"fieldtype": "Column Break"
},
{
"fieldname": "google_calendar",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Google Calendar",
"options": "Google Calendar",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-04 00:00:00.000000",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Google Meet Settings",
"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": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSGoogleMeetSettings(Document):
pass
@@ -0,0 +1,105 @@
# Copyright (c) 2026, Frappe and Contributors
# See license.txt
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestLMSGoogleMeetSettings(UnitTestCase):
"""
Unit tests for LMSGoogleMeetSettings.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSGoogleMeetSettings(IntegrationTestCase):
"""
Integration tests for LMSGoogleMeetSettings.
"""
def setUp(self):
self.cleanup_items = []
google_settings = frappe.get_doc("Google Settings")
self._original_google_settings = {
"enable": google_settings.enable,
"client_id": google_settings.client_id,
}
google_settings.enable = 1
google_settings.client_id = "test-client-id"
google_settings.client_secret = "test-client-secret"
google_settings.save(ignore_permissions=True)
def tearDown(self):
for item_type, item_name in reversed(self.cleanup_items):
if frappe.db.exists(item_type, item_name):
try:
frappe.delete_doc(item_type, item_name, force=True)
except Exception:
pass
if hasattr(self, "_original_google_settings"):
google_settings = frappe.get_doc("Google Settings")
google_settings.enable = self._original_google_settings["enable"]
google_settings.client_id = self._original_google_settings["client_id"]
google_settings.client_secret = ""
google_settings.save(ignore_permissions=True)
def _create_google_calendar(self, name="Test Google Calendar"):
if frappe.db.exists("Google Calendar", name):
return frappe.get_doc("Google Calendar", name)
calendar = frappe.get_doc(
{
"doctype": "Google Calendar",
"calendar_name": name,
"user": "Administrator",
"google_account": "test@gmail.com",
}
)
calendar.insert(ignore_permissions=True)
self.cleanup_items.append(("Google Calendar", calendar.name))
return calendar
def test_create_google_meet_settings_with_valid_data(self):
calendar = self._create_google_calendar()
settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": f"Test Meet Account {frappe.generate_hash(length=6)}",
"member": "Administrator",
"google_calendar": calendar.name,
"enabled": 1,
}
)
settings.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Google Meet Settings", settings.name))
self.assertTrue(frappe.db.exists("LMS Google Meet Settings", settings.name))
self.assertEqual(settings.enabled, 1)
self.assertEqual(settings.google_calendar, calendar.name)
def test_create_google_meet_settings_without_calendar_raises_error(self):
with self.assertRaises(frappe.exceptions.MandatoryError):
settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": f"Test No Calendar {frappe.generate_hash(length=6)}",
"member": "Administrator",
}
)
settings.insert(ignore_permissions=True)
def test_create_google_meet_settings_without_member_raises_error(self):
calendar = self._create_google_calendar()
with self.assertRaises(frappe.exceptions.MandatoryError):
settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": f"Test No Member {frappe.generate_hash(length=6)}",
"google_calendar": calendar.name,
}
)
settings.insert(ignore_permissions=True)
@@ -10,7 +10,9 @@
"field_order": [
"title",
"host",
"conferencing_provider",
"zoom_account",
"google_meet_account",
"batch_name",
"column_break_astv",
"date",
@@ -107,6 +109,7 @@
"read_only": 1
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "password",
"fieldtype": "Password",
"label": "Password"
@@ -125,6 +128,7 @@
},
{
"default": "No Recording",
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "auto_recording",
"fieldtype": "Select",
"label": "Auto Recording",
@@ -137,14 +141,30 @@
"options": "Event",
"read_only": 1
},
{
"fieldname": "conferencing_provider",
"fieldtype": "Select",
"label": "Conferencing Provider",
"options": "Zoom\nGoogle Meet"
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings",
"reqd": 1
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"mandatory_depends_on": "eval:doc.conferencing_provider=='Zoom'"
},
{
"fieldname": "google_meet_account",
"fieldtype": "Link",
"label": "Google Meet Account",
"options": "LMS Google Meet Settings",
"depends_on": "eval:doc.conferencing_provider=='Google Meet'",
"mandatory_depends_on": "eval:doc.conferencing_provider=='Google Meet'"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "meeting_id",
"fieldtype": "Data",
"label": "Meeting ID"
@@ -160,6 +180,7 @@
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.conferencing_provider=='Zoom'",
"fieldname": "uuid",
"fieldtype": "Data",
"label": "UUID"
+144 -19
View File
@@ -1,7 +1,6 @@
# Copyright (c) 2023, Frappe and contributors
# For license information, please see license.txt
import json
from datetime import timedelta
import frappe
@@ -15,6 +14,60 @@ from lms.lms.doctype.lms_batch.lms_batch import authenticate
class LMSLiveClass(Document):
def after_insert(self):
if self.conferencing_provider == "Google Meet":
self._create_google_meet_event()
else:
self._create_calendar_event()
def on_update(self):
if not self.event:
return
if (
not self.has_value_changed("date")
and not self.has_value_changed("time")
and not self.has_value_changed("duration")
and not self.has_value_changed("title")
):
return
self._update_linked_event()
def on_trash(self):
if self.event and frappe.db.exists("Event", self.event):
event_name = self.event
frappe.db.set_value("LMS Live Class", self.name, "event", "")
frappe.delete_doc("Event", event_name, ignore_permissions=True)
def _get_participants(self):
participants = frappe.get_all("LMS Batch Enrollment", {"batch": self.batch_name}, pluck="member")
instructors = frappe.get_all(
"Course Instructor", {"parenttype": "LMS Batch", "parent": self.batch_name}, pluck="instructor"
)
participants.append(frappe.session.user)
participants.extend(instructors)
return list(set(participants))
def _build_event_description(self):
description = f"A Live Class has been scheduled on {format_date(self.date, 'medium')} at {format_time(self.time, 'hh:mm a')}."
if self.join_url:
description += f" Click on this link to join. {self.join_url}."
if self.description:
description += f" {self.description}"
return description
def _update_linked_event(self):
event = frappe.get_doc("Event", self.event)
start = f"{self.date} {self.time}"
event.subject = f"Live Class on {self.title}"
event.starts_on = start
event.ends_on = get_datetime(start) + timedelta(minutes=cint(self.duration))
event.description = self._build_event_description()
event.save(ignore_permissions=True)
def _create_calendar_event(self):
calendar = frappe.db.get_value("Google Calendar", {"user": frappe.session.user, "enable": 1}, "name")
if calendar:
@@ -22,6 +75,83 @@ class LMSLiveClass(Document):
self.add_event_participants(event, calendar)
frappe.db.set_value(self.doctype, self.name, "event", event.name)
def _create_google_meet_event(self):
google_meet_settings = frappe.get_doc("LMS Google Meet Settings", self.google_meet_account)
calendar = google_meet_settings.google_calendar
if not calendar:
frappe.throw(_("Google Calendar is not configured for this Google Meet account."))
event = self.create_event()
event.reload()
event.update(
{
"sync_with_google_calendar": 1,
"add_video_conferencing": 1,
"google_calendar": calendar,
"description": self._build_event_description(),
}
)
event.save()
event.reload()
meet_link = event.google_meet_link
frappe.db.set_value(
self.doctype,
self.name,
{
"event": event.name,
"join_url": meet_link or "",
"start_url": meet_link or "",
},
)
if not meet_link:
frappe.msgprint(
_(
"The Meet link is not yet available. It will be generated once Google Calendar syncs the event. Please refresh the page after a few moments."
),
indicator="orange",
alert=True,
)
self._add_google_meet_participants(event, calendar)
def _add_google_meet_participants(self, event, calendar):
from frappe.integrations.doctype.google_calendar.google_calendar import get_google_calendar_object
attendees = []
for participant in self._get_participants():
email = frappe.db.get_value("User", participant, "email")
if not email:
continue
attendees.append({"email": email})
if not attendees:
return
try:
google_calendar_api, account = get_google_calendar_object(calendar)
google_calendar_api.events().patch(
calendarId=event.google_calendar_id,
eventId=event.google_calendar_event_id,
body={
"attendees": attendees,
"guestsCanSeeOtherGuests": False,
},
sendUpdates="all",
).execute()
except Exception:
frappe.log_error(title=_("Google Meet - Failed to add participants to calendar event"))
frappe.msgprint(
_(
"Live class was created but calendar invites could not be sent to participants. You may need to share the Meet link manually."
),
indicator="orange",
alert=True,
)
def create_event(self):
start = f"{self.date} {self.time}"
@@ -37,17 +167,8 @@ class LMSLiveClass(Document):
event.save()
return event
def add_event_participants(self, event, calendar):
participants = frappe.get_all("LMS Batch Enrollment", {"batch": self.batch_name}, pluck="member")
instructors = frappe.get_all(
"Course Instructor", {"parenttype": "LMS Batch", "parent": self.batch_name}, pluck="instructor"
)
participants.append(frappe.session.user)
participants.extend(instructors)
participants = list(set(participants))
for participant in participants:
def add_event_participants(self, event, calendar, add_video_conferencing=False):
for participant in self._get_participants():
frappe.get_doc(
{
"doctype": "Event Participants",
@@ -61,14 +182,17 @@ class LMSLiveClass(Document):
).save()
event.reload()
event.update(
{
"sync_with_google_calendar": 1,
"google_calendar": calendar,
"description": f"A Live Class has been scheduled on {format_date(self.date, 'medium')} at {format_time(self.time, 'hh:mm a')}. Click on this link to join. {self.join_url}. {self.description}",
}
)
update_data = {
"sync_with_google_calendar": 1,
"google_calendar": calendar,
"description": self._build_event_description(),
}
if add_video_conferencing:
update_data["add_video_conferencing"] = 1
event.update(update_data)
event.save()
@@ -118,6 +242,7 @@ def update_attendance():
{
"uuid": ["is", "set"],
"attendees": ["is", "not set"],
"conferencing_provider": ["!=", "Google Meet"],
},
["name", "uuid", "zoom_account"],
)
@@ -1,9 +1,357 @@
# Copyright (c) 2023, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import UnitTestCase
from unittest.mock import MagicMock, patch
import frappe
from frappe.utils import add_days, nowdate
from lms.lms.test_helpers import BaseTestUtils
GOOGLE_CALENDAR_MODULE = "frappe.integrations.doctype.google_calendar.google_calendar"
class TestLMSLiveClass(UnitTestCase):
pass
class TestLMSLiveClass(BaseTestUtils):
"""Tests for LMS Live Class including Google Meet integration."""
def setUp(self):
super().setUp()
# Mock get_google_calendar_object to prevent Frappe's Event hooks
# from calling the real Google Calendar API (no OAuth tokens in CI).
mock_api = MagicMock()
mock_api.events.return_value.insert.return_value.execute.return_value = {
"id": "test-gcal-event-id",
"hangoutLink": "https://meet.google.com/test-link",
"status": "confirmed",
}
mock_api.events.return_value.update.return_value.execute.return_value = {
"id": "test-gcal-event-id",
"hangoutLink": "https://meet.google.com/test-link",
}
mock_api.events.return_value.patch.return_value.execute.return_value = {}
mock_api.events.return_value.delete.return_value.execute.return_value = None
self._gcal_patcher = patch(
f"{GOOGLE_CALENDAR_MODULE}.get_google_calendar_object",
return_value=(mock_api, MagicMock()),
)
self._gcal_patcher.start()
self._setup_course_flow()
self._setup_batch_flow()
self._setup_google_meet()
def tearDown(self):
super().tearDown()
self._gcal_patcher.stop()
if hasattr(self, "_original_google_settings"):
google_settings = frappe.get_doc("Google Settings")
google_settings.enable = self._original_google_settings["enable"]
google_settings.client_id = self._original_google_settings["client_id"]
google_settings.client_secret = ""
google_settings.save(ignore_permissions=True)
def _setup_google_meet(self):
"""Create Google Calendar and Google Meet Settings for testing."""
google_settings = frappe.get_doc("Google Settings")
self._original_google_settings = {
"enable": google_settings.enable,
"client_id": google_settings.client_id,
}
google_settings.enable = 1
google_settings.client_id = "test-client-id"
google_settings.client_secret = "test-client-secret"
google_settings.save(ignore_permissions=True)
calendar_name = f"Test GCal {frappe.generate_hash(length=6)}"
if not frappe.db.exists("Google Calendar", calendar_name):
calendar = frappe.get_doc(
{
"doctype": "Google Calendar",
"calendar_name": calendar_name,
"user": "Administrator",
"google_account": "test@gmail.com",
}
)
calendar.insert(ignore_permissions=True)
self.cleanup_items.append(("Google Calendar", calendar.name))
self.google_calendar = calendar
else:
self.google_calendar = frappe.get_doc("Google Calendar", calendar_name)
account_name = f"Test Meet {frappe.generate_hash(length=6)}"
self.google_meet_settings = frappe.get_doc(
{
"doctype": "LMS Google Meet Settings",
"account_name": account_name,
"member": "Administrator",
"google_calendar": self.google_calendar.name,
"enabled": 1,
}
)
self.google_meet_settings.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Google Meet Settings", self.google_meet_settings.name))
def _create_live_class(self, provider="Google Meet", **kwargs):
"""Helper to create a live class for testing."""
data = {
"doctype": "LMS Live Class",
"title": f"Test Class {frappe.generate_hash(length=6)}",
"host": "Administrator",
"date": add_days(nowdate(), 1),
"time": "10:00:00",
"duration": 60,
"timezone": "Asia/Kolkata",
"batch_name": self.batch.name,
"conferencing_provider": provider,
}
if provider == "Google Meet":
data["google_meet_account"] = self.google_meet_settings.name
data.update(kwargs)
live_class = frappe.get_doc(data)
live_class.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Live Class", live_class.name))
return live_class
# --- T9: Unit tests for Google Meet live class creation ---
def test_google_meet_live_class_creates_event(self):
"""Creating a Google Meet live class should create a linked Frappe Event."""
live_class = self._create_live_class()
live_class.reload()
self.assertTrue(live_class.event)
self.assertTrue(frappe.db.exists("Event", live_class.event))
event = frappe.get_doc("Event", live_class.event)
self.assertEqual(event.sync_with_google_calendar, 1)
self.assertEqual(event.add_video_conferencing, 1)
self.assertEqual(event.google_calendar, self.google_calendar.name)
def test_google_meet_live_class_event_has_correct_times(self):
"""The linked Event should have correct start and end times."""
live_class = self._create_live_class()
live_class.reload()
event = frappe.get_doc("Event", live_class.event)
self.assertIn("10:00", str(event.starts_on))
self.assertIn("11:00", str(event.ends_on))
def test_google_meet_disabled_account_raises_error(self):
"""Creating a live class with a disabled Google Meet account should raise an error."""
from lms.lms.doctype.lms_batch.lms_batch import create_google_meet_live_class
self.google_meet_settings.enabled = 0
self.google_meet_settings.save()
with self.assertRaises(frappe.exceptions.ValidationError):
create_google_meet_live_class(
batch_name=self.batch.name,
google_meet_account=self.google_meet_settings.name,
title="Test Disabled",
duration=30,
date=add_days(nowdate(), 1),
time="10:00:00",
timezone="Asia/Kolkata",
)
self.google_meet_settings.enabled = 1
self.google_meet_settings.save()
def test_google_meet_missing_calendar_raises_error(self):
"""Creating a live class with a Google Meet account without a calendar should raise an error."""
from lms.lms.doctype.lms_batch.lms_batch import create_google_meet_live_class
old_calendar = self.google_meet_settings.google_calendar
self.google_meet_settings.google_calendar = ""
self.google_meet_settings.flags.ignore_mandatory = True
self.google_meet_settings.save()
with self.assertRaises(frappe.exceptions.ValidationError):
create_google_meet_live_class(
batch_name=self.batch.name,
google_meet_account=self.google_meet_settings.name,
title="Test No Calendar",
duration=30,
date=add_days(nowdate(), 1),
time="10:00:00",
timezone="Asia/Kolkata",
)
self.google_meet_settings.google_calendar = old_calendar
self.google_meet_settings.save()
def test_zoom_live_class_not_affected(self):
"""Creating a Zoom-style live class should still work (regression test)."""
live_class = frappe.get_doc(
{
"doctype": "LMS Live Class",
"title": f"Zoom Class {frappe.generate_hash(length=6)}",
"host": "Administrator",
"date": add_days(nowdate(), 1),
"time": "14:00:00",
"duration": 45,
"timezone": "Asia/Kolkata",
"batch_name": self.batch.name,
"conferencing_provider": "Zoom",
"join_url": "https://zoom.us/j/123456",
"start_url": "https://zoom.us/s/123456",
}
)
live_class.insert(ignore_permissions=True)
self.cleanup_items.append(("LMS Live Class", live_class.name))
self.assertTrue(frappe.db.exists("LMS Live Class", live_class.name))
self.assertEqual(live_class.join_url, "https://zoom.us/j/123456")
# --- T10: Unit tests for event update and cancellation sync ---
def test_update_live_class_date_updates_event(self):
"""Rescheduling a live class should update the linked Event."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
new_date = add_days(nowdate(), 5)
live_class.date = new_date
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn(str(new_date), str(event.starts_on))
def test_update_live_class_time_updates_event(self):
"""Changing the time of a live class should update the linked Event."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
live_class.time = "15:00:00"
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn("15:00", str(event.starts_on))
def test_update_live_class_title_updates_event(self):
"""Changing the title of a live class should update the linked Event subject."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
live_class.title = "Updated Title"
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn("Updated Title", event.subject)
def test_update_live_class_duration_updates_event(self):
"""Changing the duration should update the linked Event's end time."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
live_class.duration = 120
live_class.save(ignore_permissions=True)
event = frappe.get_doc("Event", event_name)
self.assertIn("12:00", str(event.ends_on))
def test_delete_live_class_deletes_event(self):
"""Deleting a live class should delete the linked Frappe Event."""
live_class = self._create_live_class()
live_class.reload()
event_name = live_class.event
self.assertTrue(frappe.db.exists("Event", event_name))
# Remove from cleanup since we're deleting manually
self.cleanup_items = [
(t, n) for t, n in self.cleanup_items if not (t == "LMS Live Class" and n == live_class.name)
]
frappe.delete_doc("LMS Live Class", live_class.name, force=True)
self.assertFalse(frappe.db.exists("Event", event_name))
def test_delete_zoom_live_class_with_event(self):
"""Deleting a Zoom live class with a linked event should also delete the event (regression)."""
live_class = self._create_live_class(provider="Zoom")
# Zoom classes created via direct insert won't have an event from calendar flow,
# but if one is set manually, on_trash should clean it up
event = frappe.get_doc(
{
"doctype": "Event",
"subject": "Test Zoom Event",
"event_type": "Public",
"starts_on": f"{add_days(nowdate(), 1)} 14:00:00",
"ends_on": f"{add_days(nowdate(), 1)} 15:00:00",
}
)
event.insert(ignore_permissions=True)
self.cleanup_items.append(("Event", event.name))
frappe.db.set_value("LMS Live Class", live_class.name, "event", event.name)
live_class.reload()
self.cleanup_items = [
(t, n) for t, n in self.cleanup_items if not (t == "LMS Live Class" and n == live_class.name)
]
# Remove event from cleanup too since on_trash will delete it
self.cleanup_items = [(t, n) for t, n in self.cleanup_items if not (t == "Event" and n == event.name)]
frappe.delete_doc("LMS Live Class", live_class.name, force=True)
self.assertFalse(frappe.db.exists("Event", event.name))
# --- T11: Integration tests for end-to-end workflow ---
def test_batch_validation_google_meet_without_account(self):
"""Saving a batch with Google Meet provider but no account should fail."""
self.batch.conferencing_provider = "Google Meet"
self.batch.google_meet_account = ""
with self.assertRaises(frappe.exceptions.ValidationError):
self.batch.save()
# Reset
self.batch.reload()
def test_batch_validation_google_meet_with_valid_account(self):
"""Saving a batch with Google Meet and a valid account should succeed."""
self.batch.conferencing_provider = "Google Meet"
self.batch.google_meet_account = self.google_meet_settings.name
self.batch.save()
self.batch.reload()
self.assertEqual(self.batch.conferencing_provider, "Google Meet")
self.assertEqual(self.batch.google_meet_account, self.google_meet_settings.name)
# Reset
self.batch.conferencing_provider = ""
self.batch.google_meet_account = ""
self.batch.save()
def test_batch_validation_zoom_without_account(self):
"""Saving a batch with Zoom provider but no account should fail."""
self.batch.conferencing_provider = "Zoom"
self.batch.zoom_account = ""
with self.assertRaises(frappe.exceptions.ValidationError):
self.batch.save()
# Reset
self.batch.reload()
def test_update_attendance_skips_google_meet(self):
"""The Zoom attendance scheduler should skip Google Meet classes."""
live_class = self._create_live_class()
live_class.reload()
# The update_attendance function uses conferencing_provider != "Google Meet"
# to filter out Google Meet classes from Zoom attendance processing.
# Verify a Google Meet class is excluded by that filter.
past_classes = frappe.get_all(
"LMS Live Class",
{
"conferencing_provider": ["!=", "Google Meet"],
},
pluck="name",
)
self.assertNotIn(live_class.name, past_classes)
+6 -5
View File
@@ -19,7 +19,6 @@ from frappe.utils import (
get_datetime,
get_frappe_version,
get_fullname,
get_time_str,
getdate,
nowtime,
pretty_date,
@@ -1128,6 +1127,8 @@ def get_batch_details(batch: str):
"timezone",
"category",
"zoom_account",
"conferencing_provider",
"google_meet_account",
],
as_dict=True,
)
@@ -1138,7 +1139,7 @@ def get_batch_details(batch: str):
if (
not batch_details.accept_enrollments
and batch_details.start_date == getdate()
and get_time_str(batch_details.start_time) > nowtime()
and str(batch_details.start_time) > nowtime()
):
batch_details.accept_enrollments = True
@@ -1174,7 +1175,7 @@ def categorize_batches(batches: list) -> dict:
private.append(batch)
elif getdate(batch.start_date) < getdate():
archived.append(batch)
elif getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) < nowtime():
elif getdate(batch.start_date) == getdate() and str(batch.start_time) < nowtime():
archived.append(batch)
else:
upcoming.append(batch)
@@ -2156,14 +2157,14 @@ def filter_batches_based_on_start_time(batches: list, filters: dict) -> list:
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) < nowtime()
if getdate(batch.start_date) == getdate() and str(batch.start_time) < nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
elif batchType == "archived":
batches_to_remove = [
batch
for batch in batches
if getdate(batch.start_date) == getdate() and get_time_str(batch.start_time) >= nowtime()
if getdate(batch.start_date) == getdate() and str(batch.start_time) >= nowtime()
]
batches = [batch for batch in batches if batch not in batches_to_remove]
return batches
+2 -1
View File
@@ -119,4 +119,5 @@ lms.patches.v2_0.open_to_work
lms.patches.v2_0.share_enrollment
lms.patches.v2_0.give_user_list_permission #11-02-2026
lms.patches.v2_0.rename_badge_assignment_event
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.enable_allow_job_posting
lms.patches.v2_0.set_conferencing_provider_for_zoom
@@ -0,0 +1,16 @@
import frappe
def execute():
frappe.db.set_value(
"LMS Batch",
{"zoom_account": ["is", "set"]},
"conferencing_provider",
"Zoom",
)
frappe.db.set_value(
"LMS Live Class",
{"zoom_account": ["is", "set"]},
"conferencing_provider",
"Zoom",
)