feat: multiple zoom accounts

This commit is contained in:
Jannat Patel
2025-05-26 18:08:17 +05:30
parent 69107d4441
commit d5b882d3f8
19 changed files with 351 additions and 63 deletions

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full" class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
style="min-height: 150px" style="min-height: 150px"
> >
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9"> <div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">

View File

@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4" class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
> >
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<div class="flex flex-col space-y-2 flex-1"> <div class="flex flex-col space-y-2 flex-1">

View File

@@ -1,5 +1,15 @@
<template> <template>
<div class="flex items-center justify-between mb-5"> <div
v-if="hasPermission() && !props.zoomAccount"
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
>
<AlertCircle class="size-4 stroke-1.5" />
<span>
{{ __('Please add a zoom account to the batch to create live classes.') }}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9"> <div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }} {{ __('Live Class') }}
</div> </div>
@@ -12,10 +22,10 @@
</span> </span>
</Button> </Button>
</div> </div>
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5"> <div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5 mt-5">
<div <div
v-for="cls in liveClasses.data" v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3" class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
> >
<div class="font-semibold text-ink-gray-9 text-lg mb-1"> <div class="font-semibold text-ink-gray-9 text-lg mb-1">
{{ cls.title }} {{ cls.title }}
@@ -44,7 +54,8 @@
v-if="user.data?.is_moderator || user.data?.is_evaluator" v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url" :href="cls.start_url"
target="_blank" target="_blank"
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded" class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
:class="cls.join_url ? 'w-full' : 'w-1/2'"
> >
<Monitor class="h-4 w-4 stroke-1.5" /> <Monitor class="h-4 w-4 stroke-1.5" />
{{ __('Start') }} {{ __('Start') }}
@@ -67,21 +78,30 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else class="text-sm italic text-ink-gray-5"> <div v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }} {{ __('No live classes scheduled') }}
</div> </div>
<LiveClassModal <LiveClassModal
:batch="props.batch" :batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal" v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses" v-model:reloadLiveClasses="liveClasses"
/> />
</template> </template>
<script setup> <script setup>
import { createListResource, Button } from 'frappe-ui' import { createListResource, Button } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next' import {
import { inject } from 'vue' Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue' import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import { ref } from 'vue'
import { formatTime } from '@/utils/' import { formatTime } from '@/utils/'
const user = inject('$user') const user = inject('$user')
@@ -94,6 +114,7 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
zoomAccount: String,
}) })
const liveClasses = createListResource({ const liveClasses = createListResource({
@@ -120,6 +141,11 @@ const openLiveClassModal = () => {
const canCreateClass = () => { const canCreateClass = () => {
if (readOnlyMode) return false if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
return user.data?.is_moderator || user.data?.is_evaluator return user.data?.is_moderator || user.data?.is_evaluator
} }
</script> </script>

View File

@@ -8,7 +8,7 @@
{ {
label: 'Submit', label: 'Submit',
variant: 'solid', variant: 'solid',
onClick: (close) => submitLiveClass(close), onClick: ({ close }) => submitLiveClass(close),
}, },
], ],
}" }"
@@ -107,7 +107,11 @@ const dayjs = inject('$dayjs')
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: String, type: String,
default: null, required: true,
},
zoomAccount: {
type: String,
required: true,
}, },
}) })
@@ -159,6 +163,7 @@ const createLiveClass = createResource({
return { return {
doctype: 'LMS Live Class', doctype: 'LMS Live Class',
batch_name: values.batch, batch_name: values.batch,
zoom_account: props.zoomAccount,
...values, ...values,
} }
}, },
@@ -167,39 +172,11 @@ const createLiveClass = createResource({
const submitLiveClass = (close) => { const submitLiveClass = (close) => {
return createLiveClass.submit(liveClass, { return createLiveClass.submit(liveClass, {
validate() { validate() {
if (!liveClass.title) { validateFormFields()
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
}, },
onSuccess() { onSuccess() {
liveClasses.value.reload() liveClasses.value.reload()
refreshForm()
close() close()
}, },
onError(err) { onError(err) {
@@ -208,6 +185,39 @@ const submitLiveClass = (close) => {
}) })
} }
const validateFormFields = () => {
if (!liveClass.title) {
return __('Please enter a title.')
}
if (!liveClass.date) {
return __('Please select a date.')
}
if (!liveClass.time) {
return __('Please select a time.')
}
if (!liveClass.timezone) {
return __('Please select a timezone.')
}
if (!valideTime()) {
return __('Please enter a valid time in the format HH:mm.')
}
const liveClassDateTime = dayjs(`${liveClass.date}T${liveClass.time}`).tz(
liveClass.timezone,
true
)
if (
liveClassDateTime.isSameOrBefore(
dayjs().tz(liveClass.timezone, false),
'minute'
)
) {
return __('Please select a future date and time.')
}
if (!liveClass.duration) {
return __('Please select a duration.')
}
}
const valideTime = () => { const valideTime = () => {
let time = liveClass.time.split(':') let time = liveClass.time.split(':')
if (time.length != 2) { if (time.length != 2) {
@@ -221,4 +231,14 @@ const valideTime = () => {
} }
return true return true
} }
const refreshForm = () => {
liveClass.title = ''
liveClass.description = ''
liveClass.date = ''
liveClass.time = ''
liveClass.duration = ''
liveClass.timezone = getUserTimezone()
liveClass.auto_recording = 'No Recording'
}
</script> </script>

View File

@@ -70,7 +70,10 @@
<BatchStudents :batch="batch" /> <BatchStudents :batch="batch" />
</div> </div>
<div v-else-if="tab.label == 'Classes'"> <div v-else-if="tab.label == 'Classes'">
<LiveClass :batch="batch.data.name" /> <LiveClass
:batch="batch.data.name"
:zoomAccount="batch.data.zoom_account"
/>
</div> </div>
<div v-else-if="tab.label == 'Assessments'"> <div v-else-if="tab.label == 'Assessments'">
<Assessments :batch="batch.data.name" /> <Assessments :batch="batch.data.name" />

View File

@@ -159,6 +159,11 @@
} }
" "
/> />
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
/>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<FormControl <FormControl
@@ -327,6 +332,7 @@ const batch = reactive({
paid_batch: false, paid_batch: false,
currency: '', currency: '',
amount: 0, amount: 0,
zoom_account: '',
}) })
const instructors = ref([]) const instructors = ref([])

View File

@@ -26,6 +26,7 @@
"description", "description",
"column_break_hlqw", "column_break_hlqw",
"instructors", "instructors",
"zoom_account",
"section_break_rgfj", "section_break_rgfj",
"medium", "medium",
"category", "category",
@@ -354,6 +355,12 @@
{ {
"fieldname": "section_break_cssv", "fieldname": "section_break_cssv",
"fieldtype": "Section Break" "fieldtype": "Section Break"
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -372,7 +379,7 @@
"link_fieldname": "batch_name" "link_fieldname": "batch_name"
} }
], ],
"modified": "2025-05-21 13:30:28.904260", "modified": "2025-05-26 15:30:55.083507",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Batch", "name": "LMS Batch",

View File

@@ -146,7 +146,15 @@ class LMSBatch(Document):
@frappe.whitelist() @frappe.whitelist()
def create_live_class( def create_live_class(
batch_name, title, duration, date, time, timezone, auto_recording, description=None batch_name,
zoom_account,
title,
duration,
date,
time,
timezone,
auto_recording,
description=None,
): ):
frappe.only_for("Moderator") frappe.only_for("Moderator")
payload = { payload = {
@@ -161,7 +169,7 @@ def create_live_class(
"timezone": timezone, "timezone": timezone,
} }
headers = { headers = {
"Authorization": "Bearer " + authenticate(), "Authorization": "Bearer " + authenticate(zoom_account),
"content-type": "application/json", "content-type": "application/json",
} }
response = requests.post( response = requests.post(
@@ -183,6 +191,7 @@ def create_live_class(
"password": data.get("password"), "password": data.get("password"),
"description": description, "description": description,
"auto_recording": auto_recording, "auto_recording": auto_recording,
"zoom_account": zoom_account,
} }
) )
class_details = frappe.get_doc(payload) class_details = frappe.get_doc(payload)
@@ -194,10 +203,10 @@ def create_live_class(
) )
def authenticate(): def authenticate(zoom_account):
zoom = frappe.get_single("Zoom Settings") zoom = frappe.get_doc("LMS Zoom Settings", zoom_account)
if not zoom.enable: if not zoom.enabled:
frappe.throw(_("Please enable Zoom Settings to use this feature.")) frappe.throw(_("Please enable the zoom account to use this feature."))
authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}" authenticate_url = f"https://zoom.us/oauth/token?grant_type=account_credentials&account_id={zoom.account_id}"

View File

@@ -9,21 +9,22 @@
"field_order": [ "field_order": [
"title", "title",
"host", "host",
"zoom_account",
"batch_name", "batch_name",
"event",
"column_break_astv", "column_break_astv",
"description",
"section_break_glxh",
"date", "date",
"duration",
"column_break_spvt",
"time", "time",
"duration",
"timezone", "timezone",
"section_break_yrpq", "section_break_glxh",
"description",
"column_break_spvt",
"event",
"password", "password",
"auto_recording",
"section_break_yrpq",
"start_url", "start_url",
"column_break_yokr", "column_break_yokr",
"auto_recording",
"join_url" "join_url"
], ],
"fields": [ "fields": [
@@ -73,8 +74,7 @@
}, },
{ {
"fieldname": "section_break_glxh", "fieldname": "section_break_glxh",
"fieldtype": "Section Break", "fieldtype": "Section Break"
"label": "Date and Time"
}, },
{ {
"fieldname": "column_break_spvt", "fieldname": "column_break_spvt",
@@ -130,13 +130,21 @@
"label": "Event", "label": "Event",
"options": "Event", "options": "Event",
"read_only": 1 "read_only": 1
},
{
"fieldname": "zoom_account",
"fieldtype": "Link",
"label": "Zoom Account",
"options": "LMS Zoom Settings",
"reqd": 1
} }
], ],
"grid_page_length": 50,
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2024-11-11 18:59:26.396111", "modified": "2025-05-26 13:23:38.209187",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Live Class", "name": "LMS Live Class",
"owner": "Administrator", "owner": "Administrator",
@@ -175,10 +183,11 @@
"share": 1 "share": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "title", "title_field": "title",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Zoom Settings", {
// refresh(frm) {
// },
// });

View File

@@ -0,0 +1,103 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:account_name",
"creation": "2025-05-26 13:04:18.285735",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enabled",
"section_break_xfow",
"account_name",
"member",
"member_name",
"column_break_fxxg",
"account_id",
"client_id",
"client_secret"
],
"fields": [
{
"default": "0",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "account_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Account ID",
"reqd": 1
},
{
"fieldname": "client_id",
"fieldtype": "Data",
"label": "Client ID",
"reqd": 1
},
{
"fieldname": "client_secret",
"fieldtype": "Password",
"label": "Client Secret",
"reqd": 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"
},
{
"fieldname": "section_break_xfow",
"fieldtype": "Section Break"
},
{
"fieldname": "account_name",
"fieldtype": "Data",
"label": "Account Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "column_break_fxxg",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-26 13:21:38.227043",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Zoom 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
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class LMSZoomSettings(Document):
pass

View File

@@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# 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 UnitTestLMSZoomSettings(UnitTestCase):
"""
Unit tests for LMSZoomSettings.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSZoomSettings(IntegrationTestCase):
"""
Integration tests for LMSZoomSettings.
Use this class for testing interactions between multiple components.
"""
pass

View File

@@ -1391,6 +1391,7 @@ def get_batch_details(batch):
"certification", "certification",
"timezone", "timezone",
"category", "category",
"zoom_account",
], ],
as_dict=True, as_dict=True,
) )

View File

@@ -103,4 +103,7 @@ lms.patches.v2_0.delete_old_enrollment_doctypes
lms.patches.v2_0.delete_unused_custom_fields lms.patches.v2_0.delete_unused_custom_fields
lms.patches.v2_0.update_certificate_request_status lms.patches.v2_0.update_certificate_request_status
lms.patches.v2_0.update_job_city_and_country lms.patches.v2_0.update_job_city_and_country
lms.patches.v2_0.update_course_evaluator_data lms.patches.v2_0.update_course_evaluator_data
lms.patches.v2_0.move_zoom_settings #20-05-2025
lms.patches.v2_0.link_zoom_account_to_live_class
lms.patches.v2_0.link_zoom_account_to_batch

View File

@@ -0,0 +1,11 @@
import frappe
def execute():
live_classes = frappe.get_all("LMS Live Class", ["name", "batch_name"])
zoom_account = frappe.get_all("LMS Zoom Settings", pluck="name")
zoom_account = zoom_account[0] if zoom_account else None
if zoom_account:
for live_class in live_classes:
frappe.db.set_value("LMS Batch", live_class.batch_name, "zoom_account", zoom_account)

View File

@@ -0,0 +1,16 @@
import frappe
def execute():
live_classes = frappe.get_all("LMS Live Class", pluck="name")
zoom_account = frappe.get_all("LMS Zoom Settings", pluck="name")
zoom_account = zoom_account[0] if zoom_account else None
if zoom_account:
for live_class in live_classes:
frappe.db.set_value(
"LMS Live Class",
live_class,
"zoom_account",
zoom_account,
)

View File

@@ -0,0 +1,27 @@
import frappe
def execute():
create_settings()
def create_settings():
current_settings = frappe.get_single("Zoom Settings")
member = current_settings.owner
member_name = frappe.get_value("User", member, "full_name")
if not frappe.db.exists(
"LMS Zoom Settings",
{
"account_name": member_name,
},
):
new_settings = frappe.new_doc("LMS Zoom Settings")
new_settings.enabled = current_settings.enable
new_settings.account_name = member_name
new_settings.member = member
new_settings.member_name = member_name
new_settings.account_id = current_settings.account_id
new_settings.client_id = current_settings.client_id
new_settings.client_secret = current_settings.client_secret
new_settings.insert()