Merge pull request #1570 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2025-06-10 15:51:20 +05:30
committed by GitHub
101 changed files with 13763 additions and 5725 deletions
+180
View File
@@ -0,0 +1,180 @@
describe("Batch Creation", () => {
it("creates a new batch", () => {
cy.login();
cy.wait(500);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Open Settings
cy.get("span").contains("Learning").click();
cy.get("span").contains("Settings").click();
// Add a new member
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Members$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const dateNow = Date.now();
const randomEmail = `testuser_${dateNow}@example.com`;
const randomName = `Test User ${dateNow}`;
cy.get("input[placeholder='Email']").type(randomEmail);
cy.get("input[placeholder='First Name']").type(randomName);
cy.get("button").contains("Add").click();
// Add evaluator
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("span")
.contains(/^Evaluators$/)
.click();
cy.get('[id^="headlessui-dialog-panel-v-"]')
.find("button")
.contains("New")
.click();
const randomEvaluator = `evaluator${dateNow}@example.com`;
cy.get("input[placeholder='Email']").type(randomEvaluator);
cy.get("button").contains("Add").click();
cy.get("div").contains(randomEvaluator).should("be.visible").click();
cy.visit("/lms/batches");
cy.closeOnboardingModal();
// Create a batch
cy.get("button").contains("New").click();
cy.wait(500);
cy.url().should("include", "/batches/new/edit");
cy.get("label").contains("Title").type("Test Batch");
cy.get("label").contains("Start Date").type("2030-10-01");
cy.get("label").contains("End Date").type("2030-10-31");
cy.get("label").contains("Start Time").type("10:00");
cy.get("label").contains("End Time").type("11:00");
cy.get("label").contains("Timezone").type("IST");
cy.get("label").contains("Seat Count").type("10");
cy.get("label").contains("Published").click();
cy.get("label")
.contains("Short Description")
.type("Test Batch Short Description to test the UI");
cy.get("div[contenteditable=true").invoke(
"text",
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
);
/* Instructor */
cy.get("label")
.contains("Instructors")
.parent()
.within(() => {
cy.get("input").click().type("evaluator");
cy.get("input")
.invoke("attr", "aria-controls")
.as("instructor_list_id");
});
cy.get("@instructor_list_id").then((instructor_list_id) => {
cy.get(`[id^=${instructor_list_id}`)
.should("be.visible")
.within(() => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
// View Batch
cy.wait(1000);
cy.visit("/lms/batches");
cy.closeOnboardingModal();
cy.url().should("include", "/lms/batches");
cy.get('[id^="headlessui-radiogroup-v-"]')
.find("span")
.contains("Upcoming")
.should("be.visible")
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span")
.contains("10:00 AM - 11:00 AM")
.should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
.should("be.visible");
cy.get("a").contains("Evaluator").should("be.visible");
cy.get("span")
.contains("01 Oct 2030 - 31 Oct 2030")
.should("be.visible");
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
cy.get("span").contains("IST").should("be.visible");
cy.get("div")
.contains("10")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
cy.get("p")
.contains(
"Test Batch Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
)
.should("be.visible");
cy.get("button").contains("Manage Batch").click();
/* Add student to batch */
cy.get("button").contains("Add").click();
cy.get('div[id^="headlessui-dialog-panel-v-"]')
.first()
.find("button")
.eq(1)
.click();
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
cy.get("div").contains(randomEmail).click();
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("div")
.contains("9")
.should("be.visible")
.get("span")
.contains("Seats Left")
.should("be.visible");
});
});
+11 -4
View File
@@ -72,8 +72,15 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
Cypress.Commands.add("closeOnboardingModal", () => { Cypress.Commands.add("closeOnboardingModal", () => {
cy.wait(500); cy.wait(500);
cy.get('[class*="z-50"]') cy.get("body").then(($body) => {
.find('button:has(svg[class*="feather-x"])') // Check if any element with class including 'z-50' exists
.realClick(); if ($body.find('[class*="z-50"]').length > 0) {
cy.wait(1000); cy.get('[class*="z-50"]')
.find('button:has(svg[class*="feather-x"])')
.realClick();
cy.wait(1000);
} else {
cy.log("Onboarding modal not found, skipping close.");
}
});
}); });
+13 -9
View File
@@ -27,9 +27,9 @@ declare module 'vue' {
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default'] BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default'] BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default'] BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default'] BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default'] BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Categories.vue')['default'] Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default'] CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default'] ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default'] CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
@@ -48,10 +48,10 @@ declare module 'vue' {
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default'] EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default'] EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default'] EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
EmailTemplates: typeof import('./src/components/EmailTemplates.vue')['default'] EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
EmptyState: typeof import('./src/components/EmptyState.vue')['default'] EmptyState: typeof import('./src/components/EmptyState.vue')['default']
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default'] EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
Evaluators: typeof import('./src/components/Evaluators.vue')['default'] Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
Event: typeof import('./src/components/Modals/Event.vue')['default'] Event: typeof import('./src/components/Modals/Event.vue')['default']
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default'] ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default'] FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
@@ -65,28 +65,30 @@ declare module 'vue' {
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default'] LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default'] Link: typeof import('./src/components/Controls/Link.vue')['default']
LiveClass: typeof import('./src/components/LiveClass.vue')['default'] LiveClass: typeof import('./src/components/LiveClass.vue')['default']
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default'] LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default'] LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
Members: typeof import('./src/components/Members.vue')['default'] Members: typeof import('./src/components/Settings/Members.vue')['default']
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default'] MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default'] MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
NoPermission: typeof import('./src/components/NoPermission.vue')['default'] NoPermission: typeof import('./src/components/NoPermission.vue')['default']
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default'] NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default'] NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default'] PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default'] PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
Play: typeof import('./src/components/Icons/Play.vue')['default'] Play: typeof import('./src/components/Icons/Play.vue')['default']
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default'] ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
Question: typeof import('./src/components/Modals/Question.vue')['default'] Question: typeof import('./src/components/Modals/Question.vue')['default']
Quiz: typeof import('./src/components/Quiz.vue')['default'] Quiz: typeof import('./src/components/Quiz.vue')['default']
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default'] QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
Rating: typeof import('./src/components/Controls/Rating.vue')['default'] Rating: typeof import('./src/components/Controls/Rating.vue')['default']
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default'] ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default'] SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
SettingFields: typeof import('./src/components/SettingFields.vue')['default'] SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
Settings: typeof import('./src/components/Modals/Settings.vue')['default'] Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default'] SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default'] StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default'] StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
@@ -97,5 +99,7 @@ declare module 'vue' {
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default'] UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default'] UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default'] VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
} }
} }
-1
View File
@@ -45,7 +45,6 @@ const Layout = computed(() => {
onUnmounted(() => { onUnmounted(() => {
noSidebar.value = false noSidebar.value = false
stopSession()
}) })
watch(userResource, () => { watch(userResource, () => {
+14 -1
View File
@@ -181,7 +181,16 @@
import UserDropdown from '@/components/UserDropdown.vue' import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue' import {
ref,
onMounted,
inject,
watch,
reactive,
markRaw,
h,
onUnmounted,
} from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { sessionStore } from '@/stores/session' import { sessionStore } from '@/stores/session'
@@ -626,4 +635,8 @@ watch(userResource, () => {
const redirectToWebsite = () => { const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank') window.open('https://frappe.io/learning', '_blank')
} }
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
</script> </script>
+1 -1
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">
-1
View File
@@ -106,7 +106,6 @@ const courses = createResource({
params: { params: {
batch: props.batch, batch: props.batch,
}, },
cache: ['batchCourses', props.batchName],
auto: true, auto: true,
}) })
@@ -55,9 +55,10 @@
</div> </div>
</li> </li>
</ComboboxOption> </ComboboxOption>
<div class="h-10"></div>
<div <div
v-if="attrs.onCreate" v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t" class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
> >
<Button <Button
variant="ghost" variant="ghost"
@@ -146,7 +146,7 @@
<script setup> <script setup>
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next' import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { Badge, Button, createResource, toast } from 'frappe-ui' import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/' import { formatAmount } from '@/utils/'
import { capture } from '@/telemetry' import { capture } from '@/telemetry'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -175,15 +175,11 @@ function enrollStudent() {
toast.success(__('You need to login first to enroll for this course')) toast.success(__('You need to login first to enroll for this course'))
setTimeout(() => { setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}` window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 1000) }, 500)
} else { } else {
const enrollStudentResource = createResource({ call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', course: props.course.data.name,
}) })
enrollStudentResource
.submit({
course: props.course.data.name,
})
.then(() => { .then(() => {
capture('enrolled_in_course', { capture('enrolled_in_course', {
course: props.course.data.name, course: props.course.data.name,
@@ -198,7 +194,11 @@ function enrollStudent() {
lessonNumber: 1, lessonNumber: 1,
}, },
}) })
}, 2000) }, 1000)
})
.catch((err) => {
toast.warning(__(err.messages?.[0] || err))
console.error(err)
}) })
} }
} }
@@ -97,7 +97,7 @@ import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue' import { ref, inject, onMounted, onUnmounted } from 'vue'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
} }
) )
} }
onUnmounted(() => {
socket.off('publish_message')
socket.off('update_message')
socket.off('delete_message')
})
</script> </script>
+5 -1
View File
@@ -70,7 +70,7 @@
import { createResource, Button } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { singularize, timeAgo } from '../utils' import { singularize, timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue' import { ref, onMounted, inject, onUnmounted } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue' import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue' import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareText } from 'lucide-vue-next' import { MessageSquareText } from 'lucide-vue-next'
@@ -153,4 +153,8 @@ const showReplies = (topic) => {
const openTopicModal = () => { const openTopicModal = () => {
showTopicModal.value = true showTopicModal.value = true
} }
onUnmounted(() => {
socket.off('new_discussion_topic')
})
</script> </script>
+1 -1
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">
+92 -19
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,18 @@
</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-3 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"
:class="{
'cursor-pointer': hasPermission() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
> >
<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 }}
@@ -23,7 +41,7 @@
<div class="short-introduction"> <div class="short-introduction">
{{ cls.description }} {{ cls.description }}
</div> </div>
<div class="space-y-3"> <div class="mt-auto space-y-3">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" /> <Calendar class="w-4 h-4 stroke-1.5" />
<span> <span>
@@ -33,18 +51,20 @@
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" /> <Clock class="w-4 h-4 stroke-1.5" />
<span> <span>
{{ formatTime(cls.time) }} {{ formatTime(cls.time) }} -
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
</span> </span>
</div> </div>
<div <div
v-if="cls.date >= dayjs().format('YYYY-MM-DD')" v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto" class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
> >
<a <a
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') }}
@@ -58,42 +78,63 @@
{{ __('Join') }} {{ __('Join') }}
</a> </a>
</div> </div>
<div v-else class="flex items-center space-x-2 text-yellow-700"> <Tooltip
<Info class="w-4 h-4 stroke-1.5" /> v-else-if="hasClassEnded(cls)"
<span> :text="__('This class has ended')"
{{ __('This class has ended') }} placement="right"
</span> >
</div> <div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
<Info class="w-4 h-4 stroke-1.5" />
<span>
{{ __('Ended') }}
</span>
</div>
</Tooltip>
</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"
/> />
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
</template> </template>
<script setup> <script setup>
import { createListResource, Button } from 'frappe-ui' import { createListResource, Button, Tooltip } from 'frappe-ui'
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next' import {
import { inject } from 'vue' Plus,
import LiveClassModal from '@/components/Modals/LiveClassModal.vue' Clock,
import { ref } from 'vue' Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/' import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user') const user = inject('$user')
const showLiveClassModal = ref(false) const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({ const props = defineProps({
batch: { batch: {
type: String, type: String,
required: true, required: true,
}, },
zoomAccount: String,
}) })
const liveClasses = createListResource({ const liveClasses = createListResource({
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
'description', 'description',
'time', 'time',
'date', 'date',
'duration',
'attendees',
'start_url', 'start_url',
'join_url', 'join_url',
'owner', 'owner',
@@ -120,8 +163,38 @@ 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
} }
const canAccessClass = (cls) => {
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
if (hasClassEnded(cls)) return false
return true
}
const getClassEnd = (cls) => {
const classStart = new Date(`${cls.date}T${cls.time}`)
return new Date(classStart.getTime() + cls.duration * 60000)
}
const hasClassEnded = (cls) => {
const classEnd = getClassEnd(cls)
const now = new Date()
return now > classEnd
}
const openAttendanceModal = (cls) => {
if (!hasPermission()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script> </script>
<style> <style>
.short-introduction { .short-introduction {
@@ -97,7 +97,7 @@ import {
} from 'frappe-ui' } from 'frappe-ui'
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next' import { FileText, X } from 'lucide-vue-next'
import { getFileSize, escapeHTML } from '@/utils' import { getFileSize } from '@/utils'
const reloadProfile = defineModel('reloadProfile') const reloadProfile = defineModel('reloadProfile')
@@ -132,7 +132,6 @@ const imageResource = createResource({
const updateProfile = createResource({ const updateProfile = createResource({
url: 'frappe.client.set_value', url: 'frappe.client.set_value',
makeParams(values) { makeParams(values) {
profile.bio = escapeHTML(profile.bio)
return { return {
doctype: 'User', doctype: 'User',
name: props.profile.data.name, name: props.profile.data.name,
@@ -139,16 +139,7 @@ function submitEvaluation(close) {
close() close()
}, },
onError(err) { onError(err) {
const message = err.messages?.[0] || err toast.warning(__(err.messages?.[0] || err))
let unavailabilityMessage
if (typeof message === 'string') {
unavailabilityMessage = message?.includes('unavailable')
} else {
unavailabilityMessage = false
}
toast.warning(__(unavailabilityMessage || 'Evaluator is unavailable'))
}, },
}) })
} }
+2 -2
View File
@@ -76,8 +76,8 @@
</Button> </Button>
</div> </div>
</div> </div>
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2"> <Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
<template #default="{ tab }"> <template #tab-panel="{ tab }">
<div <div
v-if="tab.label == 'Evaluation'" v-if="tab.label == 'Evaluation'"
class="flex flex-col space-y-4 p-5" class="flex flex-col space-y-4 p-5"
@@ -0,0 +1,91 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Attendance for Class - {0}').format(live_class?.title),
size: 'xl',
}"
>
<template #body-content>
<div class="space-y-5">
<div
v-for="participant in participants.data"
@click="redirectToProfile(participant.member_username)"
class="cursor-pointer text-base w-fit"
>
<Tooltip placement="right">
<div class="flex items-center space-x-2">
<Avatar
:image="participant.member_image"
:label="participant.member_name"
size="xl"
/>
<div class="space-y-1">
<div class="font-medium">
{{ participant.member_name }}
</div>
<div>
{{ participant.member }}
</div>
</div>
</div>
<template #body>
<div
class="max-w-[30ch] rounded bg-surface-gray-7 px-2 py-1 text-p-xs text-ink-white leading-5 shadow-xl"
>
{{ dayjs(participant.joined_at).format('HH:mm a') }} -
{{ dayjs(participant.left_at).format('HH:mm a') }}
<br />
{{ __('attended for') }} {{ participant.duration }}
{{ __('minutes') }}
</div>
</template>
</Tooltip>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { inject } from 'vue'
const show = defineModel()
const router = useRouter()
const dayjs = inject('$dayjs')
interface LiveClass {
name: String
title: String
}
const props = defineProps<{
live_class: LiveClass | null
}>()
const participants = createListResource({
doctype: 'LMS Live Class Participant',
filter: {
live_class: props.live_class?.name,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'joined_at',
'left_at',
'duration',
],
auto: true,
})
const redirectToProfile = (username: string) => {
router.push({
name: 'Profile',
params: { username },
})
}
</script>
@@ -8,7 +8,7 @@
{ {
label: 'Submit', label: 'Submit',
variant: 'solid', variant: 'solid',
onClick: (close) => submitLiveClass(close), onClick: ({ close }) => submitLiveClass(close),
}, },
], ],
}" }"
@@ -16,14 +16,29 @@
<template #body-content> <template #body-content>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div class="space-y-4">
<FormControl <FormControl
type="text" type="text"
v-model="liveClass.title" v-model="liveClass.title"
:label="__('Title')" :label="__('Title')"
class="mb-4"
:required="true" :required="true"
/> />
<FormControl
v-model="liveClass.date"
type="date"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
:required="true"
/>
</Tooltip>
</div>
<div class="space-y-4">
<Tooltip <Tooltip
:text=" :text="
__( __(
@@ -35,7 +50,6 @@
v-model="liveClass.time" v-model="liveClass.time"
type="time" type="time"
:label="__('Time')" :label="__('Time')"
class="mb-4"
:required="true" :required="true"
/> />
</Tooltip> </Tooltip>
@@ -52,24 +66,6 @@
:required="true" :required="true"
/> />
</div> </div>
</div>
<div>
<FormControl
v-model="liveClass.date"
type="date"
class="mb-4"
:label="__('Date')"
:required="true"
/>
<Tooltip :text="__('Duration of the live class in minutes')">
<FormControl
type="number"
v-model="liveClass.duration"
:label="__('Duration')"
class="mb-4"
:required="true"
/>
</Tooltip>
<FormControl <FormControl
v-model="liveClass.auto_recording" v-model="liveClass.auto_recording"
type="select" type="select"
@@ -107,7 +103,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 +159,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 +168,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 +181,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 +227,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>
@@ -0,0 +1,225 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Add quiz to this video'),
size: '2xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="flex items-end gap-4">
<FormControl
:label="__('Time in Video')"
v-model="quiz.time"
type="text"
placeholder="2:15"
class="flex-1"
/>
<Link
v-model="quiz.quiz"
:label="__('Quiz')"
doctype="LMS Quiz"
class="flex-1"
/>
<Button @click="addQuiz()" variant="solid">
<template #prefix>
<Plus class="w-4 h-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button>
</div>
<div class="mt-10 mb-5">
<div class="font-medium mb-4">
{{ __('Quizzes in this video') }}
</div>
<ListView
v-if="allQuizzes.length"
:columns="columns"
:rows="allQuizzes"
row-key="quiz"
:options="{
showTooltip: false,
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in allQuizzes">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key as keyof Quiz]"
:align="column.align"
>
<div v-if="column.key == 'time'" class="leading-5 text-sm">
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key as keyof Quiz] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeQuiz(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
<div v-else class="text-ink-gray-5 italic text-xs">
{{ __('No quizzes added yet.') }}
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Dialog,
Button,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { formatTimestamp } from '@/utils'
import Link from '@/components/Controls/Link.vue'
type Quiz = {
time: string
quiz: string
}
const show = defineModel()
const allQuizzes = ref<Quiz[]>([])
const quiz = reactive<Quiz>({
time: '',
quiz: '',
})
const props = defineProps({
quizzes: {
type: Array as () => Quiz[],
default: () => [],
},
saveQuizzes: {
type: Function,
required: true,
},
duration: {
type: Number,
default: 0,
},
})
const addQuiz = () => {
quiz.time = `${getTimeInSeconds()}`
if (!isTimeValid() || !isFormComplete()) return
allQuizzes.value.push({
time: quiz.time,
quiz: quiz.quiz,
})
props.saveQuizzes(allQuizzes.value)
quiz.time = ''
quiz.quiz = ''
}
const getTimeInSeconds = () => {
if (quiz.time && !quiz.time.includes(':')) {
quiz.time = `${quiz.time}:00`
}
const timeParts = quiz.time.split(':')
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
return timeInSeconds
}
const isTimeValid = () => {
if (parseInt(quiz.time) > props.duration) {
toast.error(__('Time in video exceeds the total duration of the video.'))
return false
}
return true
}
const isFormComplete = () => {
if (!quiz.time) {
toast.error(__('Please enter a valid timestamp'))
return false
}
if (!quiz.quiz) {
toast.error(__('Please select a quiz'))
return false
}
return true
}
const removeQuiz = (selections: string, unselectAll: () => void) => {
Array.from(selections).forEach((selection) => {
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
if (index !== -1) {
allQuizzes.value.splice(index, 1)
}
unselectAll()
})
props.saveQuizzes(allQuizzes.value)
}
watch(
() => props.quizzes,
(newQuizzes) => {
allQuizzes.value = newQuizzes
},
{ immediate: true }
)
const columns = computed(() => {
return [
{
key: 'quiz',
label: __('Quiz'),
},
{
key: 'time',
label: __('Time in Video (minutes)'),
align: 'center',
},
]
})
</script>
@@ -0,0 +1,206 @@
<template>
<Dialog
v-model="show"
:options="{
title:
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
size: 'xl',
actions: [
{
label: __('Save'),
variant: 'solid',
onClick: ({ close }) => {
saveAccount(close)
},
},
],
}"
>
<template #body-content>
<div class="mb-4">
<FormControl
v-model="account.enabled"
:label="__('Enabled')"
type="checkbox"
/>
</div>
<div class="grid grid-cols-2 gap-5">
<FormControl
v-model="account.name"
:label="__('Account Name')"
type="text"
:required="true"
/>
<FormControl
v-model="account.client_id"
:label="__('Client ID')"
type="text"
:required="true"
/>
<Link
v-model="account.member"
:label="__('Member')"
doctype="Course Evaluator"
:onCreate="(value, close) => openSettings('Members', close)"
:required="true"
/>
<FormControl
v-model="account.client_secret"
:label="__('Client Secret')"
type="password"
:required="true"
/>
<FormControl
v-model="account.account_id"
:label="__('Account ID')"
type="text"
:required="true"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { call, Dialog, FormControl, toast } from 'frappe-ui'
import { inject, reactive, watch } from 'vue'
import { User } from '@/components/Settings/types'
import { openSettings, cleanError } from '@/utils'
import Link from '@/components/Controls/Link.vue'
interface ZoomAccount {
name: string
account_name: string
enabled: boolean
member: string
account_id: string
client_id: string
client_secret: string
}
interface ZoomAccounts {
data: ZoomAccount[]
reload: () => void
insert: {
submit: (
data: ZoomAccount,
options: { onSuccess: () => void; onError: (err: any) => void }
) => void
}
}
const show = defineModel('show')
const user = inject<User | null>('$user')
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
const account = reactive({
name: '',
enabled: false,
member: user?.data?.name || '',
account_id: '',
client_id: '',
client_secret: '',
})
const props = defineProps({
accountID: {
type: String,
default: 'new',
},
})
watch(
() => props.accountID,
(val) => {
if (val != 'new') {
zoomAccounts.value?.data.forEach((acc) => {
if (acc.name === val) {
account.name = acc.name
account.enabled = acc.enabled || false
account.member = acc.member
account.account_id = acc.account_id
account.client_id = acc.client_id
account.client_secret = acc.client_secret
}
})
}
}
)
watch(show, (val) => {
if (!val) {
account.name = ''
account.enabled = false
account.member = user?.data?.name || ''
account.account_id = ''
account.client_id = ''
account.client_secret = ''
}
})
const saveAccount = (close) => {
if (props.accountID == 'new') {
createAccount(close)
} else {
updateAccount(close)
}
}
const createAccount = (close) => {
zoomAccounts.value?.insert.submit(
{
account_name: account.name,
...account,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account created successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error creating Zoom Account')
)
},
}
)
}
const updateAccount = async (close) => {
if (props.accountID != account.name) {
await renameDoc()
}
setValue(close)
}
const renameDoc = async () => {
await call('frappe.client.rename_doc', {
doctype: 'LMS Zoom Settings',
old_name: props.accountID,
new_name: account.name,
})
}
const setValue = (close) => {
zoomAccounts.value?.setValue.submit(
{
...account,
name: account.name,
},
{
onSuccess() {
zoomAccounts.value?.reload()
close()
toast.success(__('Zoom Account updated successfully'))
},
onError(err) {
close()
toast.error(
cleanError(err.messages[0]) || __('Error updating Zoom Account')
)
},
}
)
}
</script>
+58 -28
View File
@@ -1,8 +1,11 @@
<template> <template>
<div v-if="quiz.data"> <div v-if="quiz.data">
<div <div
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3" class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3 leading-5"
> >
<div v-if="inVideo">
{{ __('You will have to complete the quiz to continue the video') }}
</div>
<div class="leading-5"> <div class="leading-5">
{{ {{
__('This quiz consists of {0} questions.').format(questions.length) __('This quiz consists of {0} questions.').format(questions.length)
@@ -55,19 +58,30 @@
<div class="font-semibold text-lg text-ink-gray-9"> <div class="font-semibold text-lg text-ink-gray-9">
{{ quiz.data.title }} {{ quiz.data.title }}
</div> </div>
<Button <div class="flex items-center justify-center space-x-2 mt-4">
<Button
v-if="
!quiz.data.max_attempts ||
attempts.data?.length < quiz.data.max_attempts
"
variant="solid"
@click="startQuiz"
>
<span>
{{ inVideo ? __('Start the Quiz') : __('Start') }}
</span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
<div
v-if=" v-if="
!quiz.data.max_attempts || quiz.data.max_attempts &&
attempts.data?.length < quiz.data.max_attempts attempts.data?.length >= quiz.data.max_attempts
" "
@click="startQuiz" class="leading-5 text-ink-gray-7"
class="mt-2"
> >
<span>
{{ __('Start') }}
</span>
</Button>
<div v-else class="leading-5 text-ink-gray-7">
{{ {{
__( __(
'You have already exceeded the maximum number of attempts allowed for this quiz.' 'You have already exceeded the maximum number of attempts allowed for this quiz.'
@@ -247,18 +261,23 @@
) )
}} }}
</div> </div>
<Button <div class="space-x-2">
@click="resetQuiz()" <Button
class="mt-2" @click="resetQuiz()"
v-if=" class="mt-2"
!quiz.data.max_attempts || v-if="
attempts?.data.length < quiz.data.max_attempts !quiz.data.max_attempts ||
" attempts?.data.length < quiz.data.max_attempts
> "
<span> >
{{ __('Try Again') }} <span>
</span> {{ __('Try Again') }}
</Button> </span>
</Button>
<Button v-if="inVideo" @click="props.backToVideo()">
{{ __('Resume Video') }}
</Button>
</div>
</div> </div>
<div <div
v-if=" v-if="
@@ -308,13 +327,20 @@ let questions = reactive([])
const possibleAnswer = ref(null) const possibleAnswer = ref(null)
const timer = ref(0) const timer = ref(0)
let timerInterval = null let timerInterval = null
const router = useRouter()
const props = defineProps({ const props = defineProps({
quizName: { quizName: {
type: String, type: String,
required: true, required: true,
}, },
inVideo: {
type: Boolean,
default: false,
},
backToVideo: {
type: Function,
default: () => {},
},
}) })
const quiz = createResource({ const quiz = createResource({
@@ -611,11 +637,15 @@ const getInstructions = (question) => {
} }
const markLessonProgress = () => { const markLessonProgress = () => {
if (router.currentRoute.value.name == 'Lesson') { let pathname = window.location.pathname.split('/')
if (pathname[2] != 'courses') return
let lessonIndex = pathname.pop().split('-')
if (lessonIndex.length == 2) {
call('lms.lms.api.mark_lesson_progress', { call('lms.lms.api.mark_lesson_progress', {
course: router.currentRoute.value.params.courseName, course: pathname[3],
chapter_number: router.currentRoute.value.params.chapterNumber, chapter_number: lessonIndex[0],
lesson_number: router.currentRoute.value.params.lessonNumber, lesson_number: lessonIndex[1],
}) })
} }
} }
@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col justify-between min-h-0"> <div class="flex flex-col justify-between h-full">
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9"> <div class="font-semibold mb-1 text-ink-gray-9">
@@ -28,7 +28,7 @@
</template> </template>
<script setup> <script setup>
import { createResource, Button, Badge } from 'frappe-ui' import { createResource, Button, Badge } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/Settings/SettingFields.vue'
import { watch, ref } from 'vue' import { watch, ref } from 'vue'
const isDirty = ref(false) const isDirty = ref(false)
@@ -5,9 +5,9 @@
<div class="text-xl font-semibold text-ink-gray-9"> <div class="text-xl font-semibold text-ink-gray-9">
{{ label }} {{ label }}
</div> </div>
<div class="text-xs text-ink-gray-5"> <!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }} {{ __(description) }}
</div> </div> -->
</div> </div>
<div class="flex items-center space-x-5"> <div class="flex items-center space-x-5">
<Button @click="openTemplateForm('new')"> <Button @click="openTemplateForm('new')">
@@ -33,6 +33,7 @@
:placeholder="__('Email')" :placeholder="__('Email')"
type="email" type="email"
class="w-full" class="w-full"
@keydown.enter="addEvaluator"
/> />
<Button @click="addEvaluator()" variant="subtle"> <Button @click="addEvaluator()" variant="subtle">
{{ __('Add') }} {{ __('Add') }}
@@ -118,23 +118,7 @@ import { useRouter } from 'vue-router'
import { ref, watch, reactive, inject } from 'vue' import { ref, watch, reactive, inject } from 'vue'
import { RefreshCw, Plus, X } from 'lucide-vue-next' import { RefreshCw, Plus, X } from 'lucide-vue-next'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import type { User } from '@/components/Settings/types'
interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}
const router = useRouter() const router = useRouter()
const show = defineModel('show') const show = defineModel('show')
@@ -30,9 +30,9 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/Settings/SettingFields.vue'
import { createResource, Badge, Button } from 'frappe-ui' import { createResource, Badge, Button } from 'frappe-ui'
import { watch, ref } from 'vue' import { watch } from 'vue'
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -28,7 +28,7 @@
<script setup> <script setup>
import { Button, Badge, toast } from 'frappe-ui' import { Button, Badge, toast } from 'frappe-ui'
import SettingFields from '@/components/SettingFields.vue' import SettingFields from '@/components/Settings/SettingFields.vue'
const props = defineProps({ const props = defineProps({
fields: { fields: {
@@ -6,7 +6,7 @@
<div v-for="(column, index) in columns" :key="index"> <div v-for="(column, index) in columns" :key="index">
<div <div
class="flex flex-col space-y-5" class="flex flex-col space-y-5"
:class="columns.length > 1 ? 'w-[21rem]' : 'w-1/2'" :class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
> >
<div v-for="field in column"> <div v-for="field in column">
<Link <Link
@@ -55,11 +55,13 @@
<div v-else> <div v-else>
<div class="flex items-center text-sm space-x-2"> <div class="flex items-center text-sm space-x-2">
<div <div
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2 px-20 py-5" class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
> >
<img <img
:src="data[field.name]?.file_url || data[field.name]" :src="data[field.name]?.file_url || data[field.name]"
class="size-6 rounded" class="rounded"
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
/> />
</div> </div>
<div class="flex flex-col flex-wrap"> <div class="flex flex-col flex-wrap">
@@ -101,6 +103,7 @@
:rows="field.rows" :rows="field.rows"
:options="field.options" :options="field.options"
:description="field.description" :description="field.description"
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
/> />
</div> </div>
</div> </div>
@@ -56,6 +56,11 @@
:label="activeTab.label" :label="activeTab.label"
:description="activeTab.description" :description="activeTab.description"
/> />
<ZoomSettings
v-else-if="activeTab.label === 'Zoom Accounts'"
:label="activeTab.label"
:description="activeTab.description"
/>
<PaymentSettings <PaymentSettings
v-else-if="activeTab.label === 'Payment Gateway'" v-else-if="activeTab.label === 'Payment Gateway'"
:label="activeTab.label" :label="activeTab.label"
@@ -86,14 +91,15 @@
import { Dialog, createDocumentResource, createResource } from 'frappe-ui' import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import SettingDetails from '../SettingDetails.vue' import SettingDetails from '@/components/Settings/SettingDetails.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import Members from '@/components/Members.vue' import Members from '@/components/Settings/Members.vue'
import Evaluators from '@/components/Evaluators.vue' import Evaluators from '@/components/Settings/Evaluators.vue'
import Categories from '@/components/Categories.vue' import Categories from '@/components/Settings/Categories.vue'
import EmailTemplates from '@/components/EmailTemplates.vue' import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
import BrandSettings from '@/components/BrandSettings.vue' import BrandSettings from '@/components/Settings/BrandSettings.vue'
import PaymentSettings from '@/components/PaymentSettings.vue' import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
const show = defineModel() const show = defineModel()
const doctype = ref('LMS Settings') const doctype = ref('LMS Settings')
@@ -149,13 +155,13 @@ const tabsStructure = computed(() => {
type: 'Column Break', type: 'Column Break',
}, },
{ {
label: 'Batch Confirmation Template', label: 'Batch Confirmation Email Template',
name: 'batch_confirmation_template', name: 'batch_confirmation_template',
doctype: 'Email Template', doctype: 'Email Template',
type: 'Link', type: 'Link',
}, },
{ {
label: 'Certification Template', label: 'Certification Email Template',
name: 'certification_template', name: 'certification_template',
doctype: 'Email Template', doctype: 'Email Template',
type: 'Link', type: 'Link',
@@ -239,6 +245,11 @@ const tabsStructure = computed(() => {
description: 'Manage the email templates for your learning system', description: 'Manage the email templates for your learning system',
icon: 'MailPlus', icon: 'MailPlus',
}, },
{
label: 'Zoom Accounts',
description: 'Manage the Zoom accounts for your learning system',
icon: 'Video',
},
], ],
}, },
{ {
@@ -282,8 +293,8 @@ const tabsStructure = computed(() => {
type: 'checkbox', type: 'checkbox',
}, },
{ {
label: 'Certified Participants', label: 'Certified Members',
name: 'certified_participants', name: 'certified_members',
type: 'checkbox', type: 'checkbox',
}, },
{ {
@@ -324,6 +335,9 @@ const tabsStructure = computed(() => {
description: description:
'New users will have to be manually registered by Admins.', 'New users will have to be manually registered by Admins.',
}, },
{
type: 'Column Break',
},
{ {
label: 'Signup Consent HTML', label: 'Signup Consent HTML',
name: 'custom_signup_content', name: 'custom_signup_content',
@@ -351,12 +365,16 @@ const tabsStructure = computed(() => {
type: 'textarea', type: 'textarea',
rows: 4, rows: 4,
description: description:
'Keywords for search engines to find your website. Separated by commas.', 'Comma separated keywords for search engines to find your website.',
},
{
type: 'Column Break',
}, },
{ {
label: 'Meta Image', label: 'Meta Image',
name: 'meta_image', name: 'meta_image',
type: 'Upload', type: 'Upload',
size: 'lg',
}, },
], ],
}, },
@@ -0,0 +1,188 @@
<template>
<div class="flex flex-col min-h-0 text-base">
<div class="flex items-center justify-between mb-5">
<div class="flex flex-col space-y-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ label }}
</div>
<!-- <div class="text-xs text-ink-gray-5">
{{ __(description) }}
</div> -->
</div>
<div class="flex items-center space-x-5">
<Button @click="openForm('new')">
<template #prefix>
<Plus class="h-3 w-3 stroke-1.5" />
</template>
{{ __('New') }}
</Button>
</div>
</div>
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
<ListView
:columns="columns"
:rows="zoomAccounts.data"
row-key="name"
:options="{
showTooltip: false,
onRowClick: (row) => {
openForm(row.name)
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in columns">
<template #prefix="{ item }">
<component
v-if="item.icon"
:is="item.icon"
class="h-4 w-4 stroke-1.5 ml-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow :row="row" v-for="row in zoomAccounts.data">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'enabled'">
<Badge v-if="row[column.key]" theme="blue">
{{ __('Enabled') }}
</Badge>
<Badge v-else theme="gray">
{{ __('Disabled') }}
</Badge>
</div>
<div v-else class="leading-5 text-sm">
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeAccount(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
</ListView>
</div>
</div>
<ZoomAccountModal
v-model="showForm"
v-model:zoomAccounts="zoomAccounts"
:accountID="currentAccount"
/>
</template>
<script setup lang="ts">
import {
Button,
Badge,
call,
createListResource,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
ListSelectBanner,
toast,
} from 'frappe-ui'
import { computed, inject, onMounted, ref } from 'vue'
import { Plus, Trash2 } from 'lucide-vue-next'
import { cleanError } from '@/utils'
import { User } from '@/components/Settings/types'
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
const user = inject<User | null>('$user')
const showForm = ref(false)
const currentAccount = ref<string | null>(null)
const props = defineProps({
label: String,
description: String,
})
const zoomAccounts = createListResource({
doctype: 'LMS Zoom Settings',
fields: [
'name',
'enabled',
'member',
'member_name',
'account_id',
'client_id',
'client_secret',
],
cache: ['zoomAccounts'],
})
onMounted(() => {
fetchZoomAccounts()
})
const fetchZoomAccounts = () => {
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
if (!user?.data?.is_moderator) {
zoomAccounts.update({
filters: {
member: user.data.name,
},
})
}
zoomAccounts.reload()
}
const openForm = (accountID: string) => {
currentAccount.value = accountID
showForm.value = true
}
const removeAccount = (selections, unselectAll) => {
call('lms.lms.api.delete_documents', {
doctype: 'LMS Zoom Settings',
documents: Array.from(selections),
})
.then(() => {
zoomAccounts.reload()
toast.success(__('Email Templates deleted successfully'))
unselectAll()
})
.catch((err) => {
toast.error(
cleanError(err.messages[0]) || __('Error deleting email templates')
)
})
}
const columns = computed(() => {
return [
{
label: __('Account'),
key: 'name',
},
{
label: __('Member'),
key: 'member_name',
},
{
label: __('Status'),
key: 'enabled',
align: 'center',
},
]
})
</script>
+16
View File
@@ -0,0 +1,16 @@
export interface User {
data: {
email: string
name: string
enabled: boolean
user_image: string
full_name: string
user_type: ['System User', 'Website User']
username: string
is_moderator: boolean
is_system_manager: boolean
is_evaluator: boolean
is_instructor: boolean
is_fc_site: boolean
}
}
+1 -1
View File
@@ -61,7 +61,7 @@
</button> </button>
</template> </template>
<script setup> <script setup>
import { Tooltip, Button } from 'frappe-ui' import { Tooltip } from 'frappe-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as icons from 'lucide-vue-next' import * as icons from 'lucide-vue-next'
@@ -5,10 +5,7 @@
{{ __('Upcoming Evaluations') }} {{ __('Upcoming Evaluations') }}
</div> </div>
<Button <Button
v-if=" v-if="upcoming_evals.data?.length != evaluationCourses.length"
!upcoming_evals.data?.length ||
upcoming_evals.length == courses.length
"
@click="openEvalModal" @click="openEvalModal"
> >
{{ __('Schedule Evaluation') }} {{ __('Schedule Evaluation') }}
@@ -118,7 +115,7 @@ import {
HeadsetIcon, HeadsetIcon,
EllipsisVertical, EllipsisVertical,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { inject, ref, getCurrentInstance } from 'vue' import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '../utils' import { formatTime } from '../utils'
import { Button, createResource, call } from 'frappe-ui' import { Button, createResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue' import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
@@ -163,6 +160,12 @@ const openEvalCall = (evl) => {
window.open(evl.google_meet_link, '_blank') window.open(evl.google_meet_link, '_blank')
} }
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
})
})
const cancelEvaluation = (evl) => { const cancelEvaluation = (evl) => {
$dialog({ $dialog({
title: __('Cancel this evaluation?'), title: __('Cancel this evaluation?'),
+1 -1
View File
@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
import { useSettings } from '@/stores/settings' import { useSettings } from '@/stores/settings'
import { markRaw, watch, ref, onMounted, computed } from 'vue' import { markRaw, watch, ref, onMounted, computed } from 'vue'
import { createDialog } from '@/utils/dialogs' import { createDialog } from '@/utils/dialogs'
import SettingsModal from '@/components/Modals/Settings.vue' import SettingsModal from '@/components/Settings/Settings.vue'
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue' import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
import { import {
ChevronDown, ChevronDown,
+175 -80
View File
@@ -1,80 +1,129 @@
<template> <template>
<div ref="videoContainer" class="video-block relative group"> <div>
<video
@timeupdate="updateTime"
@ended="videoEnded"
@click="togglePlay"
oncontextmenu="return false"
class="rounded-md border border-gray-100 cursor-pointer"
ref="videoRef"
>
<source :src="fileURL" :type="type" />
</video>
<div <div
v-if="!playing" v-if="quizzes.length && !showQuiz && readOnly"
class="absolute inset-0 flex items-center justify-center cursor-pointer" class="bg-surface-blue-2 space-y-1 py-3 px-4 rounded-md text-sm text-ink-blue-3 leading-5"
@click="playVideo"
> >
<div {{
class="rounded-full p-4 pl-4.5" __('This video contains {0} {1}:').format(
style=" quizzes.length,
background: radial-gradient( quizzes.length == 1 ? 'quiz' : 'quizzes'
circle, )
rgba(0, 0, 0, 0.3) 0%, }}
rgba(0, 0, 0, 0.4) 50%
); <div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
" <span> {{ index + 1 }}. {{ quiz.quiz }} </span>
> {{ __('at {0}').format(formatTimestamp(quiz.time)) }}
<Play />
</div> </div>
</div> </div>
<div <div
class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md" v-if="!showQuiz"
:class="{ ref="videoContainer"
'invisible group-hover:visible': playing, class="video-block relative group"
}"
> >
<Button variant="ghost"> <video
<template #icon> @timeupdate="updateTime"
<Play @ended="videoEnded"
v-if="!playing" @click="togglePlay"
@click="playVideo" oncontextmenu="return false"
class="size-4 text-ink-gray-9" class="rounded-md border border-gray-100 cursor-pointer"
/> ref="videoRef"
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" /> >
</template> <source :src="fileURL" :type="type" />
</Button> </video>
<Button variant="ghost" @click="toggleMute"> <div
<template #icon> v-if="!playing"
<Volume2 v-if="!muted" class="size-5 text-ink-white" /> class="absolute inset-0 flex items-center justify-center cursor-pointer"
<VolumeX v-else class="size-5 text-ink-white" /> @click="playVideo"
</template> >
</Button> <div
<input class="rounded-full p-4 pl-4.5"
type="range" style="
min="0" background: radial-gradient(
:max="duration" circle,
step="0.1" rgba(0, 0, 0, 0.3) 0%,
v-model="currentTime" rgba(0, 0, 0, 0.4) 50%
@input="changeCurrentTime" );
class="duration-slider w-full h-1" "
/> >
<span class="text-sm font-semibold"> <Play />
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} </div>
</span> </div>
<Button variant="ghost" @click="toggleFullscreen"> <div
<template #icon> class="flex items-center space-x-2 py-2 px-1 text-ink-white bg-gradient-to-b from-transparent to-black/75 absolute bottom-0 left-0 right-0 mx-auto rounded-md"
<Maximize class="size-5 text-ink-white" /> :class="{
</template> 'invisible group-hover:visible': playing,
}"
>
<Button variant="ghost" class="hover:bg-transparent">
<template #icon>
<Play
v-if="!playing"
@click="playVideo"
class="size-4 text-ink-gray-9"
/>
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
</template>
</Button>
<input
type="range"
min="0"
:max="duration"
step="0.1"
v-model="currentTime"
@input="changeCurrentTime"
class="duration-slider w-full h-1"
/>
<span class="text-sm font-medium">
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
</span>
<Button
variant="ghost"
@click="toggleMute"
class="hover:bg-transparent"
>
<template #icon>
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
<VolumeX v-else class="size-5 text-ink-white" />
</template>
</Button>
<Button
variant="ghost"
@click="toggleFullscreen"
class="hover:bg-transparent"
>
<template #icon>
<Maximize class="size-5 text-ink-white" />
</template>
</Button>
</div>
</div>
<Quiz
v-if="showQuiz"
:quizName="currentQuiz"
:inVideo="true"
:backToVideo="resumeVideo"
/>
<div v-if="!readOnly" @click="showQuizModal = true">
<Button>
{{ __('Add Quiz to Video') }}
</Button> </Button>
</div> </div>
</div> </div>
<QuizInVideo
v-model="showQuizModal"
:quizzes="quizzes"
:saveQuizzes="saveQuizzes"
:duration="duration"
/>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next' import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
import { Button } from 'frappe-ui' import { Button } from 'frappe-ui'
import { formatSeconds, formatTimestamp } from '@/utils'
import Play from '@/components/Icons/Play.vue' import Play from '@/components/Icons/Play.vue'
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
const videoRef = ref(null) const videoRef = ref(null)
const videoContainer = ref(null) const videoContainer = ref(null)
@@ -82,6 +131,10 @@ let playing = ref(false)
let currentTime = ref(0) let currentTime = ref(0)
let duration = ref(0) let duration = ref(0)
let muted = ref(false) let muted = ref(false)
const showQuizModal = ref(false)
const showQuiz = ref(false)
const currentQuiz = ref(null)
const nextQuiz = ref({})
const props = defineProps({ const props = defineProps({
file: { file: {
@@ -92,34 +145,81 @@ const props = defineProps({
type: String, type: String,
default: 'video/mp4', default: 'video/mp4',
}, },
readOnly: {
type: String,
default: true,
},
quizzes: {
type: Array,
default: () => [],
},
saveQuizzes: {
type: Function,
},
}) })
onMounted(() => { onMounted(() => {
updateCurrentTime()
updateNextQuiz()
})
const updateCurrentTime = () => {
setTimeout(() => { setTimeout(() => {
videoRef.value.onloadedmetadata = () => { videoRef.value.onloadedmetadata = () => {
duration.value = videoRef.value.duration duration.value = videoRef.value.duration
} }
videoRef.value.ontimeupdate = () => { videoRef.value.ontimeupdate = () => {
currentTime.value = videoRef.value.currentTime currentTime.value = videoRef.value?.currentTime || currentTime.value
if (currentTime.value >= nextQuiz.value.time) {
videoRef.value.pause()
playing.value = false
videoRef.value.onTimeupdate = null
currentQuiz.value = nextQuiz.value.quiz
showQuiz.value = true
}
} }
}, 0) }, 0)
}) }
const resumeVideo = (restart = false) => {
showQuiz.value = false
currentQuiz.value = null
updateCurrentTime()
setTimeout(() => {
videoRef.value.currentTime = restart ? 0 : currentTime.value
videoRef.value.play()
playing.value = true
updateNextQuiz()
}, 0)
}
const updateNextQuiz = () => {
if (!props.quizzes.length) return
props.quizzes.forEach((quiz) => {
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
let time = quiz.time.split(':')
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
quiz.time = timeInSeconds
}
})
props.quizzes.sort((a, b) => a.time - b.time)
const nextQuizIndex = props.quizzes.findIndex(
(quiz) => quiz.time > currentTime.value
)
if (nextQuizIndex !== -1) {
nextQuiz.value = props.quizzes[nextQuizIndex]
} else {
nextQuiz.value = {}
}
}
const fileURL = computed(() => { const fileURL = computed(() => {
if (isYoutube) {
let url = props.file
if (url.includes('watch?v=')) {
url = url.replace('watch?v=', 'embed/')
}
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
}
return props.file return props.file
}) })
const isYoutube = computed(() => {
return props.type == 'video/youtube'
})
const playVideo = () => { const playVideo = () => {
videoRef.value.play() videoRef.value.play()
playing.value = true playing.value = true
@@ -149,12 +249,7 @@ const toggleMute = () => {
const changeCurrentTime = () => { const changeCurrentTime = () => {
videoRef.value.currentTime = currentTime.value videoRef.value.currentTime = currentTime.value
} updateNextQuiz()
const formatTime = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
} }
const toggleFullscreen = () => { const toggleFullscreen = () => {
+6 -3
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" />
@@ -121,7 +124,7 @@
:endDate="batch.data.end_date" :endDate="batch.data.end_date"
class="mb-3" class="mb-3"
/> />
<div class="flex items-center mb-4 text-ink-gray-7"> <div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" /> <Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span> <span>
{{ formatTime(batch.data.start_time) }} - {{ formatTime(batch.data.start_time) }} -
@@ -130,7 +133,7 @@
</div> </div>
<div <div
v-if="batch.data.timezone" v-if="batch.data.timezone"
class="flex items-center mb-4 text-ink-gray-7" class="flex items-center mb-3 text-ink-gray-7"
> >
<Globe class="h-4 w-4 stroke-1.5 mr-2" /> <Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span> <span>
+48 -6
View File
@@ -23,10 +23,10 @@
/> />
<MultiSelect <MultiSelect
v-model="instructors" v-model="instructors"
doctype="User" doctype="Course Evaluator"
:label="__('Instructors')" :label="__('Instructors')"
:required="true" :required="true"
:onCreate="(close) => openSettings('Members', close)" :onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }" :filters="{ ignore_user_type: 1 }"
/> />
</div> </div>
@@ -159,6 +159,16 @@
} }
" "
/> />
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
</div> </div>
<div class="space-y-5"> <div class="space-y-5">
<FormControl <FormControl
@@ -263,6 +273,27 @@
/> />
</div> </div>
</div> </div>
<div class="px-20 pb-5 space-y-5 border-b">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Meta Tags') }}
</div>
<div class="space-y-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -292,12 +323,13 @@ import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import { openSettings } from '@/utils' import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
const router = useRouter() const router = useRouter()
const user = inject('$user') const user = inject('$user')
const { brand } = sessionStore() const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning') const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const props = defineProps({ const props = defineProps({
batchName: { batchName: {
@@ -327,20 +359,29 @@ const batch = reactive({
paid_batch: false, paid_batch: false,
currency: '', currency: '',
amount: 0, amount: 0,
zoom_account: '',
}) })
const instructors = ref([]) const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => { onMounted(() => {
if (!user.data) window.location.href = '/login' if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') { if (props.batchName != 'new') {
batchDetail.reload() fetchBatchInfo()
} else { } else {
capture('batch_form_opened') capture('batch_form_opened')
} }
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
}) })
const fetchBatchInfo = () => {
batchDetail.reload()
getMetaInfo('batches', props.batchName, meta)
}
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
if ( if (
e.key === 's' && e.key === 's' &&
@@ -454,7 +495,7 @@ const createNewBatch = () => {
localStorage.setItem('firstBatch', data.name) localStorage.setItem('firstBatch', data.name)
}) })
} }
updateMetaInfo('batches', data.name, meta)
capture('batch_created') capture('batch_created')
router.push({ router.push({
name: 'BatchDetail', name: 'BatchDetail',
@@ -475,6 +516,7 @@ const editBatchDetails = () => {
{}, {},
{ {
onSuccess(data) { onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({ router.push({
name: 'BatchDetail', name: 'BatchDetail',
params: { params: {
+12 -12
View File
@@ -12,10 +12,7 @@
</Button> </Button>
</router-link> </router-link>
</header> </header>
<div <div class="mx-auto w-full max-w-4xl pt-6 pb-10">
v-if="participants.data?.length"
class="mx-auto w-full max-w-4xl pt-6 pb-10"
>
<div class="flex flex-col md:flex-row justify-between mb-4 px-3"> <div class="flex flex-col md:flex-row justify-between mb-4 px-3">
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"> <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
{{ memberCount }} {{ __('certified members') }} {{ memberCount }} {{ __('certified members') }}
@@ -41,7 +38,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="divide-y"> <div v-if="participants.data?.length" class="divide-y">
<template v-for="participant in participants.data"> <template v-for="participant in participants.data">
<router-link <router-link
:to="{ :to="{
@@ -92,6 +89,7 @@
</router-link> </router-link>
</template> </template>
</div> </div>
<EmptyState v-else type="Certified Members" />
<div <div
v-if="!participants.list.loading && participants.hasNextPage" v-if="!participants.list.loading && participants.hasNextPage"
class="flex justify-center mt-5" class="flex justify-center mt-5"
@@ -101,7 +99,6 @@
</Button> </Button>
</div> </div>
</div> </div>
<EmptyState v-else type="Certified Members" />
</template> </template>
<script setup> <script setup>
import { import {
@@ -127,22 +124,24 @@ const memberCount = ref(0)
const dayjs = inject('$dayjs') const dayjs = inject('$dayjs')
onMounted(() => { onMounted(() => {
getMemberCount()
updateParticipants() updateParticipants()
}) })
const participants = createListResource({ const participants = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certified_participants', url: 'lms.lms.api.get_certified_participants',
cache: ['certified_participants'],
start: 0, start: 0,
pageLength: 30, pageLength: 100,
}) })
const count = call('lms.lms.api.get_count_of_certified_members').then( const getMemberCount = () => {
(data) => { call('lms.lms.api.get_count_of_certified_members', {
filters: filters.value,
}).then((data) => {
memberCount.value = data memberCount.value = data
} })
) }
const categories = createListResource({ const categories = createListResource({
doctype: 'LMS Certificate', doctype: 'LMS Certificate',
@@ -157,6 +156,7 @@ const categories = createListResource({
const updateParticipants = () => { const updateParticipants = () => {
updateFilters() updateFilters()
getMemberCount()
participants.update({ participants.update({
filters: filters.value, filters: filters.value,
}) })
+77 -33
View File
@@ -76,7 +76,7 @@
<FormControl <FormControl
v-model="course.short_introduction" v-model="course.short_introduction"
type="textarea" type="textarea"
:rows="4" :rows="5"
:label="__('Short Introduction')" :label="__('Short Introduction')"
:placeholder=" :placeholder="
__( __(
@@ -201,7 +201,7 @@
/> />
</div> </div>
<div class="px-10 pb-5 space-y-5"> <div class="px-10 pb-5 space-y-5 border-b">
<div class="text-lg font-semibold mt-5"> <div class="text-lg font-semibold mt-5">
{{ __('Pricing and Certification') }} {{ __('Pricing and Certification') }}
</div> </div>
@@ -248,6 +248,27 @@
/> />
</div> </div>
</div> </div>
<div class="px-10 pb-5 space-y-5">
<div class="text-lg font-semibold mt-5">
{{ __('Meta Tags') }}
</div>
<div class="space-y-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="7"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
/>
</div>
</div>
</div> </div>
</div> </div>
<div class="border-l"> <div class="border-l">
@@ -264,6 +285,7 @@
<script setup> <script setup>
import { import {
Breadcrumbs, Breadcrumbs,
call,
TextEditor, TextEditor,
Button, Button,
createResource, createResource,
@@ -284,10 +306,10 @@ import {
} from 'vue' } from 'vue'
import { Image, Trash2, X } from 'lucide-vue-next' import { Image, Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { capture } from '@/telemetry' import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { openSettings } from '@/utils' import { openSettings, getMetaInfo, updateMetaInfo } from '@/utils'
import Link from '@/components/Controls/Link.vue' import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue' import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue' import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -328,19 +350,30 @@ const course = reactive({
evaluator: '', evaluator: '',
}) })
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => { onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) { if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' }) router.push({ name: 'Courses' })
} }
if (props.courseName !== 'new') { if (props.courseName !== 'new') {
courseResource.reload() fetchCourseInfo()
} else { } else {
capture('course_form_opened') capture('course_form_opened')
startRecording()
} }
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
}) })
const fetchCourseInfo = () => {
courseResource.reload()
getMetaInfo('courses', props.courseName, meta)
}
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
if ( if (
e.key === 's' && e.key === 's' &&
@@ -354,6 +387,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut) window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
}) })
const courseCreationResource = createResource({ const courseCreationResource = createResource({
@@ -442,40 +476,50 @@ const imageResource = createResource({
const submitCourse = () => { const submitCourse = () => {
if (courseResource.data) { if (courseResource.data) {
courseEditResource.submit( editCourse()
{
course: courseResource.data.name,
},
{
onSuccess() {
toast.success(__('Course updated successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
} else { } else {
courseCreationResource.submit(course, { createCourse()
onSuccess(data) { }
if (user.data?.is_system_manager) { }
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
capture('course_created') const createCourse = () => {
toast.success(__('Course created successfully')) courseCreationResource.submit(course, {
router.push({ onSuccess(data) {
name: 'CourseForm', updateMetaInfo('courses', data.name, meta)
params: { courseName: data.name }, if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
}) })
}
capture('course_created')
toast.success(__('Course created successfully'))
router.push({
name: 'CourseForm',
params: { courseName: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
})
}
const editCourse = () => {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
updateMetaInfo('courses', props.courseName, meta)
toast.success(__('Course updated successfully'))
}, },
onError(err) { onError(err) {
toast.error(err.messages?.[0] || err) toast.error(err.messages?.[0] || err)
}, },
}) }
} )
} }
const deleteCourse = createResource({ const deleteCourse = createResource({
@@ -515,7 +559,7 @@ watch(
() => props.courseName !== 'new', () => props.courseName !== 'new',
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
courseResource.reload() fetchCourseInfo()
} }
} }
) )
+3 -1
View File
@@ -240,7 +240,6 @@ const updateTabFilter = () => {
filters.value['live'] = 1 filters.value['live'] = 1
} else if (currentTab.value == 'Upcoming') { } else if (currentTab.value == 'Upcoming') {
filters.value['upcoming'] = 1 filters.value['upcoming'] = 1
filters.value['published'] = 1
} else if (currentTab.value == 'New') { } else if (currentTab.value == 'New') {
filters.value['published'] = 1 filters.value['published'] = 1
filters.value['published_on'] = [ filters.value['published_on'] = [
@@ -249,6 +248,8 @@ const updateTabFilter = () => {
] ]
} else if (currentTab.value == 'Created') { } else if (currentTab.value == 'Created') {
filters.value['created'] = 1 filters.value['created'] = 1
} else if (currentTab.value == 'Unpublished') {
filters.value['published'] = 0
} }
} }
} }
@@ -318,6 +319,7 @@ const courseTabs = computed(() => {
user.data?.is_evaluator user.data?.is_evaluator
) { ) {
tabs.push({ label: __('Created') }) tabs.push({ label: __('Created') })
tabs.push({ label: __('Unpublished') })
} else if (user.data) { } else if (user.data) {
tabs.push({ label: __('Enrolled') }) tabs.push({ label: __('Enrolled') })
} }
+6 -16
View File
@@ -26,7 +26,6 @@
</header> </header>
<div> <div>
<div <div
v-if="jobCount"
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5" class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
> >
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0"> <div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
@@ -34,8 +33,8 @@
</div> </div>
<div <div
v-if="jobs.data?.length || jobCount > 0" class="grid grid-cols-1 gap-2"
class="grid grid-cols-1 md:grid-cols-3 gap-2" :class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
> >
<FormControl <FormControl
type="text" type="text"
@@ -52,6 +51,7 @@
</template> </template>
</FormControl> </FormControl>
<Link <Link
v-if="user.data"
doctype="Country" doctype="Country"
v-model="country" v-model="country"
:placeholder="__('Country')" :placeholder="__('Country')"
@@ -117,12 +117,14 @@ onMounted(() => {
jobType.value = queries.get('type') jobType.value = queries.get('type')
} }
updateJobs() updateJobs()
getJobCount()
}) })
const jobs = createResource({ const jobs = createResource({
url: 'lms.lms.api.get_job_opportunities', url: 'lms.lms.api.get_job_opportunities',
cache: ['jobs'], cache: ['jobs'],
onSuccess(data) {
jobCount.value = data.length
},
}) })
const updateJobs = () => { const updateJobs = () => {
@@ -163,18 +165,6 @@ const updateFilters = () => {
} }
} }
const getJobCount = () => {
call('frappe.client.get_count', {
doctype: 'Job Opportunity',
filters: {
status: 'Open',
disabled: 0,
},
}).then((data) => {
jobCount.value = data
})
}
watch(country, (val) => { watch(country, (val) => {
updateJobs() updateJobs()
}) })
+6
View File
@@ -303,6 +303,7 @@ import ProgressBar from '@/components/ProgressBar.vue'
import CertificationLinks from '@/components/CertificationLinks.vue' import CertificationLinks from '@/components/CertificationLinks.vue'
const user = inject('$user') const user = inject('$user')
const socket = inject('$socket')
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const allowDiscussions = ref(false) const allowDiscussions = ref(false)
@@ -335,6 +336,11 @@ const props = defineProps({
onMounted(() => { onMounted(() => {
startTimer() startTimer()
document.addEventListener('fullscreenchange', attachFullscreenEvent) document.addEventListener('fullscreenchange', attachFullscreenEvent)
socket.on('update_lesson_progress', (data) => {
if (data.course === props.courseName) {
lessonProgress.value = data.progress
}
})
}) })
const attachFullscreenEvent = () => { const attachFullscreenEvent = () => {
+4 -2
View File
@@ -99,7 +99,7 @@ import EditorJS from '@editorjs/editorjs'
import LessonHelp from '@/components/LessonHelp.vue' import LessonHelp from '@/components/LessonHelp.vue'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import { getEditorTools, enablePlyr } from '@/utils' import { getEditorTools, enablePlyr } from '@/utils'
import { capture } from '@/telemetry' import { capture, startRecording, stopRecording } from '@/telemetry'
import { useOnboarding } from 'frappe-ui/frappe' import { useOnboarding } from 'frappe-ui/frappe'
const { brand } = sessionStore() const { brand } = sessionStore()
@@ -131,6 +131,7 @@ onMounted(() => {
window.location.href = '/login' window.location.href = '/login'
} }
capture('lesson_form_opened') capture('lesson_form_opened')
startRecording()
editor.value = renderEditor('content') editor.value = renderEditor('content')
instructorEditor.value = renderEditor('instructor-notes') instructorEditor.value = renderEditor('instructor-notes')
window.addEventListener('keydown', keyboardShortcut) window.addEventListener('keydown', keyboardShortcut)
@@ -209,7 +210,7 @@ const addInstructorNotes = (data) => {
const enableAutoSave = () => { const enableAutoSave = () => {
autoSaveInterval = setInterval(() => { autoSaveInterval = setInterval(() => {
saveLesson({ showSuccessMessage: false }) saveLesson({ showSuccessMessage: false })
}, 10000) }, 5000)
} }
const keyboardShortcut = (e) => { const keyboardShortcut = (e) => {
@@ -226,6 +227,7 @@ const keyboardShortcut = (e) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInterval(autoSaveInterval) clearInterval(autoSaveInterval)
window.removeEventListener('keydown', keyboardShortcut) window.removeEventListener('keydown', keyboardShortcut)
stopRecording()
}) })
const newLessonResource = createResource({ const newLessonResource = createResource({
+20 -14
View File
@@ -22,6 +22,7 @@
<div <div
v-if="notifications?.length" v-if="notifications?.length"
v-for="log in notifications" v-for="log in notifications"
:key="log.name"
class="flex items-center py-2 justify-between" class="flex items-center py-2 justify-between"
> >
<div class="flex items-center"> <div class="flex items-center">
@@ -32,22 +33,20 @@
<Link <Link
v-if="log.link" v-if="log.link"
:to="log.link" :to="log.link"
@click="markAsRead.submit({ name: log.name })" @click="(e) => handleMarkAsRead(e, log.name)"
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7" class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
> >
{{ __('View') }} {{ __('View') }}
</Link> </Link>
<Tooltip :text="__('Mark as read')"> <Button
<Button variant="ghost"
variant="ghost" v-if="!log.read"
v-if="!log.read" @click.stop="(e) => handleMarkAsRead(e, log.name)"
@click="markAsRead.submit({ name: log.name })" >
> <template #icon>
<template #icon> <X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" /> </template>
</template> </Button>
</Button>
</Tooltip>
</div> </div>
</div> </div>
<div v-else class="text-ink-gray-5"> <div v-else class="text-ink-gray-5">
@@ -64,11 +63,10 @@ import {
Link, Link,
TabButtons, TabButtons,
Button, Button,
Tooltip,
usePageMeta, usePageMeta,
} from 'frappe-ui' } from 'frappe-ui'
import { sessionStore } from '../stores/session' import { sessionStore } from '../stores/session'
import { computed, inject, ref, onMounted } from 'vue' import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
@@ -135,6 +133,14 @@ const markAllAsRead = createResource({
}, },
}) })
const handleMarkAsRead = (e, logName) => {
markAsRead.submit({ name: logName })
}
onUnmounted(() => {
socket.off('publish_lms_notifications')
})
const breadcrumbs = computed(() => { const breadcrumbs = computed(() => {
let crumbs = [ let crumbs = [
{ {
@@ -58,6 +58,7 @@ const evaluations = createListResource({
doctype: 'LMS Certificate Request', doctype: 'LMS Certificate Request',
filters: { filters: {
evaluator: user.data?.name, evaluator: user.data?.name,
status: ['!=', 'Cancelled'],
}, },
fields: [ fields: [
'name', 'name',
-8
View File
@@ -69,17 +69,9 @@ function capture(
} }
function startRecording() { function startRecording() {
if (!isTelemetryEnabled()) return
if (window.posthog?.__loaded) {
window.posthog.startSessionRecording()
}
} }
function stopRecording() { function stopRecording() {
if (!isTelemetryEnabled()) return
if (window.posthog?.__loaded && window.posthog.sessionRecordingStarted()) {
window.posthog.stopSessionRecording()
}
} }
// Posthog Plugin // Posthog Plugin
+46 -6
View File
@@ -1,3 +1,5 @@
import { watch } from 'vue'
import { call, toast } from 'frappe-ui'
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { Quiz } from '@/utils/quiz' import { Quiz } from '@/utils/quiz'
import { Assignment } from '@/utils/assignment' import { Assignment } from '@/utils/assignment'
@@ -10,7 +12,6 @@ import Paragraph from '@editorjs/paragraph'
import { CodeBox } from '@/utils/code' import { CodeBox } from '@/utils/code'
import NestedList from '@editorjs/nested-list' import NestedList from '@editorjs/nested-list'
import InlineCode from '@editorjs/inline-code' import InlineCode from '@editorjs/inline-code'
import { watch } from 'vue'
import dayjs from '@/utils/dayjs' import dayjs from '@/utils/dayjs'
import Embed from '@editorjs/embed' import Embed from '@editorjs/embed'
import SimpleImage from '@editorjs/simple-image' import SimpleImage from '@editorjs/simple-image'
@@ -27,20 +28,21 @@ export function timeAgo(date) {
export function formatTime(timeString) { export function formatTime(timeString) {
if (!timeString) return '' if (!timeString) return ''
const [hour, minute] = timeString.split(':').map(Number) const [hour, minute] = timeString.split(':').map(Number)
// Create a Date object with dummy values for day, month, and year
const dummyDate = new Date(0, 0, 0, hour, minute) const dummyDate = new Date(0, 0, 0, hour, minute)
// Use Intl.DateTimeFormat to format the time in 12-hour format
const formattedTime = new Intl.DateTimeFormat('en-US', { const formattedTime = new Intl.DateTimeFormat('en-US', {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
hour12: true, hour12: true,
}).format(dummyDate) }).format(dummyDate)
return formattedTime return formattedTime
} }
export const formatSeconds = (time) => {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
export function formatNumber(number) { export function formatNumber(number) {
return number.toLocaleString('en-IN', { return number.toLocaleString('en-IN', {
maximumFractionDigits: 0, maximumFractionDigits: 0,
@@ -582,3 +584,41 @@ export const cleanError = (message) => {
.replace(/&#x3B;/g, ';') .replace(/&#x3B;/g, ';')
.replace(/&#x3A;/g, ':') .replace(/&#x3A;/g, ':')
} }
export const getMetaInfo = (type, route, meta) => {
call('lms.lms.api.get_meta_info', {
type: type,
route: route,
}).then((data) => {
if (data.length) {
data.forEach((row) => {
if (row.key == 'description') {
meta.description = row.value
} else if (row.key == 'keywords') {
meta.keywords = row.value
}
})
}
})
}
export const updateMetaInfo = (type, route, meta) => {
call('lms.lms.api.update_meta_info', {
type: type,
route: route,
meta_tags: [
{ key: 'description', value: meta.description },
{ key: 'keywords', value: meta.keywords },
],
}).catch((error) => {
toast.error(__('Failed to update meta tags {0}').format(error))
console.error(error)
})
}
export const formatTimestamp = (seconds) => {
const date = new Date(seconds * 1000)
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const secs = String(date.getUTCSeconds()).padStart(2, '0')
return `${minutes}:${secs}`
}
+8
View File
@@ -46,7 +46,14 @@ export class Upload {
if (this.isVideo(file.file_type)) { if (this.isVideo(file.file_type)) {
const app = createApp(VideoBlock, { const app = createApp(VideoBlock, {
file: file.file_url, file: file.file_url,
readOnly: this.readOnly,
quizzes: file.quizzes || [],
saveQuizzes: (quizzes) => {
if (this.readOnly) return
this.data.quizzes = quizzes
},
}) })
app.use(translationPlugin)
app.mount(this.wrapper) app.mount(this.wrapper)
return return
} else if (this.isAudio(file.file_type)) { } else if (this.isAudio(file.file_type)) {
@@ -93,6 +100,7 @@ export class Upload {
return { return {
file_url: this.data.file_url, file_url: this.data.file_url,
file_type: this.data.file_type, file_type: this.data.file_type,
quizzes: this.data.quizzes || [],
} }
} }
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.28.1" __version__ = "2.29.0"
+1
View File
@@ -116,6 +116,7 @@ scheduler_events = {
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals", "lms.lms.doctype.lms_certificate_request.lms_certificate_request.schedule_evals",
"lms.lms.api.update_course_statistics", "lms.lms.api.update_course_statistics",
"lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed", "lms.lms.doctype.lms_certificate_request.lms_certificate_request.mark_eval_as_completed",
"lms.lms.doctype.lms_live_class.lms_live_class.update_attendance",
], ],
"daily": [ "daily": [
"lms.job.doctype.job_opportunity.job_opportunity.update_job_openings", "lms.job.doctype.job_opportunity.job_opportunity.update_job_openings",
+92 -15
View File
@@ -20,7 +20,6 @@ from frappe.utils import (
date_diff, date_diff,
) )
from frappe.query_builder import DocType from frappe.query_builder import DocType
from pypika.functions import DistinctOptionFunction
from lms.lms.utils import get_average_rating, get_lesson_count from lms.lms.utils import get_average_rating, get_lesson_count
from xml.dom.minidom import parseString from xml.dom.minidom import parseString
from lms.lms.doctype.course_lesson.course_lesson import save_progress from lms.lms.doctype.course_lesson.course_lesson import save_progress
@@ -413,7 +412,7 @@ def get_evaluator_details(evaluator):
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_certified_participants(filters=None, start=0, page_length=30): def get_certified_participants(filters=None, start=0, page_length=100):
or_filters = {} or_filters = {}
if not filters: if not filters:
filters = {} filters = {}
@@ -451,23 +450,29 @@ def get_certified_participants(filters=None, start=0, page_length=30):
return participants return participants
class CountDistinct(DistinctOptionFunction):
def __init__(self, field):
super().__init__("COUNT", field, distinct=True)
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def get_count_of_certified_members(): def get_count_of_certified_members(filters=None):
Certificate = DocType("LMS Certificate") Certificate = DocType("LMS Certificate")
query = ( query = (
frappe.qb.from_(Certificate) frappe.qb.from_(Certificate)
.select(CountDistinct(Certificate.member).as_("total")) .select(Certificate.member)
.distinct()
.where(Certificate.published == 1) .where(Certificate.published == 1)
) )
if filters:
for field, value in filters.items():
if field == "category":
query = query.where(
Certificate.course_title.like(f"%{value}%")
| Certificate.batch_title.like(f"%{value}%")
)
elif field == "member_name":
query = query.where(Certificate.member_name.like(value[1]))
result = query.run(as_dict=True) result = query.run(as_dict=True)
return result[0]["total"] if result else 0 return len(result) or 0
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
@@ -544,7 +549,7 @@ def get_sidebar_settings():
items = [ items = [
"courses", "courses",
"batches", "batches",
"certified_participants", "certified_members",
"jobs", "jobs",
"statistics", "statistics",
"notifications", "notifications",
@@ -691,13 +696,13 @@ def get_categories(doctype, filters):
@frappe.whitelist() @frappe.whitelist()
def get_members(start=0, search=""): def get_members(start=0, search=""):
"""Get members for the given search term and start index. """Get members for the given search term and start index.
Args: start (int): Start index for the query. Args: start (int): Start index for the query.
<<<<<<< HEAD <<<<<<< HEAD
search (str): Search term to filter the results. search (str): Search term to filter the results.
======= =======
search (str): Search term to filter the results. search (str): Search term to filter the results.
>>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577 >>>>>>> 4869bba7bbb2fb38477d6fc29fb3b5838e075577
Returns: List of members. Returns: List of members.
""" """
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]} filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
@@ -838,6 +843,14 @@ def delete_documents(doctype, documents):
frappe.delete_doc(doctype, doc) frappe.delete_doc(doctype, doc)
@frappe.whitelist(allow_guest=True)
def get_count(doctype, filters):
return frappe.db.count(
doctype,
filters=filters,
)
@frappe.whitelist() @frappe.whitelist()
def get_payment_gateway_details(payment_gateway): def get_payment_gateway_details(payment_gateway):
fields = [] fields = []
@@ -1416,3 +1429,67 @@ def capture_user_persona(responses):
if response.get("message").get("name"): if response.get("message").get("name"):
frappe.db.set_single_value("LMS Settings", "persona_captured", True) frappe.db.set_single_value("LMS Settings", "persona_captured", True)
return response return response
@frappe.whitelist()
def get_meta_info(type, route):
if frappe.db.exists("Website Meta Tag", {"parent": f"{type}/{route}"}):
meta_tags = frappe.get_all(
"Website Meta Tag",
{
"parent": f"{type}/{route}",
},
["name", "key", "value"],
)
return meta_tags
return []
@frappe.whitelist()
def update_meta_info(type, route, meta_tags):
parent_name = f"{type}/{route}"
if not isinstance(meta_tags, list):
frappe.throw(_("Meta tags should be a list."))
for tag in meta_tags:
existing_tag = frappe.db.exists(
"Website Meta Tag",
{
"parent": parent_name,
"parenttype": "Website Route Meta",
"parentfield": "meta_tags",
"key": tag["key"],
},
)
if existing_tag:
if not tag.get("value"):
frappe.db.delete("Website Meta Tag", existing_tag)
continue
frappe.db.set_value("Website Meta Tag", existing_tag, "value", tag["value"])
elif tag.get("value"):
tag_properties = {
"parent": parent_name,
"parenttype": "Website Route Meta",
"parentfield": "meta_tags",
"key": tag["key"],
"value": tag["value"],
}
parent_exists = frappe.db.exists("Website Route Meta", parent_name)
if not parent_exists:
route_meta = frappe.new_doc("Website Route Meta")
route_meta.update(
{
"__newname": parent_name,
}
)
route_meta.append("meta_tags", tag_properties)
route_meta.insert()
else:
new_tag = frappe.new_doc("Website Meta Tag")
new_tag.update(tag_properties)
print(new_tag)
new_tag.insert()
print(new_tag.as_dict())
@@ -103,6 +103,7 @@
"read_only": 1 "read_only": 1
} }
], ],
"grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [ "links": [
{ {
@@ -111,7 +112,7 @@
"link_fieldname": "chapter" "link_fieldname": "chapter"
} }
], ],
"modified": "2025-02-03 15:23:17.125617", "modified": "2025-05-29 12:38:26.266673",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "Course Chapter", "name": "Course Chapter",
@@ -151,8 +152,21 @@
"role": "Course Creator", "role": "Course Creator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic",
"search_fields": "title", "search_fields": "title",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "modified", "sort_field": "modified",
@@ -160,4 +174,4 @@
"states": [], "states": [],
"title_field": "title", "title_field": "title",
"track_changes": 1 "track_changes": 1
} }
@@ -86,8 +86,8 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-03-26 14:02:46.588721", "modified": "2025-06-05 11:04:32.475711",
"modified_by": "Administrator", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "Course Evaluator", "name": "Course Evaluator",
"naming_rule": "By fieldname", "naming_rule": "By fieldname",
@@ -133,5 +133,6 @@
"row_format": "Dynamic", "row_format": "Dynamic",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"title_field": "full_name"
} }
+14 -1
View File
@@ -2,12 +2,13 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
import json
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress from lms.lms.utils import get_course_progress
from ...md import find_macros from ...md import find_macros
import json from frappe.realtime import get_website_room
class CourseLesson(Document): class CourseLesson(Document):
@@ -76,6 +77,13 @@ def save_progress(lesson, course):
enrollment.save() enrollment.save()
enrollment.run_method("on_change") enrollment.run_method("on_change")
frappe.publish_realtime(
event="update_lesson_progress",
room=get_website_room(),
message={"course": course, "lesson": lesson, "progress": progress},
after_commit=True,
)
return progress return progress
@@ -96,6 +104,11 @@ def get_quiz_progress(lesson):
for block in content.get("blocks"): for block in content.get("blocks"):
if block.get("type") == "quiz": if block.get("type") == "quiz":
quizzes.append(block.get("data").get("quiz")) quizzes.append(block.get("data").get("quiz"))
if block.get("type") == "upload":
quizzes_in_video = block.get("data").get("quizzes")
if quizzes_in_video and len(quizzes_in_video) > 0:
for row in quizzes_in_video:
quizzes.append(row.get("quiz"))
elif lesson_details.body: elif lesson_details.body:
macros = find_macros(lesson_details.body) macros = find_macros(lesson_details.body)
+8 -1
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",
+17 -6
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(
@@ -175,6 +183,8 @@ def create_live_class(
"doctype": "LMS Live Class", "doctype": "LMS Live Class",
"start_url": data.get("start_url"), "start_url": data.get("start_url"),
"join_url": data.get("join_url"), "join_url": data.get("join_url"),
"meeting_id": data.get("id"),
"uuid": data.get("uuid"),
"title": title, "title": title,
"host": frappe.session.user, "host": frappe.session.user,
"date": date, "date": date,
@@ -183,6 +193,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 +205,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}"
@@ -87,8 +87,7 @@ class LMSCertificateRequest(Document):
req.date == getdate(self.date) req.date == getdate(self.date)
or getdate() < getdate(req.date) or getdate() < getdate(req.date)
or ( or (
getdate() == getdate(req.date) getdate() == getdate(req.date) and get_time(nowtime()) < get_time(req.start_time)
and getdate(self.start_time) < getdate(req.start_time)
) )
): ):
course_title = frappe.db.get_value("LMS Course", req.course, "title") course_title = frappe.db.get_value("LMS Course", req.course, "title")
+15 -2
View File
@@ -290,7 +290,7 @@
} }
], ],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2025-03-13 16:01:19.105212", "modified": "2025-05-29 12:38:01.002898",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Course", "name": "LMS Course",
@@ -319,12 +319,25 @@
"role": "Course Creator", "role": "Course Creator",
"share": 1, "share": 1,
"write": 1 "write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
} }
], ],
"row_format": "Dynamic",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"title_field": "title", "title_field": "title",
"track_changes": 1 "track_changes": 1
} }
@@ -84,7 +84,11 @@ class LMSEnrollment(Document):
def create_membership( def create_membership(
course, batch=None, member=None, member_type="Student", role="Member" course, batch=None, member=None, member_type="Student", role="Member"
): ):
frappe.get_doc( if frappe.db.get_value("LMS Course", course, "disable_self_learning"):
return False
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.update(
{ {
"doctype": "LMS Enrollment", "doctype": "LMS Enrollment",
"batch_old": batch, "batch_old": batch,
@@ -93,8 +97,9 @@ def create_membership(
"member_type": member_type, "member_type": member_type,
"member": member or frappe.session.user, "member": member or frappe.session.user,
} }
).save(ignore_permissions=True) )
return "OK" enrollment.insert()
return enrollment
@frappe.whitelist() @frappe.whitelist()
@@ -9,21 +9,27 @@
"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",
"auto_recording",
"section_break_fhet",
"meeting_id",
"uuid",
"column_break_aony",
"attendees",
"password", "password",
"section_break_yrpq",
"start_url", "start_url",
"column_break_yokr", "column_break_yokr",
"auto_recording",
"join_url" "join_url"
], ],
"fields": [ "fields": [
@@ -73,8 +79,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 +135,50 @@
"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
},
{
"fieldname": "meeting_id",
"fieldtype": "Data",
"label": "Meeting ID"
},
{
"fieldname": "attendees",
"fieldtype": "Int",
"label": "Attendees",
"read_only": 1
},
{
"fieldname": "section_break_fhet",
"fieldtype": "Section Break"
},
{
"fieldname": "uuid",
"fieldtype": "Data",
"label": "UUID"
},
{
"fieldname": "column_break_aony",
"fieldtype": "Column Break"
} }
], ],
"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_by": "Administrator", "link_doctype": "LMS Live Class Participant",
"link_fieldname": "live_class"
}
],
"modified": "2025-05-27 14:44:35.679712",
"modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Live Class", "name": "LMS Live Class",
"owner": "Administrator", "owner": "Administrator",
@@ -175,10 +217,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
} }
@@ -2,10 +2,13 @@
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
import requests
import json
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from datetime import timedelta from datetime import timedelta
from frappe.utils import cint, get_datetime, format_date, nowdate, format_time from frappe.utils import cint, get_datetime, format_date, nowdate, format_time
from lms.lms.doctype.lms_batch.lms_batch import authenticate
class LMSLiveClass(Document): class LMSLiveClass(Document):
@@ -102,3 +105,56 @@ def send_mail(live_class, student):
args=args, args=args,
header=[_(f"Class Reminder: {live_class.title}"), "orange"], header=[_(f"Class Reminder: {live_class.title}"), "orange"],
) )
def update_attendance():
past_live_classes = frappe.get_all(
"LMS Live Class",
{
"uuid": ["is", "set"],
"attendees": ["is", "not set"],
},
["name", "uuid", "zoom_account"],
)
for live_class in past_live_classes:
attendance_data = get_attendance(live_class)
create_attendance(live_class, attendance_data)
update_attendees_count(live_class, attendance_data)
def get_attendance(live_class):
headers = {
"Authorization": "Bearer " + authenticate(live_class.zoom_account),
"content-type": "application/json",
}
encoded_uuid = requests.utils.quote(live_class.uuid, safe="")
response = requests.get(
f"https://api.zoom.us/v2/past_meetings/{encoded_uuid}/participants", headers=headers
)
if response.status_code != 200:
frappe.throw(
_("Failed to fetch attendance data from Zoom for class {0}: {1}").format(
live_class, response.text
)
)
data = response.json()
return data.get("participants", [])
def create_attendance(live_class, data):
for participant in data:
doc = frappe.new_doc("LMS Live Class Participant")
doc.live_class = live_class.name
doc.member = participant.get("user_email")
doc.joined_at = participant.get("join_time")
doc.left_at = participant.get("leave_time")
doc.duration = participant.get("duration")
doc.insert()
def update_attendees_count(live_class, data):
frappe.db.set_value("LMS Live Class", live_class.name, "attendees", len(data))
@@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe and contributors
// For license information, please see license.txt
// frappe.ui.form.on("LMS Live Class Participant", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,116 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-05-27 12:09:57.712221",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"live_class",
"joined_at",
"column_break_dwbm",
"duration",
"left_at",
"section_break_xczy",
"member",
"member_name",
"column_break_bpjn",
"member_image",
"member_username"
],
"fields": [
{
"fieldname": "live_class",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Live Class",
"options": "LMS Live Class",
"reqd": 1
},
{
"fieldname": "member",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member",
"options": "User",
"reqd": 1
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Member Name"
},
{
"fieldname": "column_break_dwbm",
"fieldtype": "Column Break"
},
{
"fieldname": "duration",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Duration",
"reqd": 1
},
{
"fieldname": "joined_at",
"fieldtype": "Datetime",
"label": "Joined At",
"reqd": 1
},
{
"fieldname": "left_at",
"fieldtype": "Datetime",
"label": "Left At",
"reqd": 1
},
{
"fieldname": "section_break_xczy",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_bpjn",
"fieldtype": "Column Break"
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
},
{
"fetch_from": "member.username",
"fieldname": "member_username",
"fieldtype": "Data",
"label": "Member Username"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-27 22:32:24.196643",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Live Class Participant",
"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": [],
"title_field": "member_name"
}
@@ -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 LMSLiveClassParticipant(Document):
pass
@@ -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 UnitTestLMSLiveClassParticipant(UnitTestCase):
"""
Unit tests for LMSLiveClassParticipant.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestLMSLiveClassParticipant(IntegrationTestCase):
"""
Integration tests for LMSLiveClassParticipant.
Use this class for testing interactions between multiple components.
"""
pass
@@ -35,6 +35,7 @@
"courses", "courses",
"batches", "batches",
"certified_participants", "certified_participants",
"certified_members",
"column_break_exdz", "column_break_exdz",
"jobs", "jobs",
"statistics", "statistics",
@@ -277,6 +278,7 @@
"default": "1", "default": "1",
"fieldname": "certified_participants", "fieldname": "certified_participants",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1,
"label": "Certified Participants" "label": "Certified Participants"
}, },
{ {
@@ -397,13 +399,19 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Persona Captured", "label": "Persona Captured",
"read_only": 1 "read_only": 1
},
{
"default": "0",
"fieldname": "certified_members",
"fieldtype": "Check",
"label": "Certified Members"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-05-14 12:43:22.749850", "modified": "2025-05-30 19:02:51.381668",
"modified_by": "sayali@frappe.io", "modified_by": "sayali@frappe.io",
"module": "LMS", "module": "LMS",
"name": "LMS Settings", "name": "LMS Settings",
@@ -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) {
// },
// });
@@ -0,0 +1,128 @@
{
"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 18:09:09.392368",
"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
},
{
"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": []
}
@@ -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
@@ -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
+1 -9
View File
@@ -961,15 +961,6 @@ def apply_gst(amount, country=None):
return amount, gst_applied return amount, gst_applied
def create_membership(course, payment):
membership = frappe.new_doc("LMS Enrollment")
membership.update(
{"member": frappe.session.user, "course": course, "payment": payment.name}
)
membership.save(ignore_permissions=True)
return f"/lms/courses/{course}/learn/1-1"
def get_current_exchange_rate(source, target="USD"): def get_current_exchange_rate(source, target="USD"):
url = f"https://api.frankfurter.app/latest?from={source}&to={target}" url = f"https://api.frankfurter.app/latest?from={source}&to={target}"
@@ -1391,6 +1382,7 @@ def get_batch_details(batch):
"certification", "certification",
"timezone", "timezone",
"category", "category",
"zoom_account",
], ],
as_dict=True, as_dict=True,
) )
+552 -229
View File
File diff suppressed because it is too large Load Diff
+554 -231
View File
File diff suppressed because it is too large Load Diff
+553 -230
View File
File diff suppressed because it is too large Load Diff
+554 -231
View File
File diff suppressed because it is too large Load Diff
+553 -230
View File
File diff suppressed because it is too large Load Diff
+565 -242
View File
File diff suppressed because it is too large Load Diff
+556 -233
View File
File diff suppressed because it is too large Load Diff
+553 -230
View File
File diff suppressed because it is too large Load Diff
+552 -229
View File
File diff suppressed because it is too large Load Diff
+435 -202
View File
File diff suppressed because it is too large Load Diff
+552 -229
View File
File diff suppressed because it is too large Load Diff
+552 -229
View File
File diff suppressed because it is too large Load Diff
+552 -229
View File
File diff suppressed because it is too large Load Diff
+553 -230
View File
File diff suppressed because it is too large Load Diff
+1491 -1168
View File
File diff suppressed because it is too large Load Diff
+554 -231
View File
File diff suppressed because it is too large Load Diff
+552 -229
View File
File diff suppressed because it is too large Load Diff
+554 -231
View File
File diff suppressed because it is too large Load Diff
+554 -231
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -103,4 +103,9 @@ 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
lms.patches.v2_0.sidebar_for_certified_members
lms.patches.v2_0.move_batch_instructors_to_evaluators
@@ -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)
@@ -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,
)
@@ -0,0 +1,22 @@
import frappe
def execute():
batch_instructors = frappe.get_all(
"Course Instructor",
{
"parenttype": "LMS Batch",
},
["name", "instructor", "parent"],
)
for instructor in batch_instructors:
if not frappe.db.exists(
"Course Evaluator",
{
"evaluator": instructor.instructor,
},
):
doc = frappe.new_doc("Course Evaluator")
doc.evaluator = instructor.instructor
doc.insert()
+31
View File
@@ -0,0 +1,31 @@
import frappe
def execute():
create_settings()
def create_settings():
current_settings = frappe.get_single("Zoom Settings")
if not current_settings.enable:
return
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()

Some files were not shown because too many files have changed in this diff Show More