mirror of
https://github.com/frappe/lms.git
synced 2026-05-02 13:39:31 +03:00
Merge pull request #2122 from ColoredCow/feature/google-meet
feat: Google Meet integration for Live Classes
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user