Merge pull request #2125 from frappe/develop

chore: merge `develop` into `main-hotfix`
This commit is contained in:
Jannat Patel
2026-02-25 11:24:11 +05:30
committed by GitHub
114 changed files with 3842 additions and 3784 deletions

View File

@@ -8,6 +8,7 @@ on:
pull_request: {}
jobs:
tests:
name: Server Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false

View File

@@ -54,25 +54,21 @@ describe("Batch Creation", () => {
cy.get("button").contains("Create").click();
cy.get("span").contains("New Batch").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")
.contains("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")
@@ -90,13 +86,14 @@ describe("Batch Creation", () => {
cy.get("[id^=headlessui-combobox-option-").first().click();
});
});
cy.button("Save").click();
cy.get("label").contains("Published").click();
cy.button("Save").click();
cy.wait(1000);
let batchName;
cy.url().then((url) => {
console.log(url);
batchName = url.split("/").pop();
batchName = url.split("/").pop().split("#")[0];
cy.wrap(batchName).as("batchName");
});
cy.wait(500);
@@ -115,7 +112,7 @@ describe("Batch Creation", () => {
.click();
cy.get("@batchName").then((batchName) => {
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
cy.get(`a[href='/lms/batches/${batchName}'`).within(() => {
cy.get("div").contains("Test Batch").should("be.visible");
cy.get("div")
.contains("Test Batch Short Description to test the UI")
@@ -132,7 +129,7 @@ describe("Batch Creation", () => {
"be.visible"
);
});
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
cy.get(`a[href='/lms/batches/${batchName}'`).click();
});
cy.get("div").contains("Test Batch").should("be.visible");
@@ -154,14 +151,14 @@ describe("Batch Creation", () => {
"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:visible").contains("Manage Batch").click();
cy.get("button:visible").contains("Dashboard").click();
/* Add student to batch */
cy.get("button").contains("Students").click();
cy.get("button").contains("Add").click();
cy.get("button").contains("Enroll").click();
cy.get('div[role="dialog"]')
.first()
.find("input[id^='headlessui-combobox-input-v-']")
.find("div[label='Student']")
.find("div")
.first()
.click();
cy.get("input[placeholder='Search']").type(randomEmail);
@@ -169,7 +166,7 @@ describe("Batch Creation", () => {
cy.get("button").contains("Submit").click();
// Verify Seat Count
cy.get("span").contains("Details").click();
cy.get("button:visible").contains("Overview").click();
cy.contains("div:visible", "9 Seats Left").should("be.visible");
});
});

View File

@@ -53,7 +53,7 @@ describe("Course Creation", () => {
});
});
cy.button("Create").last().click();
cy.button("Save").last().click();
// Edit Course Details
cy.wait(500);
@@ -65,12 +65,9 @@ describe("Course Creation", () => {
.contains("Category")
.parent()
.within(() => {
cy.get("input").click();
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
cy.get("div").contains("Business").click();
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");

View File

@@ -8,14 +8,10 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminBatchDashboard: typeof import('./src/components/AdminBatchDashboard.vue')['default']
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
Apps: typeof import('./src/components/Sidebar/Apps.vue')['default']
AppSidebar: typeof import('./src/components/Sidebar/AppSidebar.vue')['default']
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
AssessmentPlugin: typeof import('./src/components/AssessmentPlugin.vue')['default']
Assessments: typeof import('./src/components/Assessments.vue')['default']
Assignment: typeof import('./src/components/Assignment.vue')['default']
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
@@ -24,16 +20,8 @@ declare module 'vue' {
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
BatchDashboard: typeof import('./src/components/BatchDashboard.vue')['default']
BatchFeedback: typeof import('./src/components/BatchFeedback.vue')['default']
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
@@ -82,7 +70,6 @@ declare module 'vue' {
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
Link: typeof import('./src/components/Controls/Link.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']
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']

View File

@@ -51,12 +51,12 @@
"vuedraggable": "4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "5.0.3",
"@vitejs/plugin-vue": "5.0.3",
"autoprefixer": "10.4.2",
"postcss": "8.4.5",
"tailwindcss": "^3.4.15",
"unplugin-auto-import": "^20.3.0",
"vite": "5.0.11",
"vite-plugin-pwa": "0.15.0"
"vite-plugin-pwa": "^1.2.0"
}
}

View File

@@ -1,118 +0,0 @@
<template>
<div v-if="batch?.data" class="">
<div class="w-full flex items-center justify-between pb-4">
<div class="font-medium text-ink-gray-7">
{{ __('Statistics') }}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChart
class="border rounded-md"
:config="{ title: __('Students'), value: studentCount.data || 0 }"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Certified'),
value: certificationCount.data || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{
title: __('Courses'),
value: batch?.data?.courses?.length || 0,
}"
/>
<NumberChart
class="border rounded-md"
:config="{ title: __('Assessments'), value: assessmentCount.data || 0 }"
/>
</div>
<AxisChart
v-if="showProgressChart"
class="border rounded-lg p-3 min-h-[300px]"
:config="{
data: filteredChartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
swapXY: true,
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
</div>
</template>
<script setup lang="ts">
import { AxisChart, createResource, NumberChart } from 'frappe-ui'
import { computed } from 'vue'
const props = defineProps<{
batch: { [key: string]: any } | null
}>()
const studentCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_student_count', props.batch?.data?.name],
params: {
doctype: 'LMS Batch Enrollment',
filters: { batch: props.batch?.data?.name },
},
auto: true,
})
const assessmentCount = createResource({
url: 'lms.lms.utils.get_batch_assessment_count',
cache: ['batch_assessment_count', props.batch?.data?.name],
params: {
batch: props.batch?.data?.name,
},
auto: true,
})
const chartData = createResource({
url: 'lms.lms.utils.get_batch_chart_data',
cache: ['batch_chart_data', props.batch?.data?.name],
params: { batch: props.batch?.data?.name },
auto: true,
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_certificate_count', props.batch?.data?.name],
params: {
doctype: 'LMS Certificate',
filters: { batch_name: props.batch?.data?.name },
},
auto: true,
})
const filteredChartData = computed(() =>
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
)
const showProgressChart = computed(
() =>
studentCount.data &&
(props.batch?.data?.courses?.length || assessmentCount.data)
)
</script>

View File

@@ -1,53 +0,0 @@
<template>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No announcements') }}
</div>
</template>
<script setup>
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: String,
required: true,
},
})
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch,
}
},
auto: true,
cache: ['announcement', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>

View File

@@ -64,8 +64,8 @@
import { Dialog, FormControl } from 'frappe-ui'
import { nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Link } from 'frappe-ui/frappe'
import { getLmsRoute } from '@/utils/basePath'
import Link from '@/components/Controls/Link.vue'
const show = ref(false)
const quiz = ref(null)

View File

@@ -16,8 +16,8 @@
{{ __('Submission by') }} {{ submissionResource.doc?.member_name }}
</div>
</div>
<div class="text-sm text-ink-gray-7 font-medium mb-2">
{{ __('Question') }}:
<div class="text-ink-gray-9 font-semibold mb-5">
{{ __('Assignment Question') }}
</div>
<div
v-html="assignment.data.question"
@@ -42,7 +42,11 @@
>
{{ submissionResource.doc?.status }}
</Badge>
<Button variant="solid" @click="submitAssignment()">
<Button
v-if="canModifyAssignment"
variant="solid"
@click="submitAssignment()"
>
{{ __('Save') }}
</Button>
</div>
@@ -73,12 +77,14 @@
}}
</div>
<FileUploader
v-if="!submissionResource.doc?.assignment_attachment"
v-if="!attachment"
:fileTypes="getType()"
:uploadArgs="{
private: true,
}"
:validateFile="validateFile"
:validateFile="
(file) => validateFile(file, assignment.data.type.toLowerCase())
"
@success="(file) => saveSubmission(file)"
>
<template #default="{ uploading, progress, openFileSelector }">
@@ -94,7 +100,7 @@
<div v-else>
<div class="flex items-center text-ink-gray-7">
<a
:href="submissionResource.doc.assignment_attachment"
:href="attachment"
target="_blank"
class="cursor-pointer !no-underline text-sm leading-5"
>
@@ -103,11 +109,7 @@
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<span>
{{
submissionResource.doc.assignment_attachment
.split('/')
.pop()
}}
{{ attachment.split('/').pop() }}
</span>
</div>
</a>
@@ -138,6 +140,7 @@
@change="(val) => (answer = val)"
:editable="true"
:fixedMenu="true"
:readonly="!canModifyAssignment"
:uploadArgs="{
private: true,
}"
@@ -150,7 +153,7 @@
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 border rounded-lg"
class="mt-8 p-3 border rounded-lg bg-surface-gray-2"
>
<div class="text-ink-gray-5 mb-4">
{{ __('Comments by Evaluator') }}
@@ -213,8 +216,10 @@ import {
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { validateFile } from '@/utils'
const answer = ref(null)
const attachment = ref(null)
const comments = ref(null)
const router = useRouter()
const user = inject('$user')
@@ -264,118 +269,98 @@ const assignment = createResource({
},
})
const newSubmission = createResource({
url: 'frappe.client.insert',
makeParams(values) {
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
}
return {
doc: doc,
}
},
})
const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
auto: false,
onError(err) {
toast.error(err.messages?.[0] || err)
},
auto: false,
cache: [user.data?.name, props.assignmentID],
})
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (
showUploader() &&
!submissionResource.doc.assignment_attachment
) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
} else {
isDirty.value = false
}
if (!submissionResource.doc) return
console.log(submissionResource.doc)
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
if (submissionResource.doc.assignment_attachment) {
attachment.value = submissionResource.doc.assignment_attachment
}
if (submissionResource.doc.comments) {
comments.value = submissionResource.doc.comments
}
})
watch(
() => submissionResource.doc,
() => {
if (
props.submissionName == 'new' &&
submissionResource.doc?.assignment_attachment
) {
isDirty.value = true
}
}
)
const submitAssignment = () => {
if (props.submissionName != 'new') {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
}
)
updateSubmission()
} else {
addNewSubmission()
}
}
const addNewSubmission = () => {
newSubmission.submit(
{},
let doc = {
doctype: 'LMS Assignment Submission',
assignment: props.assignmentID,
member: user.data?.name,
}
if (!showUploader()) {
doc.answer = answer.value
} else {
doc.assignment_attachment = attachment.value
}
call('frappe.client.insert', {
doc: doc,
})
.then((data) => {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
isDirty.value = false
submissionResource.name = data.name
submissionResource.reload()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const updateSubmission = () => {
let evaluator =
submissionResource.doc && submissionResource.doc.owner != user.data?.name
? user.data?.name
: null
submissionResource.setValue.submit(
{
...submissionResource.doc,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
assignment_attachment: attachment.value,
},
{
onSuccess(data) {
toast.success(__('Assignment submitted successfully'))
if (router.currentRoute.value.name == 'AssignmentSubmission') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: props.assignmentID,
submissionName: data.name,
},
query: { fromLesson: router.currentRoute.value.query.fromLesson },
})
} else {
markLessonProgress()
router.go()
}
submissionResource.name = data.name
submissionResource.reload()
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
@@ -383,7 +368,7 @@ const addNewSubmission = () => {
const saveSubmission = (file) => {
isDirty.value = true
submissionResource.doc.assignment_attachment = file.file_url
attachment.value = file.file_url
}
const markLessonProgress = () => {
@@ -417,21 +402,6 @@ const getType = () => {
}
}
const validateFile = (file) => {
let type = assignment.data?.type
let extension = file.name.split('.').pop().toLowerCase()
if (type == 'Image' && !['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
} else if (
type == 'Document' &&
!['doc', 'docx', 'xml'].includes(extension)
) {
return 'Only document file is allowed.'
} else if (type == 'PDF' && !['pdf'].includes(extension)) {
return 'Only PDF file is allowed.'
}
}
const removeSubmission = () => {
isDirty.value = true
submissionResource.doc.assignment_attachment = ''

View File

@@ -1,26 +0,0 @@
<template>
<div class="space-y-10">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
<Assessments :batch="batch.data.name" />
<!-- <StudentHeatmap /> -->
</div>
</template>
<script setup>
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
import Assessments from '@/components/Assessments.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
</script>

View File

@@ -1,226 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-9 font-medium">
{{ studentCount.data ?? 0 }} {{ __('Students') }}
</div>
<Button v-if="!readOnlyMode" @click="openStudentModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="students.data?.length">
<ListView
class="max-h-[75vh]"
:columns="studentColumns"
:rows="students.data"
row-key="name"
: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 studentColumns"
:title="item.label"
>
<template #prefix="{ item }">
<FeatherIcon
v-if="item.icon"
:name="item.icon"
class="h-4 w-4 stroke-1.5"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in students.data"
class="group cursor-pointer hover:bg-surface-gray-2 rounded"
@click="openStudentProgressModal(row)"
>
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="text-sm"
>
<template #prefix>
<div v-if="column.key == 'full_name'">
<Avatar
class="flex items-center"
:image="row['user_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div
v-if="column.key == 'progress'"
class="flex items-center space-x-4 w-full"
>
<ProgressBar :progress="row[column.key]" size="sm" />
<div class="text-xs">{{ row[column.key] }}%</div>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
variant="ghost"
@click="removeStudents(selections, unselectAll)"
>
<Trash2 class="h-4 w-4 stroke-1.5" />
</Button>
</div>
</template>
</ListSelectBanner>
<div class="mt-4 flex justify-center" v-if="students.hasNextPage">
<Button @click="students.next()">
{{ __('Load More') }}
</Button>
</div>
</ListView>
</div>
<div v-else-if="!students.loading" class="text-sm italic text-ink-gray-5">
{{ __('There are no students in this batch.') }}
</div>
</div>
<StudentModal
:batch="props.batch.data.name"
v-model="showStudentModal"
v-model:reloadStudents="students"
v-model:batchModal="props.batch"
/>
<BatchStudentProgress
:student="selectedStudent"
v-model="showStudentProgressModal"
/>
</template>
<script setup>
import {
Avatar,
Button,
createListResource,
createResource,
FeatherIcon,
ListHeader,
ListHeaderItem,
ListSelectBanner,
ListRow,
ListRows,
ListView,
ListRowItem,
toast,
} from 'frappe-ui'
import { Plus, Trash2 } from 'lucide-vue-next'
import { ref } from 'vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
const showStudentModal = ref(false)
const showStudentProgressModal = ref(false)
const selectedStudent = ref(null)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const studentCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_student_count', props.batch?.data?.name],
params: {
doctype: 'LMS Batch Enrollment',
filters: { batch: props.batch?.data?.name },
},
auto: true,
})
const students = createListResource({
doctype: 'LMS Batch Enrollment',
url: 'lms.lms.utils.get_batch_students',
cache: ['batch_students', props.batch?.data?.name],
pageLength: 50,
filters: {
batch: props.batch?.data?.name,
},
auto: true,
})
const studentColumns = [
{
label: 'Full Name',
key: 'full_name',
width: '25rem',
icon: 'user',
},
{
label: 'Progress',
key: 'progress',
width: '15rem',
icon: 'activity',
},
{
label: 'Last Active',
key: 'last_active',
width: '10rem',
align: 'center',
icon: 'clock',
},
]
const openStudentModal = () => {
showStudentModal.value = true
}
const openStudentProgressModal = (row) => {
showStudentProgressModal.value = true
selectedStudent.value = row
}
const deleteStudents = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'LMS Batch Enrollment',
documents: values.students,
}
},
})
const removeStudents = (selections, unselectAll) => {
deleteStudents.submit(
{
students: Array.from(selections),
},
{
onSuccess(data) {
students.reload()
studentCount.reload()
props.batch.reload()
toast.success(__('Students deleted successfully'))
unselectAll()
},
}
)
}
</script>

View File

@@ -1,95 +1,142 @@
<template>
<div>
<!-- Label -->
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
{{ __(label) }}
<span class="text-ink-red-3" v-if="attrs.required">*</span>
</div>
<Combobox v-model="selectedValue" nullable v-slot="{ open }">
<div class="relative w-full">
<ComboboxInput
class="form-input w-full"
:class="inputClasses"
type="text"
:value="selectedValue"
autocomplete="off"
@click="onFocus"
/>
<ComboboxButton ref="trigger" class="hidden" />
<!-- Dropdown -->
<ComboboxOptions
class="absolute z-20 mt-1 w-full rounded-lg bg-surface-modal py-1 text-base border-2 border-outline-gray-modals shadow-lg"
>
<input
ref="search"
v-model="query"
class="form-input w-[98%] rounded-tl-lg rounded-tr-lg mb-1 mx-1"
type="text"
placeholder="Search"
autocomplete="off"
/>
<!-- Options -->
<div class="my-1 max-h-[12rem] overflow-y-auto px-1.5">
<template v-for="group in groups" :key="group.key">
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
<Combobox
v-model="selectedValue"
nullable
v-slot="{ open: isComboboxOpen }"
>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ open: openPopover, togglePopover }">
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
<div class="w-full">
<button
class="flex w-full items-center justify-between focus:outline-none"
:class="inputClasses"
@click="
() => {
showOptions = !showOptions
togglePopover()
}
"
:disabled="attrs.readonly"
>
{{ group.group }}
</div>
<ComboboxOption
v-for="option in group.items"
:key="option.value"
:value="option.value"
v-slot="{ active }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base cursor-pointer',
{ 'bg-surface-gray-2': active },
]"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value === option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</template>
<div class="flex items-center w-[90%]">
<slot name="prefix" />
<span
class="block truncate text-base leading-5"
v-if="selectedValue"
>
{{ displayValue(selectedValue) }}
</span>
<span class="text-base leading-5 text-ink-gray-4" v-else>
{{ placeholder || '' }}
</span>
</div>
<ChevronDown class="h-4 w-4 stroke-1.5" />
</button>
</div>
</slot>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen" class="">
<div
v-if="groups.length === 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
>
{{ __('No results found') }}
<div class="relative px-1.5 pt-0.5">
<ComboboxInput
ref="search"
class="form-input w-full"
type="text"
@change="
(e) => {
query = e.target.value
}
"
:value="query"
autocomplete="off"
placeholder="Search"
/>
<button
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
@click="selectedValue = null"
>
<X class="h-4 w-4 stroke-1.5 text-ink-gray-7" />
</button>
</div>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
static
>
<div
class="mt-1.5"
v-for="group in groups"
:key="group.key"
v-show="group.items.length > 0"
>
<div
v-if="group.group && !group.hideLabel"
class="px-2.5 py-1.5 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<ComboboxOption
as="template"
v-for="option in group.items"
:key="option.value"
:value="option"
v-slot="{ active, selected }"
>
<li
:class="[
'flex items-center rounded px-2.5 py-2 text-base',
{ 'bg-surface-gray-2': active },
]"
>
<slot
name="item-prefix"
v-bind="{ active, selected, option }"
/>
<slot
name="item-label"
v-bind="{ active, selected, option }"
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{
option.value == option.label && option.description
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</slot>
</li>
</ComboboxOption>
</div>
<li
v-if="groups.length == 0"
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-ink-gray-5"
>
{{ __('No results found') }}
</li>
</ComboboxOptions>
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
<slot
name="footer"
v-bind="{ value: search?.el._value, close }"
></slot>
</div>
</div>
</div>
<!-- Footer -->
<div
v-if="slots.footer"
class="border-t border-outline-gray-modals p-1.5 pb-0.5"
>
<slot
name="footer"
v-bind="{
value: selectedValue,
close,
}"
/>
</div>
</ComboboxOptions>
</div>
</template>
</Popover>
</Combobox>
</div>
</template>
@@ -100,15 +147,15 @@ import {
ComboboxInput,
ComboboxOptions,
ComboboxOption,
ComboboxButton,
} from '@headlessui/vue'
import { Popover } from 'frappe-ui'
import { ChevronDown, X } from 'lucide-vue-next'
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: [String, Object],
default: null,
type: String,
default: '',
},
options: {
type: Array,
@@ -139,93 +186,107 @@ const props = defineProps({
default: true,
},
})
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
const trigger = ref(null)
const query = ref('')
const showOptions = ref(false)
const search = ref(null)
const attrs = useAttrs()
const slots = useSlots()
const selectedValue = ref(props.modelValue)
const query = ref('')
const valuePropPassed = computed(() => 'value' in attrs)
watch(selectedValue, (val) => {
query.value = ''
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
const selectedValue = computed({
get() {
return valuePropPassed.value ? attrs.value : props.modelValue
},
set(val) {
query.value = ''
if (val) {
showOptions.value = false
}
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
},
})
function clearValue() {
emit('update:modelValue', null)
function close() {
showOptions.value = false
}
const groups = computed(() => {
if (!props.options?.length) return []
if (!props.options || props.options.length == 0) return []
const normalized = props.options[0]?.group
let groups = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return normalized
.map((group, i) => ({
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}))
return groups
.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: props.filterable ? filterOptions(group.items) : group.items,
}
})
.filter((group) => group.items.length > 0)
})
function filterOptions(options) {
if (!query.value) return options
const q = query.value.toLowerCase()
return options.filter((option) =>
[option.label, option.value]
.filter(Boolean)
.some((text) => text.toString().toLowerCase().includes(q))
)
}
watchDebounced(
query,
(val) => {
emit('update:query', val)
},
{ debounce: 300 }
)
const onFocus = () => {
trigger.value?.$el.click()
nextTick(() => {
search.value?.focus()
if (!query.value) {
return options
}
return options.filter((option) => {
let searchTexts = [option.label, option.value]
return searchTexts.some((text) =>
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
)
})
}
const close = () => {
selectedValue.value = null
trigger.value?.$el.click()
function displayValue(option) {
if (typeof option === 'string') {
let allOptions = groups.value.flatMap((group) => group.items)
let selectedOption = allOptions.find((o) => o.value === option)
return selectedOption?.label || option
}
return option?.label
}
const textColor = computed(() =>
props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
)
watch(query, (q) => {
emit('update:query', q)
})
watch(showOptions, (val) => {
if (val) {
nextTick(() => {
search.value.el.focus()
})
}
})
const textColor = computed(() => {
return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8'
})
const inputClasses = computed(() => {
const sizeClasses = {
let sizeClasses = {
sm: 'text-base rounded h-7',
md: 'text-base rounded h-8',
lg: 'text-lg rounded-md h-10',
xl: 'text-xl rounded-md h-10',
}[props.size]
const paddingClasses = {
let paddingClasses = {
sm: 'py-1.5 px-2',
md: 'py-1.5 px-2.5',
lg: 'py-1.5 px-3',
xl: 'py-1.5 px-3',
}[props.size]
const variant = props.disabled ? 'disabled' : props.variant
const variantClasses = {
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-outline-gray-modals bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
outline:
@@ -246,4 +307,6 @@ const inputClasses = computed(() => {
'transition-colors w-full',
]
})
defineExpose({ query })
</script>

View File

@@ -95,7 +95,8 @@ const value = computed({
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
set: (val) => {
return (
val && emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val.value)
)
},
})

View File

@@ -38,7 +38,7 @@
'border object-cover',
shape === 'circle'
? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 rounded-md',
: 'w-44 h-auto min-h-20 max-h-32 rounded-md',
]"
/>
<video v-else controls class="border rounded-md w-44 h-auto">

View File

@@ -12,7 +12,7 @@
</div>
<div class="grid gap-8 mt-10">
<div v-for="(review, index) in reviews.data">
<div class="flex items-center">
<div class="flex">
<router-link
:to="{
name: 'Profile',
@@ -46,11 +46,11 @@
"
/>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div>
</div>
<div v-if="review.review" class="mt-4 leading-5 text-ink-gray-7">
{{ review.review }}
</div>
</div>
</div>
</div>

View File

@@ -1,221 +0,0 @@
<template>
<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 text-xs"
>
<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">
{{ __('Live Class') }}
</div>
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div
v-if="liveClasses.data?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mt-5"
>
<div
v-for="cls in liveClasses.data"
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">
{{ cls.title }}
</div>
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
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" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full 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"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<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 v-else class="text-sm italic text-ink-gray-5 mt-2">
{{ __('No live classes scheduled') }}
</div>
<LiveClassModal
:batch="props.batch"
:zoomAccount="props.zoomAccount"
v-model="showLiveClassModal"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template>
<script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: String,
required: true,
},
zoomAccount: String,
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch,
},
fields: [
'title',
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.zoomAccount) return false
return hasPermission()
}
const hasPermission = () => {
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 getClassStart = (cls) => {
return new Date(`${cls.date}T${cls.time}`)
}
const getClassEnd = (cls) => {
const classStart = getClassStart(cls)
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>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>

View File

@@ -20,11 +20,15 @@
:options="assessmentTypes"
v-model="assessmentType"
:label="__('Type')"
placeholder=" "
@update:modelValue="() => (assessment = null)"
/>
<Link
v-if="assessmentType"
v-model="assessment"
:doctype="assessmentType"
:label="__('Assessment')"
placeholder=" "
:onCreate="
(value, close) => {
close()
@@ -49,7 +53,7 @@
</template>
<script setup>
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
import Link from '@/components/Controls/Link.vue'
import { Link } from 'frappe-ui/frappe'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'

View File

@@ -2,8 +2,8 @@
<Dialog
v-model="show"
:options="{
title: __('Add Course'),
size: 'sm',
title: __('Add a course to the batch'),
size: 'lg',
actions: [
{
label: __('Submit'),
@@ -41,7 +41,7 @@
</Dialog>
</template>
<script setup>
import { Dialog, createResource, toast } from 'frappe-ui'
import { Dialog, toast } from 'frappe-ui'
import { ref, inject } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { useOnboarding } from 'frappe-ui/frappe'
@@ -63,37 +63,28 @@ const props = defineProps({
},
})
const createBatchCourse = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Batch Course',
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
course: course.value,
evaluator: evaluator.value,
},
}
},
})
const addCourse = (close) => {
createBatchCourse.submit(
{},
courses.value.insert.submit(
{
course: course.value,
evaluator: evaluator.value,
parent: props.batch,
parenttype: 'LMS Batch',
parentfield: 'courses',
},
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_course')
close()
courses.value.reload()
course.value = null
evaluator.value = null
toast.success(__('Course added to batch successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.log(err)
},
}
)

View File

@@ -1,146 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div class="p-5 space-y-10 text-base">
<div class="flex items-center space-x-2">
<Avatar :image="student.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ student.full_name }}
</div>
<Badge
v-if="
Object.keys(student.assessments).length ||
Object.keys(student.courses).length
"
:theme="student.progress === 100 ? 'green' : 'red'"
>
{{ student.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ student.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<div
v-if="Object.keys(student.assessments).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Assessment') }}
</span>
<span>
{{ __('Percentage/Status') }}
</span>
</div>
<router-link
v-for="assessment in Object.keys(student.assessments)"
class="flex items-center text-ink-gray-7 font-medium"
:to="{
name:
student.assessments[assessment].type == 'LMS Assignment'
? 'AssignmentSubmission'
: '',
params:
student.assessments[assessment].type == 'LMS Assignment'
? {
assignmentID:
student.assessments[assessment].assessment,
submissionName:
student.assessments[assessment].submission,
}
: {},
}"
>
<span class="flex-1">
{{ assessment }}
</span>
<span v-if="isAssignment(student.assessments[assessment].status)">
<Badge
:theme="
getStatusTheme(student.assessments[assessment].status)
"
>
{{ student.assessments[assessment].status }}
</Badge>
</span>
<span v-else>
{{ student.assessments[assessment].status }}
</span>
</router-link>
</div>
<!-- Courses -->
<div
v-if="Object.keys(student.courses).length"
class="space-y-2 text-sm"
>
<div
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
>
<span class="flex-1">
{{ __('Courses') }}
</span>
<span>
{{ __('Progress') }}
</span>
</div>
<div
v-for="course in Object.keys(student.courses)"
class="flex items-center text-ink-gray-7 font-medium"
>
<span class="flex-1">
{{ course }}
</span>
<span>
{{ Math.floor(student.courses[course]) }}
</span>
</div>
</div>
</div>
<!-- Heatmap -->
<StudentHeatmap :member="student.email" :days="120" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { Avatar, Badge, Dialog } from 'frappe-ui'
import StudentHeatmap from '@/components/StudentHeatmap.vue'
const show = defineModel()
const props = defineProps({
student: {
type: Object,
default: null,
},
})
const isAssignment = (value) => {
return isNaN(value)
}
const getStatusTheme = (status) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>

View File

@@ -55,6 +55,9 @@
</div>
</div>
</div>
<div v-else-if="!evaluation.course" class="text-ink-gray-7">
{{ __('Please select a course to view available slots.') }}
</div>
<div v-else class="text-ink-red-3">
{{ __('No slots available for the selected course.') }}
</div>

View File

@@ -7,7 +7,7 @@
>
<template #body>
<div class="p-5 min-h-[300px]">
<div class="text-lg font-semibold mb-4">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Training Feedback') }}
</div>
<ListView

View File

@@ -84,16 +84,10 @@
</Dialog>
</template>
<script setup>
import {
Dialog,
createResource,
Tooltip,
FormControl,
Autocomplete,
toast,
} from 'frappe-ui'
import { Dialog, createResource, Tooltip, FormControl, toast } from 'frappe-ui'
import { reactive, inject, onMounted } from 'vue'
import { getTimezones, getUserTimezone } from '@/utils/'
import Autocomplete from '@/components/Controls/Autocomplete.vue'
const liveClasses = defineModel('reloadLiveClasses')
const show = defineModel()

View File

@@ -3,7 +3,7 @@
v-model="show"
:options="{
title: __('Enroll a Student'),
size: 'sm',
size: 'lg',
actions: [
{
label: 'Submit',
@@ -51,8 +51,6 @@ import { useOnboarding } from 'frappe-ui/frappe'
import { openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const students = defineModel('reloadStudents')
const batchModal = defineModel('batchModal')
const student = ref(null)
const payment = ref(null)
const user = inject('$user')
@@ -61,33 +59,37 @@ const show = defineModel()
const props = defineProps({
batch: {
type: String,
type: Object,
default: null,
},
students: {
type: Object,
default: null,
},
})
const addStudent = (close) => {
call('frappe.client.insert', {
doc: {
doctype: 'LMS Batch Enrollment',
batch: props.batch,
props.students.insert.submit(
{
member: student.value,
payment: payment.value,
batch: props.batch.data?.name,
},
})
.then(() => {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
{
onSuccess() {
if (user.data?.is_system_manager)
updateOnboardingStep('add_batch_student')
students.value.reload()
batchModal.value.reload()
student.value = null
payment.value = null
close()
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
student.value = null
payment.value = null
props.batch.reload()
close()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
</script>

View File

@@ -3,7 +3,7 @@
v-model="show"
:options="{
size: '4xl',
title: __('Video Statistics for {0}').format(lessonTitle),
title: __('Video Statistics'),
}"
>
<template #body-content>
@@ -21,17 +21,22 @@
class="mt-2 mr-5 w-[25%]"
/> -->
</div>
<div v-if="currentTab" class="mt-4">
<div
v-if="currentTab"
:class="{
'mt-5': tabs.length > 1,
}"
>
<div class="grid grid-cols-[55%,40%] gap-5">
<div
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
class="space-y-5 border rounded-md p-2 pt-4 max-h-[70vh] overflow-y-auto"
>
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
<div class="px-4">
{{ __('Member') }}
</div>
<div class="text-center">
{{ __('Watch Time') }}
{{ __('Watch Time (mins)') }}
</div>
</div>
<div
@@ -68,15 +73,16 @@
</div>
</div>
<div class="space-y-5">
<NumberChart
class="border rounded-md"
:config="{
title: __('Average Watch Time'),
value: averageWatchTime,
}"
<NumberChartGraph
:title="__('Average Watch Time (mins)')"
:value="averageWatchTime"
/>
<div v-if="isPlyrSource">
<div class="video-player" :src="currentTab"></div>
<div
class="video-player"
:data-plyr-provider="provider"
:src="currentTab"
></div>
</div>
<VideoBlock v-else :file="currentTab" />
</div>
@@ -101,6 +107,7 @@ import {
import { computed, ref, watch } from 'vue'
import { enablePlyr, formatTimestamp } from '@/utils'
import VideoBlock from '@/components/VideoBlock.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
const show = defineModel<boolean | undefined>()
const currentTab = ref<string>('')
@@ -171,7 +178,7 @@ watch(show, () => {
const statisticsData = computed(() => {
const grouped = <Record<string, any[]>>{}
statistics.data.forEach((item: { source: string }) => {
statistics.data?.forEach((item: { source: string }) => {
if (!grouped[item.source]) {
grouped[item.source] = []
}

View File

@@ -206,7 +206,7 @@ const referenceDoctypeOptions = computed(() => {
})
const eventOptions = computed(() => {
let options = ['New', 'Value Change', 'Auto Assign']
let options = ['New', 'Value Change', 'Manual Assignment']
return options.map((event) => ({ label: __(event), value: event }))
})

View File

@@ -6,16 +6,18 @@
<div class="text-xl font-semibold leading-none text-ink-gray-9">
{{ __(label) }}
</div>
</div>
<div class="space-x-2">
<Badge
v-if="data.isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<Button variant="solid" :loading="data.save.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}

View File

@@ -219,6 +219,25 @@ const tabsStructure = computed(() => {
},
],
},
{
label: 'Jobs',
columns: [
{
fields: [
{
label: 'Allow Job Posting',
name: 'allow_job_posting',
type: 'checkbox',
description:
'If enabled, users can post job openings on the job board. Else only admins can post jobs.',
},
],
},
{
fields: [],
},
],
},
{
label: '',
columns: [

View File

@@ -5,7 +5,7 @@
{{ __('Upcoming Evaluations') }}
</div>
<Button v-if="canScheduleEvals" @click="openEvalModal">
{{ __('Schedule Evaluation') }}
{{ __('Schedule') }}
</Button>
</div>
<div
@@ -31,12 +31,14 @@
<div v-if="upcoming_evals.data?.length">
<div
class="grid gap-4"
:class="forHome ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-3'"
:class="forHome ? 'grid-cols-1 md:grid-cols-4' : 'grid-cols-1'"
>
<div v-for="evl in upcoming_evals.data">
<div class="border text-ink-gray-7 rounded-md p-3">
<div
class="border hover:border-outline-gray-3 text-ink-gray-7 rounded-md p-3"
>
<div class="flex justify-between mb-3">
<span class="text-lg font-semibold text-ink-gray-9 leading-5">
<span class="font-semibold text-ink-gray-9 leading-5">
{{ evl.course_title }}
</span>
<Menu
@@ -114,7 +116,7 @@
</div>
</div>
</div>
<div v-else-if="!endDateHasPassed" class="text-ink-gray-5">
<div v-else-if="!endDateHasPassed" class="text-ink-gray-7">
{{ __('Schedule an evaluation to get certified.') }}
</div>
</div>
@@ -200,7 +202,7 @@ const openEvalCall = (evl) => {
const evaluationCourses = computed(() => {
return props.courses.filter((course) => {
return course.evaluator != ''
return course.evaluator && course.evaluator != ''
})
})

View File

@@ -1,82 +0,0 @@
<template>
<div v-if="badge.data">
<div class="p-5 flex flex-col items-center mt-40">
<div class="text-3xl font-semibold">
{{ badge.data.badge }}
</div>
<img
:src="badge.data.badge_image"
:alt="badge.data.badge"
class="h-60 mt-2"
/>
<div class="">
{{
__('This badge has been awarded to {0} on {1}.').format(
badge.data.member_name,
dayjs(badge.data.issued_on).format('DD MMM YYYY')
)
}}
</div>
<div class="mt-2">
{{ badge.data.badge_description }}
</div>
</div>
</div>
</template>
<script setup>
import { createResource, usePageMeta } from 'frappe-ui'
import { computed, inject } from 'vue'
import { sessionStore } from '../stores/session'
const dayjs = inject('$dayjs')
const { brand } = sessionStore()
const props = defineProps({
badgeName: {
type: String,
required: true,
},
email: {
type: String,
required: true,
},
})
const badge = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Badge Assignment',
filters: {
badge: props.badgeName,
member: props.email,
},
}
},
auto: true,
})
const breadcrumbs = computed(() => {
return [
{
label: __('Badges'),
},
{
label: badge.data.badge,
route: {
name: 'Badge',
params: {
badge: badge.data.badge,
},
},
},
]
})
usePageMeta(() => {
return {
title: badge.data.badge,
icon: brand.favicon,
}
})
</script>

View File

@@ -1,395 +0,0 @@
<template>
<div v-if="isAdmin || isStudent" class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button
v-if="isAdmin && batch.data?.certification"
@click="openCertificateDialog = true"
>
{{ __('Generate Certificates') }}
</Button>
<Button v-if="canMakeAnnouncement()" @click="openAnnouncementModal()">
<span>
{{ __('Make an Announcement') }}
</span>
<template #suffix>
<SendIcon class="h-4 stroke-1.5" />
</template>
</Button>
</div>
</header>
<div
v-if="batch.data"
class="grid grid-cols-1 md:grid-cols-[75%,25%] h-[calc(100vh-3.2rem)]"
>
<div class="border-r">
<Tabs
v-model="tabIndex"
as="div"
:tabs="tabs"
tablistClass="overflow-y-hidden bg-surface-white"
>
<template #tab="{ tab, selected }" class="overflow-x-hidden">
<div>
<button
class="group -mb-px flex items-center gap-1 border-b border-transparent py-2.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
:class="{ 'text-ink-gray-9': selected }"
>
<component
v-if="tab.icon"
:is="tab.icon"
class="h-4 stroke-1.5"
/>
{{ __(tab.label) }}
<Badge
v-if="tab.count"
:class="{
'text-ink-gray-9 border border-gray-900': selected,
}"
variant="subtle"
theme="gray"
size="sm"
>
{{ tab.count }}
</Badge>
</button>
</div>
</template>
<template #tab-panel="{ tab }">
<div class="pt-5 px-5 pb-10">
<div v-if="tab.label == 'Courses'">
<BatchCourses :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Dashboard' && isStudent">
<BatchDashboard :batch="batch" :isStudent="isStudent" />
</div>
<div v-else-if="tab.label == 'Dashboard'">
<AdminBatchDashboard :batch="batch" />
</div>
<div v-else-if="tab.label == 'Students'">
<BatchStudents :batch="batch" />
</div>
<div v-else-if="tab.label == 'Classes'">
<LiveClass
:batch="batch.data.name"
:zoomAccount="batch.data.zoom_account"
/>
</div>
<div v-else-if="tab.label == 'Assessments'">
<Assessments :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Announcements'">
<Announcements :batch="batch.data.name" />
</div>
<div v-else-if="tab.label == 'Discussions'">
<Discussions
doctype="LMS Batch"
:docname="batch.data.name"
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="false"
/>
</div>
</div>
</template>
</Tabs>
</div>
<div class="p-5 border-t md:border-t-0">
<div class="mb-10">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('About this batch') }}
</div>
<div
v-html="batch.data.description"
class="leading-5 mb-4 text-ink-gray-7"
></div>
<div class="flex items-center avatar-group overlap mb-5">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<DateRange
:startDate="batch.data.start_date"
:endDate="batch.data.end_date"
class="mb-3"
/>
<div class="flex items-center mb-3 text-ink-gray-7">
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ formatTime(batch.data.start_time) }} -
{{ formatTime(batch.data.end_time) }}
</span>
</div>
<div
v-if="batch.data.timezone"
class="flex items-center mb-3 text-ink-gray-7"
>
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
<span>
{{ batch.data.timezone }}
</span>
</div>
</div>
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('Feedback') }}
</div>
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
<AnnouncementModal
v-model="showAnnouncementModal"
:batch="batch.data.name"
:students="batch.data.students"
/>
</div>
</div>
<div v-else-if="!user.data?.name" class="">
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
<div class="border-b px-5 py-3 font-medium">
<span
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
></span>
{{ __('Not Permitted') }}
</div>
<div class="px-5 py-3">
<div v-if="user.data" class="mb-4 leading-6">
{{
__(
'You are not a member of this batch. Please checkout our upcoming batches.'
)
}}
</div>
<div v-else class="mb-4 leading-6">
{{ __('Please login to access this page.') }}
</div>
<router-link
v-if="user.data"
:to="{
name: 'Batches',
params: {
batchName: batch.data?.name,
},
}"
>
<Button variant="solid" class="w-full">
{{ __('Upcoming Batches') }}
</Button>
</router-link>
<Button
v-else
variant="solid"
class="w-full"
@click="redirectToLogin()"
>
{{ __('Login') }}
</Button>
</div>
</div>
</div>
<BulkCertificates
v-if="batch.data"
v-model="openCertificateDialog"
:batch="batch.data"
/>
</template>
<script setup>
import { computed, inject, ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Breadcrumbs,
Button,
createResource,
Tabs,
Badge,
usePageMeta,
} from 'frappe-ui'
import {
Clock,
LayoutDashboard,
BookOpen,
Laptop,
BookOpenCheck,
Mail,
SendIcon,
MessageCircle,
Globe,
ClipboardPen,
} from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { sessionStore } from '@/stores/session'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import BatchDashboard from '@/components/BatchDashboard.vue'
import BatchCourses from '@/components/BatchCourses.vue'
import LiveClass from '@/components/LiveClass.vue'
import BatchStudents from '@/components/BatchStudents.vue'
import AdminBatchDashboard from '@/components/AdminBatchDashboard.vue'
import Assessments from '@/components/Assessments.vue'
import Announcements from '@/components/Annoucements.vue'
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
import Discussions from '@/components/Discussions.vue'
import DateRange from '@/components/Common/DateRange.vue'
import BulkCertificates from '@/components/Modals/BulkCertificates.vue'
import BatchFeedback from '@/components/BatchFeedback.vue'
import dayjs from 'dayjs/esm'
import { getLmsRoute } from '@/utils/basePath'
const user = inject('$user')
const showAnnouncementModal = ref(false)
const openCertificateDialog = ref(false)
const route = useRoute()
const router = useRouter()
const { brand } = sessionStore()
const tabIndex = ref(0)
const readOnlyMode = window.read_only_mode
const tabs = computed(() => {
let batchTabs = []
batchTabs.push({
label: 'Dashboard',
icon: LayoutDashboard,
})
if (isAdmin.value) {
batchTabs.push({
label: 'Students',
icon: ClipboardPen,
})
}
batchTabs.push({
label: 'Courses',
icon: BookOpen,
})
batchTabs.push({
label: 'Classes',
icon: Laptop,
})
if (isAdmin.value) {
batchTabs.push({
label: 'Assessments',
icon: BookOpenCheck,
})
}
batchTabs.push({
label: 'Announcements',
icon: Mail,
})
batchTabs.push({
label: 'Discussions',
icon: MessageCircle,
})
return batchTabs
})
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
onMounted(() => {
const hash = route.hash
if (hash) {
tabs.value.forEach((tab, index) => {
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
tabIndex.value = index
}
})
}
})
const batch = createResource({
url: 'lms.lms.utils.get_batch_details',
cache: ['batch', props.batchName],
params: {
batch: props.batchName,
},
auto: true,
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
if (!isStudent.value) {
crumbs.push({
label: __('Details'),
route: {
name: 'BatchDetail',
params: {
batchName: batch.data?.name,
},
},
})
}
crumbs.push({
label: batch?.data?.title,
route: { name: 'Batch', params: { batchName: props.batchName } },
})
return crumbs
})
const isStudent = computed(() => {
return (
user?.data &&
batch.data?.students?.length &&
batch.data?.students.includes(user.data.name)
)
})
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=${getLmsRoute(
`batches/${props.batchName}`
)}`
}
const openAnnouncementModal = () => {
showAnnouncementModal.value = true
}
watch(tabIndex, () => {
const tab = tabs.value[tabIndex.value]
if (tab.label != route.hash.replace('#', '')) {
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
}
})
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
usePageMeta(() => {
return {
title: batch?.data?.title,
icon: brand.favicon,
}
})
</script>

View File

@@ -1,158 +0,0 @@
<template>
<div v-if="batch.data" class="">
<header
class="sticky top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
</header>
<div class="m-5 pb-10">
<div class="flex justify-between w-full">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
v-html="batch.data.batch_details"
></div>
</div>
<div class="hidden md:block">
<BatchOverlay :batch="batch" />
</div>
</div>
<div v-if="batch.data.courses.length">
<div class="flex items-center mt-10">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Courses') }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
<div
v-if="batch.data.courses"
v-for="course in courses.data"
:key="course.course"
>
<router-link
:to="{
name: 'CourseDetail',
params: {
courseName: course.name,
},
}"
>
<CourseCard :course="course" :key="course.name" />
</router-link>
</div>
</div>
<div v-if="batch.data.batch_details_raw">
<div
v-html="batch.data.batch_details_raw"
class="batch-description"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Clock } from 'lucide-vue-next'
import { formatTime } from '@/utils'
import { Breadcrumbs, createResource, usePageMeta } from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import CourseCard from '@/components/CourseCard.vue'
import BatchOverlay from '@/components/BatchOverlay.vue'
import DateRange from '../components/Common/DateRange.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
const batch = createResource({
url: 'lms.lms.utils.get_batch_details',
cache: ['batch', props.batchName],
params: {
batch: props.batchName,
},
auto: true,
onSuccess: (data) => {
if (!data) {
router.push({ name: 'Batches' })
}
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batchName,
},
cache: ['batchCourses', props.batchName],
auto: true,
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
crumbs.push({
label: batch?.data?.title,
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
})
return crumbs
})
usePageMeta(() => {
return {
title: batch?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.batch-description p {
margin-bottom: 1rem;
line-height: 1.7;
}
.batch-description li {
line-height: 1.7;
}
.batch-description ol {
list-style: auto;
margin: revert;
padding: revert;
}
.batch-description strong {
font-weight: 600;
color: theme('colors.gray.900') !important;
}
</style>

View File

@@ -1,592 +0,0 @@
<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs class="h-7" :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
<template #icon>
<Trash2 class="size-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="saveBatch()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="py-5">
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<MultiSelect
v-model="instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
</div>
<FormControl
v-model="batch.description"
:label="__('Short Description')"
type="textarea"
:rows="8"
:placeholder="__('Short description of the batch')"
:required="true"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Settings') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<FormControl
v-model="batch.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batch.allow_self_enrollment"
type="checkbox"
:label="__('Allow self enrollment')"
/>
<FormControl
v-model="batch.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Date and Time') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.start_date"
:label="__('Batch Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('Batch End Date')"
type="date"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Session Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('Session End Time')"
type="time"
class="mb-4"
:required="true"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batch.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div>
<label class="block text-sm text-ink-gray-5 mb-1">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batch.batch_details"
@change="(val) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x border-outline-gray-modals bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[20rem] overflow-y-scroll mb-4"
/>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Configurations') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
<Link
doctype="Email Template"
:label="__('Email Template')"
v-model="batch.confirmation_email_template"
:onCreate="
(value, close) => {
openSettings('Email Templates', close)
}
"
/>
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batch.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.medium"
type="select"
:options="[
{
label: 'Online',
value: 'Online',
},
{
label: 'Offline',
value: 'Offline',
},
]"
:label="__('Medium')"
class="mb-4"
/>
<Link
doctype="LMS Category"
:label="__('Category')"
v-model="batch.category"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
<div class="space-y-5">
<Uploader
v-model="batch.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
</div>
<div class="px-5 md:px-20 pb-5 space-y-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Pricing') }}
</div>
<FormControl
v-model="batch.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<div
v-if="batch.paid_batch"
class="grid grid-cols-1 md:grid-cols-3 gap-5"
>
<FormControl
v-model="batch.amount"
:label="__('Amount')"
type="number"
/>
<Link
doctype="Currency"
v-model="batch.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div>
<div class="px-5 md: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">
<Uploader
v-model="batch.meta_image"
:label="__('Meta Image')"
type="image"
:required="false"
/>
<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>
</template>
<script setup>
import {
computed,
getCurrentInstance,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
} from 'vue'
import {
Breadcrumbs,
FormControl,
Button,
TextEditor,
createResource,
usePageMeta,
toast,
call,
} from 'frappe-ui'
import {
escapeHTML,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
} from '@/utils'
import { useRouter } from 'vue-router'
import { Trash2 } from 'lucide-vue-next'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import Uploader from '@/components/Controls/Uploader.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { $dialog } = app.appContext.config.globalProperties
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
const batch = reactive({
title: '',
published: false,
description: '',
batch_details: '',
start_date: '',
end_date: '',
start_time: '',
end_time: '',
timezone: '',
evaluation_end_date: '',
confirmation_email_template: '',
seat_count: '',
medium: '',
category: '',
allow_self_enrollment: false,
certification: false,
meta_image: null,
paid_batch: false,
currency: '',
amount: 0,
zoom_account: '',
video_link: '',
})
const meta = reactive({
description: '',
keywords: '',
})
onMounted(() => {
if (!user.data) window.location.href = '/login'
if (props.batchName != 'new') {
fetchBatchInfo()
} else {
capture('batch_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchBatchInfo = () => {
batchDetail.reload()
getMetaInfo('batches', props.batchName, meta)
}
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
saveBatch()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const newBatch = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Batch',
meta_image: batch.image,
video_link: batch.video_link,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...batch,
},
}
},
})
const batchDetail = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Batch',
name: props.batchName,
}
},
onSuccess(data) {
updateBatchData(data)
},
})
const updateBatchData = (data) => {
Object.keys(data).forEach((key) => {
if (key == 'instructors') {
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (['start_time', 'end_time'].includes(key)) {
batch[key] = formatTime(data[key])
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
})
let checkboxes = [
'published',
'paid_batch',
'allow_self_enrollment',
'certification',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
batch[key] = batch[key] ? true : false
}
}
const formatTime = (timeStr) => {
let [hours, minutes, seconds] = timeStr.split(':')
hours = hours.length == 1 ? '0' + hours : hours
return `${hours}:${minutes}`
}
const editBatch = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'LMS Batch',
name: props.batchName,
fieldname: {
meta_image: batch.meta_image,
video_link: batch.video_link,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
...batch,
},
}
},
})
const validateFields = () => {
batch.description = sanitizeHTML(batch.description)
batch.batch_details = sanitizeHTML(batch.batch_details)
Object.keys(batch).forEach((key) => {
if (
!['description', 'batch_details'].includes(key) &&
typeof batch[key] === 'string'
) {
batch[key] = escapeHTML(batch[key])
}
})
}
const saveBatch = () => {
validateFields()
if (batchDetail.data) {
editBatchDetails()
} else {
createNewBatch()
}
}
const createNewBatch = () => {
newBatch.submit(
{},
{
onSuccess(data) {
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_batch', true, false, () => {
localStorage.setItem('firstBatch', data.name)
})
}
updateMetaInfo('batches', data.name, meta)
capture('batch_created')
router.push({
name: 'BatchDetail',
params: {
batchName: data.name,
},
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const editBatchDetails = () => {
editBatch.submit(
{},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
router.push({
name: 'BatchDetail',
params: {
batchName: data.name,
},
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
message: __(
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }) {
trashBatch(close)
close()
},
},
],
})
}
const trashBatch = (close) => {
call('lms.lms.api.delete_batch', {
batch: props.batchName,
}).then(() => {
toast.success(__('Batch deleted successfully'))
close()
router.push({
name: 'Batches',
})
})
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: __('Batches'),
route: {
name: 'Batches',
},
},
]
if (batchDetail.data) {
crumbs.push({
label: batchDetail.data.title,
route: {
name: 'BatchDetail',
params: {
batchName: props.batchName,
},
},
})
}
crumbs.push({
label: props.batchName == 'new' ? __('New Batch') : __('Edit Batch'),
route: { name: 'BatchForm', params: { batchName: props.batchName } },
})
return crumbs
})
usePageMeta(() => {
return {
title: props.batchName == 'new' ? 'New Batch' : batchDetail.data?.title,
icon: brand.favicon,
}
})
</script>

View File

@@ -0,0 +1,267 @@
<template>
<div v-if="batch.data" class="">
<header
class="sticky top-0 z-10 border-b flex items-center justify-between bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div v-if="tabIndex == 5 && isAdmin" class="flex items-center space-x-2">
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button @click="childRef.deleteBatch()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="childRef.submitBatch()">
{{ __('Save') }}
</Button>
</div>
<Dropdown
v-else-if="isAdmin"
:options="batchMenu"
placement="left"
side="left"
>
<template v-slot="{ open }">
<Button variant="ghost">
<template #icon>
<EllipsisVertical class="w-4 h-4 stroke-1.5" />
</template>
</Button>
</template>
</Dropdown>
</header>
<div>
<BatchOverview v-if="!isAdmin && !isStudent" :batch="batch" />
<div v-else>
<Tabs :tabs="tabs" v-model="tabIndex">
<template #tab-panel="{ tab }">
<div
v-if="tab.label == 'Discussions'"
class="w-[90%] lg:w-[75%] mx-auto mt-5"
>
<Discussions
doctype="LMS Batch"
:docname="batch.data.name"
:title="__('Discussions')"
:key="batch.data.name"
:singleThread="true"
:scrollToBottom="false"
/>
</div>
<component
v-else
:is="tab.component"
:batch="batch"
ref="childRef"
/>
</template>
</Tabs>
</div>
</div>
</div>
<BulkCertificates
v-if="batch.data"
v-model="openCertificateDialog"
:batch="batch.data"
/>
<AnnouncementModal
v-if="showAnnouncementModal"
v-model="showAnnouncementModal"
:batch="batch.data.name"
:students="batch.data.students"
/>
</template>
<script setup>
import {
ClipboardPen,
EllipsisVertical,
Laptop,
List,
Mail,
MessageCircle,
SendIcon,
Settings2,
Trash2,
TrendingUp,
} from 'lucide-vue-next'
import { computed, inject, markRaw, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
Badge,
Breadcrumbs,
Button,
createResource,
Dropdown,
Tabs,
usePageMeta,
} from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import AdminBatchDashboard from '@/pages/Batches/components/AdminBatchDashboard.vue'
import StudentBatchDashboard from '@/pages/Batches/components/BatchDashboard.vue'
import BatchOverview from '@/pages/Batches/BatchOverview.vue'
import LiveClass from '@/pages/Batches/components/LiveClass.vue'
import Announcements from '@/pages/Batches/components/Announcements.vue'
import AnnouncementModal from '@/pages/Batches/components/AnnouncementModal.vue'
import BatchForm from '@/pages/Batches/BatchForm.vue'
import BulkCertificates from '@/pages/Batches/components/BulkCertificates.vue'
import Discussions from '@/components/Discussions.vue'
const router = useRouter()
const route = useRoute()
const { brand } = sessionStore()
const user = inject('$user')
const childRef = ref(null)
const tabIndex = ref(0)
const tabs = ref([])
const openCertificateDialog = ref(false)
const showAnnouncementModal = ref(false)
const readOnlyMode = window.read_only_mode
const props = defineProps({
batchName: {
type: String,
required: true,
},
})
const updateTabIndex = () => {
const hash = route.hash
if (hash) {
tabs.value.forEach((tab, index) => {
if (tab.label?.toLowerCase() === hash.replace('#', '')) {
tabIndex.value = index
}
})
}
}
watch(tabIndex, () => {
const tab = tabs.value[tabIndex.value]
if (tab.label != route.hash.replace('#', '')) {
router.push({ ...route, hash: `#${tab.label.toLowerCase()}` })
}
})
const batch = createResource({
url: 'lms.lms.utils.get_batch_details',
cache: ['batch', props.batchName],
params: {
batch: props.batchName,
},
auto: true,
onSuccess: (data) => {
if (!data) {
router.push({ name: 'Batches' })
}
},
})
watch(batch, () => {
updateTabs()
updateTabIndex()
})
const updateTabs = () => {
addToTabs('Overview', markRaw(BatchOverview), List)
if (!user.data) return
if (isAdmin.value) {
addToTabs('Dashboard', markRaw(AdminBatchDashboard), TrendingUp)
} else if (isStudent.value) {
addToTabs('Dashboard', markRaw(StudentBatchDashboard), ClipboardPen)
}
addToTabs('Classes', markRaw(LiveClass), Laptop)
addToTabs('Announcements', markRaw(Announcements), Mail)
addToTabs('Discussions', markRaw(Discussions), MessageCircle)
if (isAdmin.value) {
addToTabs('Settings', markRaw(BatchForm), Settings2)
}
}
const addToTabs = (label, component, icon) => {
if (!tabs.value.some((tab) => tab.label === label)) {
tabs.value.push({
label,
component,
icon,
})
}
}
const isAdmin = computed(() => {
return user.data?.is_moderator || batch.data?.is_evaluator
})
const isStudent = computed(() => {
return batch.data?.students?.includes(user.data?.name)
})
const openAnnouncementModal = () => {
showAnnouncementModal.value = true
}
const canMakeAnnouncement = () => {
if (readOnlyMode) return false
if (!batch.data?.students?.length) return false
return user.data?.is_moderator || user.data?.is_evaluator
}
const batchMenu = computed(() => {
let options = [
{
label: __('Generate Certificates'),
onClick() {
openCertificateDialog.value = true
},
condition: () => batch.data?.certification,
},
{
label: __('Make an Announcement'),
onClick() {
openAnnouncementModal()
},
condition: () => canMakeAnnouncement(),
},
]
return options
})
const breadcrumbs = computed(() => {
let crumbs = [{ label: __('Batches'), route: { name: 'Batches' } }]
crumbs.push({
label: batch?.data?.title,
route: { name: 'BatchDetail', params: { batchName: batch?.data?.name } },
})
return crumbs
})
usePageMeta(() => {
return {
title: batch?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.batch-description p {
margin-bottom: 1rem;
line-height: 1.7;
}
.batch-description li {
line-height: 1.7;
}
.batch-description ol {
list-style: auto;
margin: revert;
padding: revert;
}
.batch-description strong {
font-weight: 600;
color: theme('colors.gray.900') !important;
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<div class="">
<div class="grid grid-cols-1 lg:grid-cols-[3fr,2fr]">
<div v-if="batchDetail.doc" class="py-5 lg:h-[88vh] lg:overflow-y-auto">
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Details') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batchDetail.doc.published"
type="checkbox"
:label="__('Published')"
/>
<FormControl
v-model="batchDetail.doc.title"
:label="__('Title')"
:required="true"
class="w-full"
/>
<FormControl
v-model="batchDetail.doc.start_date"
:label="__('Batch Start Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.end_date"
:label="__('Batch End Date')"
type="date"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.seat_count"
:label="__('Seat Count')"
type="number"
class="mb-4"
:placeholder="__('Number of seats available')"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batchDetail.doc.allow_self_enrollment"
type="checkbox"
:label="__('Allow Self Enrollment')"
/>
<FormControl
v-model="batchDetail.doc.start_time"
:label="__('Session Start Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.end_time"
:label="__('Session End Time')"
type="time"
class="mb-4"
:required="true"
/>
<FormControl
v-model="batchDetail.doc.timezone"
:label="__('Timezone')"
type="text"
:placeholder="__('Example: IST (+5:30)')"
class="mb-4"
:required="true"
/>
<Link
doctype="LMS Category"
:label="__('Category')"
v-model="batchDetail.doc.category"
:onCreate="(value, close) => openSettings('Categories', close)"
/>
</div>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold mb-4">
{{ __('Certification') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 items-start">
<div class="flex flex-col space-y-5">
<FormControl
v-model="batchDetail.doc.evaluation"
type="checkbox"
:label="__('Evaluation')"
/>
<FormControl
v-if="batchDetail.doc.evaluation"
v-model="batchDetail.doc.evaluation_end_date"
:label="__('Evaluation End Date')"
type="date"
class="mb-4"
/>
</div>
<div>
<FormControl
v-model="batchDetail.doc.certification"
type="checkbox"
:label="__('Certification')"
/>
</div>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="grid grid-cols-2 gap-5">
<MultiSelect
v-model="instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
<FormControl
v-model="batchDetail.doc.description"
:label="__('Short Description')"
type="textarea"
:rows="4"
:placeholder="__('Short description of the batch')"
:required="true"
/>
</div>
<div>
<label class="block text-sm text-ink-gray-5 mb-2">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</label>
<TextEditor
:content="batchDetail.doc.batch_details"
@change="(val) => (batchDetail.doc.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[16rem] overflow-y-scroll mb-4"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<FormControl
v-model="batchDetail.doc.medium"
type="select"
:options="mediumOptions"
:label="__('Medium')"
class="mb-4"
/>
<Link
doctype="Email Template"
:label="__('Enrollment Confirmation Email Template')"
v-model="batchDetail.doc.confirmation_email_template"
:onCreate="
(value, close) => {
openSettings('Email Templates', close)
}
"
/>
</div>
<div class="space-y-5">
<Link
doctype="LMS Zoom Settings"
:label="__('Zoom Account')"
v-model="batchDetail.doc.zoom_account"
:onCreate="
(value, close) => {
openSettings('Zoom Accounts', close)
}
"
/>
<Uploader
v-model="batchDetail.doc.video_link"
:label="__('Preview Video')"
type="video"
:required="false"
/>
</div>
</div>
</div>
<div class="px-5 pb-5 space-y-5 border-b mb-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Pricing') }}
</div>
<FormControl
v-model="batchDetail.doc.paid_batch"
type="checkbox"
:label="__('Paid Batch')"
/>
<div
v-if="batchDetail.doc.paid_batch"
class="grid grid-cols-1 md:grid-cols-2 gap-5"
>
<FormControl
v-model="batchDetail.doc.amount"
:label="__('Amount')"
type="number"
/>
<Link
doctype="Currency"
v-model="batchDetail.doc.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
/>
</div>
</div>
<div class="px-5 pb-5 space-y-5">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Meta Tags') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="meta.description"
:label="__('Meta Description')"
type="textarea"
:rows="4"
/>
<FormControl
v-model="meta.keywords"
:label="__('Meta Keywords')"
type="textarea"
:rows="4"
:placeholder="__('Comma separated keywords')"
/>
<Uploader
v-model="batchDetail.doc.meta_image"
:label="__('Meta Image')"
type="image"
:required="false"
/>
</div>
</div>
</div>
<div class="border-l min-w-0">
<div class="border-b p-4">
<BatchCourses :batch="batch" />
</div>
<div class="p-4">
<Assessments :batch="batch.data?.name" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
computed,
getCurrentInstance,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
toRaw,
watch,
nextTick,
} from 'vue'
import {
FormControl,
TextEditor,
createDocumentResource,
toast,
call,
} from 'frappe-ui'
import {
escapeHTML,
getMetaInfo,
openSettings,
sanitizeHTML,
updateMetaInfo,
} from '@/utils'
import { useRouter } from 'vue-router'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '@/stores/session'
import Uploader from '@/components/Controls/Uploader.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Link from '@/components/Controls/Link.vue'
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
import Assessments from '@/pages/Batches/components/Assessments.vue'
const router = useRouter()
const user = inject('$user')
const { brand } = sessionStore()
const { updateOnboardingStep } = useOnboarding('learning')
const instructors = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const originalDoc = ref(null)
const meta = reactive({
description: '',
keywords: '',
})
const props = defineProps({
batch: {
type: Object,
required: true,
},
})
onMounted(() => {
if (!user.data) window.location.href = '/login'
window.addEventListener('keydown', keyboardShortcut)
})
const keyboardShortcut = (e) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
!e.target.classList.contains('ProseMirror')
) {
submitBatch()
e.preventDefault()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const batchDetail = createDocumentResource({
doctype: 'LMS Batch',
name: props.batch.data?.name,
auto: true,
})
watch(
() => batchDetail.doc,
() => {
if (!batchDetail.doc) return
getMetaInfo('batches', batchDetail.doc?.name, meta)
updateBatchData()
}
)
const updateBatchData = () => {
Object.keys(batchDetail.doc).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
batchDetail.doc.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (['start_time', 'end_time'].includes(key)) {
batchDetail.doc[key] = formatTime(batchDetail.doc[key])
}
})
let checkboxes = [
'published',
'paid_batch',
'allow_self_enrollment',
'certification',
'evaluation',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
batchDetail.doc[key] = batchDetail.doc[key] ? true : false
}
originalDoc.value = structuredClone(toRaw(batchDetail.doc))
}
const formatTime = (timeStr) => {
let [hours, minutes, seconds] = timeStr.split(':')
hours = hours.length == 1 ? '0' + hours : hours
return `${hours}:${minutes}`
}
const validateFields = () => {
batchDetail.doc.description = sanitizeHTML(batchDetail.doc.description)
batchDetail.doc.batch_details = sanitizeHTML(batchDetail.doc.batch_details)
Object.keys(batchDetail.doc).forEach((key) => {
if (
!['description', 'batch_details'].includes(key) &&
typeof batchDetail.doc[key] === 'string'
) {
batchDetail.doc[key] = escapeHTML(batchDetail.doc[key])
}
})
}
const submitBatch = () => {
validateFields()
updateBatch()
}
const updateBatch = () => {
batchDetail.setValue.submit(
{
...batchDetail.doc,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data) {
updateMetaInfo('batches', data.name, meta)
toast.success(__('Batch updated successfully'))
nextTick(() => {
originalDoc.value = structuredClone(data)
isDirty.value = false
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
watch(
() => batchDetail.doc,
() => {
if (originalDoc.value) {
isDirty.value =
JSON.stringify(batchDetail.doc) !== JSON.stringify(originalDoc.value)
}
},
{ deep: true }
)
const deleteBatch = () => {
$dialog({
title: __('Confirm your action to delete'),
message: __(
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick({ close }) {
trashBatch(close)
close()
},
},
],
})
}
const trashBatch = (close) => {
call('lms.lms.api.delete_batch', {
batch: props.batch.data.name,
}).then(() => {
toast.success(__('Batch deleted successfully'))
close()
router.push({
name: 'Batches',
})
})
}
const mediumOptions = computed(() => {
return [
{
label: 'Online',
value: 'Online',
},
{
label: 'Offline',
value: 'Offline',
},
]
})
defineExpose({
submitBatch,
deleteBatch,
isDirty,
})
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div class="m-5 pb-10">
<div class="flex justify-between w-full">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ batch.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ batch.data.description }}
</div>
<div class="flex avatar-group overlap">
<div
class="h-6 mr-1"
:class="{
'avatar-group overlap': batch.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in batch.data.instructors"
:user="instructor"
/>
</div>
<CourseInstructors :instructors="batch.data.instructors" />
</div>
<BatchOverlay :batch="batch" class="md:hidden mt-5" />
<div
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
v-html="batch.data.batch_details"
></div>
</div>
<div class="hidden md:block">
<BatchOverlay :batch="batch" />
</div>
</div>
<div v-if="courses.data?.length">
<div class="flex items-center mt-10">
<div class="text-2xl font-semibold text-ink-gray-9">
{{ __('Courses') }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mt-5">
<div
v-if="courses.data?.length"
v-for="course in courses.data"
:key="course.course"
>
<router-link
:to="{
name: 'CourseDetail',
params: {
courseName: course.name,
},
}"
>
<CourseCard :course="course" :key="course.name" />
</router-link>
</div>
</div>
<div v-if="batch.data.batch_details_raw">
<div
v-html="batch.data.batch_details_raw"
class="batch-description"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue'
import BatchOverlay from '@/pages/Batches/components/BatchOverlay.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const props = defineProps({
batch: {
type: Object,
default: null,
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batch?.data?.name,
},
cache: ['batchCourses', props.batch?.data?.name],
auto: true,
})
</script>

View File

@@ -10,10 +10,7 @@
label: __('New Batch'),
icon: 'users',
onClick() {
router.push({
name: 'BatchForm',
params: { batchName: 'new' },
})
showBatchModal = true
},
},
{
@@ -45,20 +42,6 @@
</Button>
</template>
</Dropdown>
<!-- <router-link
v-if="canCreateBatch()"
:to="{
name: 'BatchForm',
params: { batchName: 'new' },
}"
>
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Create') }}
</Button>
</router-link> -->
</header>
<div class="p-5 pb-10">
<div
@@ -125,6 +108,11 @@
</Button>
</div>
</div>
<NewBatchModal
v-if="showBatchModal"
v-model="showBatchModal"
:batches="batches"
/>
</template>
<script setup>
import {
@@ -141,8 +129,9 @@ import { computed, inject, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronDown, Plus } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import BatchCard from '@/components/BatchCard.vue'
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewBatchModal from '@/pages/Batches/components/NewBatchModal.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
@@ -159,6 +148,7 @@ const currentTab = ref(is_student.value ? 'all' : 'upcoming')
const orderBy = ref('start_date')
const readOnlyMode = window.read_only_mode
const router = useRouter()
const showBatchModal = ref(false)
onMounted(() => {
setFiltersFromQuery()

View File

@@ -0,0 +1,277 @@
<template>
<div v-if="batch?.data" class="p-5">
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
<NumberChartGraph
:title="__('Enrolled')"
:value="formatAmount(batch.data?.students?.length) || 0"
/>
<NumberChartGraph
:title="__('Certified')"
:value="certificationCount.data || 0"
/>
<NumberChartGraph
class="border rounded-md"
:title="__('Courses')"
:value="batch?.data?.courses?.length || 0"
/>
<NumberChartGraph
class="border rounded-md"
:title="__('Assessments')"
:value="batch?.data?.assessments?.length || 0"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-5 items-start">
<div class="border rounded-lg py-3 px-4 order-2 lg:order-1">
<div class="flex items-center justify-between space-x-2 mb-3">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Students') }}
</div>
<div class="flex items-center space-x-2">
<FormControl
v-model="searchFilter"
:placeholder="__('Search by name')"
type="text"
/>
<Button @click="showEnrollmentModal = true">
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Enroll') }}
</Button>
</div>
</div>
<div
v-if="students.loading || students.data?.length"
class="max-h-[63vh] overflow-y-auto"
>
<ListView
:columns="studentColumns"
:rows="students.data"
rowKey="name"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
currentStudent = row.member
showProgressModal = true
},
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-white border-b rounded-none p-2"
>
<ListHeaderItem
:item="item"
v-for="item in studentColumns"
:key="item.key"
>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in students.data" class="max-h-[500px]">
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
<!-- <ProgressBar
v-else-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4"
/> -->
</template>
<div v-if="column.key == 'creation'">
{{ dayjs(row[column.key]).format('DD MMM YYYY') }}
</div>
<div
v-else-if="column.key == 'progress'"
class="text-xs !mx-0 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<div
v-if="students.data && students.hasNextPage"
class="flex justify-center my-3"
>
<Button @click="students.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="order-1 lg:order-2">
<AxisChart
v-if="showProgressChart"
class="border rounded-lg p-3 min-h-[300px]"
:config="{
data: filteredChartData,
title: __('Batch Summary'),
subtitle: __('Progress of students in courses and assessments'),
xAxis: {
key: 'task',
title: 'Tasks',
type: 'category',
},
yAxis: {
title: __('Number of Students'),
echartOptions: {
minInterval: 1,
},
},
series: [
{
name: 'value',
type: 'bar',
},
],
}"
/>
<div class="p-4 border rounded-lg mt-5">
<BatchFeedback v-if="batch.data" :batch="batch.data.name" />
</div>
</div>
</div>
</div>
<StudentModal
v-if="showEnrollmentModal"
v-model="showEnrollmentModal"
:batch="batch"
:students="students"
/>
<BatchStudentProgress
v-if="showProgressModal"
v-model="showProgressModal"
:student="currentStudent"
:batch="batch?.data?.name"
/>
</template>
<script setup lang="ts">
import {
AxisChart,
createResource,
createListResource,
dayjs,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Avatar,
Button,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { formatAmount } from '@/utils'
import { Plus } from 'lucide-vue-next'
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
import BatchStudentProgress from '@/pages/Batches/components/BatchStudentProgress.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
import StudentModal from '@/components/Modals/StudentModal.vue'
const searchFilter = ref<string | null>(null)
const showEnrollmentModal = ref<boolean>(false)
const showProgressModal = ref<boolean>(false)
const currentStudent = ref<any>(null)
const props = defineProps<{
batch: { [key: string]: any } | null
}>()
const chartData = createResource({
url: 'lms.lms.utils.get_batch_chart_data',
cache: ['batch_chart_data', props.batch?.data?.name],
params: { batch: props.batch?.data?.name },
auto: true,
})
const certificationCount = createResource({
url: 'frappe.client.get_count',
cache: ['batch_certificate_count', props.batch?.data?.name],
params: {
doctype: 'LMS Certificate',
filters: { batch_name: props.batch?.data?.name },
},
auto: true,
})
const students = createListResource({
doctype: 'LMS Batch Enrollment',
filters: {
batch: props.batch?.data?.name,
},
fields: [
'name',
'member',
'member_name',
'member_username',
'member_image',
'creation',
],
orderBy: 'creation desc',
auto: true,
})
const filteredChartData = computed(() =>
(chartData.data || []).filter((item: { value: number }) => item.value > 0)
)
watch(searchFilter, () => {
let filters: Record<string, any> = {
batch: props.batch?.data?.name,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
}
students.update({ filters })
students.reload()
})
const studentColumns = computed(() => {
return [
{
label: __('Name'),
key: 'member_name',
width: '40%',
},
{
label: __('Enrolled On'),
key: 'creation',
align: 'right',
},
]
})
const showProgressChart = computed(
() =>
students.data?.length &&
(props.batch?.data?.courses?.length ||
props.batch?.data?.assessments?.length)
)
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="w-[90%] lg:w-[75%] mx-auto mt-5">
<div class="text-ink-gray-9 font-semibold text-lg mb-5">
{{ __('Announcements') }}
</div>
<div v-if="communications.data?.length">
<div v-for="comm in communications.data">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<Avatar :label="comm.sender_full_name" size="lg" />
<div class="ml-2 text-ink-gray-7">
{{ comm.sender_full_name }}
</div>
</div>
<div class="text-sm">
{{ timeAgo(comm.communication_date) }}
</div>
</div>
<div
class="prose prose-sm bg-surface-menu-bar !min-w-full px-4 py-2 rounded-md"
v-html="comm.content"
></div>
</div>
</div>
</div>
<div v-else class="text-ink-gray-7 leading-5">
{{ __('No announcements have been made yet for this batch') }}
</div>
</div>
</template>
<script setup>
import { createResource, Avatar } from 'frappe-ui'
import { timeAgo } from '@/utils'
const props = defineProps({
batch: {
type: Object,
required: true,
},
})
const communications = createResource({
url: 'lms.lms.api.get_announcements',
makeParams(value) {
return {
batch: props.batch.data?.name,
}
},
auto: true,
cache: ['announcement', props.batch],
})
</script>
<style>
.prose-sm p {
margin: 0 0 0.5rem;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="text-lg font-semibold text-ink-gray-9">
<div class="text-ink-gray-9 font-semibold">
{{ __('Assessments') }}
</div>
<Button v-if="canAddAssessments()" @click="showModal = true">
@@ -16,6 +16,7 @@
:columns="getAssessmentColumns()"
:rows="assessments.data"
row-key="name"
class="border rounded-lg"
:options="{
showTooltip: false,
getRowRoute: (row) => getRowRoute(row),
@@ -23,20 +24,17 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
<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 assessments.data">
<ListRow
:row="row"
v-for="row in assessments.data"
class="!rounded-none"
>
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key == 'assessment_type'">
@@ -57,7 +55,7 @@
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<ListSelectBanner class="!min-w-0">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
@@ -71,8 +69,8 @@
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No Assessments') }}
<div v-else class="text-ink-gray-7">
{{ __('No assessments added to this batch') }}
</div>
</div>
<AssessmentModal
@@ -210,12 +208,11 @@ const getAssessmentColumns = () => {
{
label: __('Assessment'),
key: 'title',
width: '25rem',
},
{
label: __('Type'),
key: 'assessment_type',
width: '15rem',
width: '10rem',
},
]

View File

@@ -1,21 +1,22 @@
<template>
<div>
<div class="flex items-center justify-between mb-4">
<div class="font-medium text-ink-gray-9">
<div class="text-ink-gray-9 font-semibold">
{{ __('Courses') }}
</div>
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
<Button v-if="isAdmin()" @click="openCourseModal()">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
{{ __('Add') }}
</Button>
</div>
<div v-if="courses.data?.length">
<div v-if="courses.data?.length" class="text-sm">
<ListView
:columns="getCoursesColumns()"
:rows="courses.data"
row-key="batch_course"
row-key="name"
class="border rounded-lg"
:options="{
showTooltip: false,
selectable: user.data?.is_student ? false : true,
@@ -26,20 +27,13 @@
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
<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 courses.data">
<ListRow :row="row" v-for="row in courses.data" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div>
@@ -49,7 +43,7 @@
</template>
</ListRow>
</ListRows>
<ListSelectBanner>
<ListSelectBanner class="!min-w-0">
<template #actions="{ unselectAll, selections }">
<div class="flex gap-2">
<Button
@@ -63,21 +57,21 @@
</ListSelectBanner>
</ListView>
</div>
<div v-else class="text-sm italic text-ink-gray-5">
{{ __('No courses added') }}
<div v-else class="text-ink-gray-7">
{{ __('No courses added to this batch') }}
</div>
<BatchCourseModal
v-model="showCourseModal"
:batch="batch"
:batch="batch.data?.name"
v-model:courses="courses"
/>
</div>
</template>
<script setup>
import { ref, inject } from 'vue'
import { ref, inject, nextTick } from 'vue'
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
import {
createResource,
createListResource,
Button,
ListHeader,
ListHeaderItem,
@@ -96,16 +90,20 @@ const user = inject('$user')
const props = defineProps({
batch: {
type: String,
type: Object,
required: true,
},
})
const courses = createResource({
url: 'lms.lms.utils.get_batch_courses',
params: {
batch: props.batch,
const courses = createListResource({
doctype: 'Batch Course',
filters: {
parent: props.batch.data?.name,
parenttype: 'LMS Batch',
},
fields: ['name', 'course', 'title', 'evaluator'],
parent: 'LMS Batch',
orderBy: 'idx',
auto: true,
})
@@ -118,47 +116,25 @@ const getCoursesColumns = () => {
{
label: 'Title',
key: 'title',
width: 2,
},
{
label: 'Lessons',
key: 'lessons',
align: 'right',
},
{
label: 'Enrollments',
align: 'right',
key: 'enrollments',
label: 'Evaluator',
key: 'evaluator',
width: '10rem',
},
]
}
const deleteCourses = createResource({
url: 'lms.lms.api.delete_documents',
makeParams(values) {
return {
doctype: 'Batch Course',
documents: values.courses,
}
},
})
const removeCourses = async (selections, unselectAll) => {
for (const course of selections) {
await courses.delete.submit(course)
}
const removeCourses = (selections, unselectAll) => {
deleteCourses.submit(
{
courses: Array.from(selections),
},
{
onSuccess(data) {
courses.reload()
toast.success(__('Courses deleted successfully'))
unselectAll()
},
}
)
unselectAll()
toast.success(__('Courses deleted successfully'))
}
const canSeeAddButton = () => {
const isAdmin = () => {
if (readOnlyMode) {
return false
}

View File

@@ -0,0 +1,137 @@
<template>
<div class="h-[88vh]">
<div class="grid grid-cols-1 lg:grid-cols-[2fr,1fr] gap-5">
<div class="p-5">
<div class="mb-8 space-y-2">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Curriculum') }}
</div>
<div class="text-ink-gray-7">
{{
__(
"As a part of this batch's curriculum you will have to complete the following courses and assessments."
)
}}
</div>
</div>
<div class="space-y-10">
<div>
<div class="text-ink-gray-9 font-semibold mb-4">
{{ __('Courses') }}
</div>
<ListView
v-if="batch.data?.courses?.length"
:columns="courseColumns"
:rows="batch.data?.courses"
row-key="name"
class="border rounded-lg"
:options="{
showTooltip: false,
selectable: user.data?.is_student ? false : true,
getRowRoute: (row) => ({
name: 'CourseDetail',
params: { courseName: row.course },
}),
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows>
<ListRow
:row="row"
v-for="row in batch.data?.courses"
class="!rounded-none text-sm"
>
<template #default="{ column, item }">
<ListRowItem :item="row[column.key]" :align="column.align">
<div v-if="column.key === 'progress'">
{{ getProgress(row.course) }}%
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<div v-else class="text-ink-gray-7">
{{ __('No courses added to this batch') }}
</div>
</div>
<!-- <BatchCourses :batch="batch" /> -->
<Assessments :batch="batch.data.name" />
</div>
</div>
<div class="border-l h-[88vh] divide-y">
<div v-if="batch.data?.evaluation" class="p-4 mb-5">
<UpcomingEvaluations
:batch="batch.data.name"
:endDate="batch.data.evaluation_end_date"
:courses="batch.data.courses"
/>
</div>
<div class="p-5">
<BatchFeedback :batch="batch.data?.name" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue'
import {
createListResource,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import Assessments from '@/pages/Batches/components/Assessments.vue'
import BatchCourses from '@/pages/Batches/components/BatchCourses.vue'
import BatchFeedback from '@/pages/Batches/components/BatchFeedback.vue'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const user = inject('$user')
const props = defineProps({
batch: {
type: Object,
default: null,
},
isStudent: {
type: Boolean,
default: false,
},
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
member: user.data?.name,
course: ['in', props.batch.data?.courses?.map((c) => c.course)],
},
fields: ['course', 'progress', 'name'],
auto: true,
})
const getProgress = (course) => {
const progress = progressList.data?.find((p) => p.course === course)
return progress ? Math.round(progress.progress) : 0
}
const courseColumns = [
{
key: 'title',
label: __('Course'),
},
{
key: 'progress',
label: __('Progress'),
align: 'right',
},
]
</script>

View File

@@ -1,63 +1,77 @@
<template>
<div v-if="user.data?.is_student">
<div>
<div class="leading-5 mb-4 text-ink-gray-7">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
<div>
<div class="flex justify-between mb-5">
<div class="space-y-1">
<div class="text-lg text-ink-gray-9 font-semibold">
{{ __('Feedback') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
<div
v-if="feedbackList.data?.length && isAdmin"
class="leading-5 text-ink-gray-7 text-sm mb-2 mt-5"
>
{{ __('Average Feedback Received') }}
</div>
</div>
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
<Button
v-if="feedbackList.data?.length && isAdmin"
variant="outline"
@click="showAllFeedback = true"
>
{{ __('View all feedback') }}
</Button>
</div>
<div v-if="user.data?.is_student">
<div>
<div class="leading-5 mb-4 text-ink-gray-7">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@click="showFeedbackForm = !showFeedbackForm"
class="underline cursor-pointer"
>{{ __('Click here') }}</span
>
{{ __('to view your feedback.') }}
</div>
<div v-else>
{{ __('Help us improve by providing your feedback.') }}
</div>
</div>
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="feedback[key]"
:label="__(convertToTitleCase(key))"
:readonly="readOnly"
/>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
<FormControl
v-model="feedback.feedback"
type="textarea"
:label="__('Feedback')"
:rows="9"
:readonly="readOnly"
/>
<Button v-if="!readOnly" @click="submitFeedback">
{{ __('Submit Feedback') }}
</Button>
</div>
</div>
</div>
<div v-else-if="feedbackList.data?.length">
<div class="leading-5 text-sm mb-2 mt-5">
{{ __('Average Feedback Received') }}
<div v-else-if="feedbackList.data?.length">
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
:label="__(convertToTitleCase(key))"
:readonly="true"
/>
</div>
</div>
<div class="space-y-4">
<Rating
v-for="key in ratingKeys"
v-model="average[key]"
:label="__(convertToTitleCase(key))"
:readonly="true"
/>
<div v-else class="text-ink-gray-7 leading-5">
{{ __('No feedback received yet.') }}
</div>
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
{{ __('View all feedback') }}
</Button>
</div>
<div v-else class="text-ink-gray-7 mt-5 leading-5">
{{ __('No feedback received yet.') }}
</div>
<FeedbackModal
v-if="feedbackList.data?.length"
@@ -66,7 +80,7 @@
/>
</template>
<script setup>
import { inject, onMounted, reactive, ref, watch } from 'vue'
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
import { convertToTitleCase } from '@/utils'
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
@@ -159,10 +173,15 @@ const submitFeedback = () => {
onSuccess: () => {
feedbackList.reload()
showFeedbackForm.value = false
readOnly.value = true
},
}
)
}
const isAdmin = computed(() => {
return user.data?.is_moderator || user.data?.is_evaluator
})
</script>
<style>
.feedback-list > button > div {

View File

@@ -26,7 +26,7 @@
/>
<div
v-if="batch.data.amount"
class="text-lg font-semibold mb-3 text-ink-gray-9"
class="text-lg font-semibold mb-5 text-ink-gray-9"
>
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
</div>
@@ -57,25 +57,6 @@
</div>
<div v-if="!readOnlyMode">
<router-link
v-if="canAccessBatch"
:to="{
name: 'Batch',
params: {
batchName: batch.data.name,
},
}"
>
<Button variant="solid" class="w-full mt-4">
<template #prefix>
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
<Settings v-else class="size-4 stroke-1.5" />
</template>
<span>
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
</span>
</Button>
</router-link>
<router-link
:to="{
name: 'Billing',
@@ -84,13 +65,13 @@
name: batch.data.name,
},
}"
v-else-if="
v-if="
batch.data.paid_batch &&
batch.data.seats_left > 0 &&
batch.data.accept_enrollments
"
>
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
<Button v-if="!canAccessBatch" class="w-full mt-4" variant="solid">
<template #prefix>
<CreditCard class="size-4 stroke-1.5" />
</template>
@@ -114,24 +95,6 @@
</template>
{{ __('Enroll Now') }}
</Button>
<router-link
v-if="canEditBatch"
:to="{
name: 'BatchForm',
params: {
batchName: batch.data.name,
},
}"
>
<Button class="w-full mt-2">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
</div>
</template>
@@ -174,7 +137,7 @@ const enroll = createResource({
const enrollInBatch = () => {
if (!user.data) {
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
window.location.href = `/login?redirect-to=/batches/${props.batch.data.name}`
}
enroll.submit(
{},

View File

@@ -0,0 +1,222 @@
<template>
<Dialog
v-model="show"
:options="{
size: 'xl',
}"
>
<template #body>
<div v-if="studentDetails.data" class="p-5 space-y-10 text-sm">
<div class="flex items-center space-x-2">
<Avatar :image="studentDetails.data.user_image" size="3xl" />
<div class="space-y-1">
<div class="flex items-center space-x-2">
<div class="text-xl font-semibold text-ink-gray-9">
{{ studentDetails.data.full_name }}
</div>
<Badge
v-if="
Object.keys(studentDetails.data.assessments).length ||
Object.keys(studentDetails.data.courses).length
"
:theme="studentDetails.data.progress === 100 ? 'green' : 'red'"
>
{{ studentDetails.data.progress }}% {{ __('Complete') }}
</Badge>
</div>
<div class="text-sm text-ink-gray-7">
{{ studentDetails.data.email }}
</div>
</div>
</div>
<div class="space-y-8">
<!-- Assessments -->
<ListView
:columns="assessmentColumns"
:rows="studentDetails.data.assessments"
row-key="title"
class="border border-outline-gray-modals rounded-lg"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
redirectToAssessment(row)
}
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows v-for="row in studentDetails.data.assessments">
<ListRow :row="row" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<div
v-if="column.key == 'status' && isAssignment(row.status)"
>
<Badge :theme="getStatusTheme(row[column.key])">
{{ row[column.key] }}
</Badge>
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
<!-- Courses -->
<ListView
:columns="courseColumns"
:rows="studentDetails.data.courses"
row-key="title"
class="border border-outline-gray-modals rounded-lg"
:options="{
selectable: false,
showTooltip: false,
onRowClick: (row: any) => {
redirectToCourse(row)
}
}"
>
<ListHeader
class="mb-2 grid items-center space-x-4 rounded-none rounded-t bg-surface-gray-2 p-2"
>
</ListHeader>
<ListRows v-for="row in studentDetails.data.courses">
<ListRow :row="row" class="!rounded-none">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
class="w-full"
>
<template #prefix>
<ProgressBar
v-if="column.key == 'progress'"
:progress="Math.ceil(row[column.key])"
class="!mx-0 !mr-4 max-w-32"
/>
</template>
<div
v-if="column.key == 'progress'"
class="text-xs !ml-0 !mr-3 w-5"
>
{{ Math.ceil(row[column.key]) }}%
</div>
<div v-else>
{{ row[column.key] }}
</div>
</ListRowItem>
</template>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Badge,
createResource,
Dialog,
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
} from 'frappe-ui'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ProgressBar.vue'
const show = defineModel()
const router = useRouter()
const props = defineProps<{
student: string
batch: string
}>()
const studentDetails = createResource({
url: 'lms.lms.utils.get_batch_student_progress',
makeParams() {
return {
member: props.student,
batch: props.batch,
}
},
auto: true,
})
const redirectToAssessment = (row: any) => {
console.log(row)
if (!row.submission) return
if (row.type == 'LMS Assignment') {
router.push({
name: 'AssignmentSubmission',
params: {
assignmentID: row.assessment,
submissionName: row.submission,
},
})
} else if (row.type == 'LMS Programming Exercise') {
router.push({
name: 'ProgrammingExerciseSubmission',
params: {
exerciseID: row.assessment,
submissionID: row.submission,
},
})
} else if (row.type == 'LMS Quiz') {
router.push({
name: 'QuizSubmission',
params: {
submission: row.submission,
},
})
}
}
const redirectToCourse = (row: any) => {
router.push({
name: 'CourseDetail',
params: {
courseName: row.course,
},
})
}
const assessmentColumns = [
{ key: 'title', label: 'Assessment', align: 'left', width: '60%' },
{ key: 'status', label: 'Percentage/Status', align: 'right' },
]
const courseColumns = [
{ key: 'title', label: 'Course', align: 'left', width: '70%' },
{ key: 'progress', label: 'Progress', align: 'right' },
]
const isAssignment = (value: any) => {
return isNaN(value)
}
const getStatusTheme = (status: string) => {
if (status === 'Pass') {
return 'green'
} else if (status == 'Not Graded') {
return 'orange'
} else {
return 'red'
}
}
</script>

View File

@@ -0,0 +1,227 @@
<template>
<div class="p-5">
<div
v-if="isAdmin() && !batch.data?.zoom_account"
class="flex lg:items-center space-x-2 mb-5 bg-surface-amber-1 px-3 py-2 rounded-lg text-ink-amber-3"
>
<AlertCircle class="size-7 md:size-4 stroke-1.5" />
<span class="leading-5">
{{
__(
'Link a Zoom account to this batch from the Settings tab to create live classes'
)
}}
</span>
</div>
<div class="flex items-center justify-between">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Live Class') }}
</div>
<Button v-if="canCreateClass()" @click="openLiveClassModal">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
<span>
{{ __('Add') }}
</span>
</Button>
</div>
<div
v-if="liveClasses.data?.length"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mt-5"
>
<div
v-for="cls in liveClasses.data"
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
:class="{
'cursor-pointer': isAdmin() && cls.attendees > 0,
}"
@click="
() => {
openAttendanceModal(cls)
}
"
>
<div class="font-semibold text-ink-gray-9 mb-1">
{{ cls.title }}
</div>
<div class="short-introduction">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3">
<div class="flex items-center space-x-2">
<Calendar class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
</span>
</div>
<div class="flex items-center space-x-2">
<Clock class="w-4 h-4 stroke-1.5" />
<span>
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
</span>
</div>
<div
v-if="canAccessClass(cls)"
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
>
<a
v-if="user.data?.is_moderator || user.data?.is_evaluator"
:href="cls.start_url"
target="_blank"
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" />
{{ __('Start') }}
</a>
<a
:href="cls.join_url"
target="_blank"
class="w-full 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"
>
<Video class="h-4 w-4 stroke-1.5" />
{{ __('Join') }}
</a>
</div>
<Tooltip
v-else-if="hasClassEnded(cls)"
:text="__('This class has ended')"
placement="right"
>
<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 v-else class="text-ink-gray-7 mt-5">
{{ __('No live classes scheduled') }}
</div>
</div>
<LiveClassModal
v-if="showLiveClassModal"
v-model="showLiveClassModal"
:batch="batch.data?.name"
:zoomAccount="batch.data?.zoom_account"
v-model:reloadLiveClasses="liveClasses"
/>
<LiveClassAttendance
v-if="showAttendance"
v-model="showAttendance"
:live_class="attendanceFor"
/>
</template>
<script setup>
import { createListResource, Button, Tooltip } from 'frappe-ui'
import {
Plus,
Clock,
Calendar,
Video,
Monitor,
Info,
AlertCircle,
} from 'lucide-vue-next'
import { inject, ref } from 'vue'
import { formatTime } from '@/utils/'
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
const user = inject('$user')
const showLiveClassModal = ref(false)
const dayjs = inject('$dayjs')
const readOnlyMode = window.read_only_mode
const showAttendance = ref(false)
const attendanceFor = ref(null)
const props = defineProps({
batch: {
type: Object,
required: true,
},
})
const liveClasses = createListResource({
doctype: 'LMS Live Class',
filters: {
batch_name: props.batch.data?.name,
},
fields: [
'title',
'description',
'time',
'date',
'duration',
'attendees',
'start_url',
'join_url',
'owner',
],
orderBy: 'date',
auto: true,
})
const openLiveClassModal = () => {
showLiveClassModal.value = true
}
const canCreateClass = () => {
if (readOnlyMode) return false
if (!props.batch.data?.zoom_account) return false
return isAdmin()
}
const isAdmin = () => {
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 getClassStart = (cls) => {
return new Date(`${cls.date}T${cls.time}`)
}
const getClassEnd = (cls) => {
const classStart = getClassStart(cls)
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 (!isAdmin()) return
if (cls.attendees <= 0) return
showAttendance.value = true
attendanceFor.value = cls
}
</script>
<style>
.short-introduction {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
margin: 0.25rem 0 1.5rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('New Batch'),
size: '3xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-2 gap-10">
<div class="space-y-5">
<FormControl
v-model="batch.title"
:label="__('Title')"
:required="true"
/>
<FormControl
v-model="batch.start_date"
:label="__('Start Date')"
type="date"
:required="true"
/>
<FormControl
v-model="batch.end_date"
:label="__('End Date')"
type="date"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="batch.category"
:label="__('Category')"
:allowCreate="true"
:onCreate="
() => {
openSettings('Categories')
show = false
}
"
/>
</div>
<div class="space-y-5">
<FormControl
v-model="batch.start_time"
:label="__('Start Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.end_time"
:label="__('End Time')"
type="time"
:required="true"
/>
<FormControl
v-model="batch.timezone"
:label="__('Timezone')"
:required="true"
/>
<FormControl
v-model="batch.seat_count"
:label="__('Seat Count')"
type="number"
:required="false"
/>
</div>
</div>
<div class="space-y-5 border-t mt-5 pt-5">
<MultiSelect
v-model="batch.instructors"
doctype="Course Evaluator"
:label="__('Instructors')"
:required="true"
:onCreate="(close: () => void) => openSettings('Evaluators', close)"
:filters="{ ignore_user_type: 1 }"
/>
<FormControl
v-model="batch.description"
:label="__('Description')"
type="textarea"
:required="true"
:rows="4"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Batch Details') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="batch.batch_details"
@change="(val: string) => (batch.batch_details = val)"
:editable="true"
:fixedMenu="true"
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[10rem]"
/>
</div>
</div>
</div>
</template>
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="saveBatch(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { ref, inject, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { cleanError, openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
const show = defineModel<boolean>({ required: true, default: false })
const router = useRouter()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const user = inject<any>('$user')
const props = defineProps<{
batches: any
}>()
const batch = ref({
title: '',
start_date: null,
end_date: null,
start_time: null,
end_time: null,
timezone: null,
description: '',
batch_details: '',
instructors: [],
category: null,
seat_count: 0,
})
const saveBatch = (close: () => void = () => {}) => {
props.batches.insert.submit(
{
...batch.value,
instructors: batch.value.instructors.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data: any) {
toast.success(__('Batch created successfully'))
close()
capture('batch_created')
router.push({
name: 'BatchDetail',
params: { batchName: data.name },
hash: '#settings',
})
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_batch', true, false, () => {
localStorage.setItem('firstBatch', data.name)
})
}
},
onError(err: any) {
toast.error(cleanError(err.messages?.[0]))
console.error(err)
},
}
)
}
const keyboardShortcut = (e: KeyboardEvent) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
e.target &&
e.target instanceof HTMLElement &&
!e.target.classList.contains('ProseMirror')
) {
saveBatch()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
capture('batch_form_opened')
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
capture('batch_form_closed', {
data: batch.value,
})
})
</script>

View File

@@ -354,14 +354,12 @@ const updateLessonProgress = (value: string) => {
}
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.course.data?.name,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
@@ -397,7 +395,7 @@ const progressColumns = computed(() => {
width: '30%',
},
{
label: __('Start Date'),
label: __('Enrolled On'),
key: 'creation',
align: 'right',
},

View File

@@ -1,7 +1,7 @@
<template>
<div class="pl-5">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] overflow-hidden">
<div v-if="courseResource.doc" class="h-[88vh] overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%]">
<div v-if="courseResource.doc" class="lg:max-h-[88vh] lg:overflow-y-auto">
<div class="my-5">
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
@@ -80,7 +80,7 @@
<div class="pr-5 md:pr-10 pb-5 mb-5 space-y-5 border-b">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }}
{{ __('Publishing Settings') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div
@@ -191,59 +191,20 @@
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
{{ __('Pricing and Certification') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<FormControl
type="checkbox"
v-model="courseResource.doc.paid_course"
:label="__('Paid Course')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.enable_certification"
:label="__('Completion Certificate')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="courseResource.doc.paid_certificate"
:label="__('Paid Certificate')"
@change="makeFormDirty()"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-5">
<div
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
class="space-y-5"
>
<FormControl
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
v-model="courseResource.doc.course_price"
:label="__('Amount')"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@input="makeFormDirty()"
/>
<Link
v-if="courseResource.doc.paid_certificate"
doctype="Course Evaluator"
v-model="courseResource.doc.evaluator"
:label="__('Evaluator')"
:required="courseResource.doc.paid_certificate"
:onCreate="
(value, close) => openSettings('Evaluators', close)
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="space-y-5">
<Link
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
doctype="Currency"
v-model="courseResource.doc.currency"
:filters="{ enabled: 1 }"
@@ -254,11 +215,21 @@
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div v-if="courseResource.doc.paid_certificate" class="space-y-5">
<Link
doctype="Course Evaluator"
v-model="courseResource.doc.evaluator"
:label="__('Evaluator')"
:required="courseResource.doc.paid_certificate"
:onCreate="
(value, close) => openSettings('Evaluators', close)
"
@update:modelValue="makeFormDirty()"
/>
<FormControl
v-if="courseResource.doc.paid_certificate"
v-model="courseResource.doc.timezone"
:label="__('Timezone')"
:required="courseResource.doc.paid_certificate"
:placeholder="__('e.g. IST, UTC, GMT...')"
@input="makeFormDirty()"
/>
@@ -290,7 +261,7 @@
</div>
</div>
</div>
<div class="border-l h-[88vh] overflow-y-auto">
<div class="min-h-0 border-l">
<CourseOutline
v-if="courseResource.doc"
:courseName="courseResource.doc.name"
@@ -304,7 +275,6 @@
<script setup>
import {
TextEditor,
Button,
createResource,
createDocumentResource,
FormControl,
@@ -373,9 +343,9 @@ const courseResource = createDocumentResource({
watch(
() => courseResource.doc,
() => {
check_permission()
getMetaInfo('courses', courseResource.doc?.name, meta)
updateCourseData()
checkPermission()
}
)
@@ -516,11 +486,10 @@ const removeTag = (tag) => {
makeFormDirty()
}
const check_permission = () => {
const checkPermission = () => {
let user_is_instructor = false
if (user.data?.is_moderator) return
instructors.value.forEach((instructor) => {
instructors.value?.forEach((instructor) => {
if (!user_is_instructor && instructor == user.data?.name) {
user_is_instructor = true
}

View File

@@ -61,7 +61,7 @@
<div class="grid grid-cols-2 gap-2">
<FormControl
v-model="title"
:placeholder="__('Search by Title')"
:placeholder="__('Search')"
type="text"
class="w-full lg:min-w-0 lg:w-32 xl:w-40"
@input="updateCourses()"

View File

@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
title: __('Create Course'),
title: __('New Course'),
size: '3xl',
}"
>
@@ -18,8 +18,7 @@
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:allowCreate="true"
@create="
:onCreate="
() => {
openSettings('Categories')
show = false
@@ -67,7 +66,7 @@
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="saveCourse(close)">
{{ __('Create') }}
{{ __('Save') }}
</Button>
</div>
</template>
@@ -75,10 +74,11 @@
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { cleanError, openSettings } from '@/utils'
import Link from '@/components/Controls/Link.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Uploader from '@/components/Controls/Uploader.vue'

View File

@@ -210,7 +210,7 @@ import {
} from 'lucide-vue-next'
import { formatTime } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'
import BatchCard from '@/components/BatchCard.vue'
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
const user = inject<any>('$user')
const dayjs = inject<any>('$dayjs')

View File

@@ -1,9 +1,4 @@
<template>
<!-- <header
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="[{ label: __('Home'), route: { name: 'Home' } }]" />
</header> -->
<div class="w-full px-5 pt-5 pb-10">
<div class="space-y-2">
<div class="flex items-center justify-between">
@@ -11,9 +6,8 @@
{{ __('Hey') }}, {{ user.data?.full_name }} 👋
</div>
<div>
<TabButtons v-if="isAdmin" v-model="currentTab" :buttons="tabs" />
<div
v-else
v-if="!isAdmin"
@click="showStreakModal = true"
class="bg-surface-amber-2 px-2 py-1 rounded-md cursor-pointer"
>
@@ -35,19 +29,16 @@
:liveClasses="adminLiveClasses"
:evals="adminEvals"
/>
<StudentHome v-else :myLiveClasses="myLiveClasses" />
<StudentHome
v-else-if="currentTab === 'student'"
:myLiveClasses="myLiveClasses"
/>
</div>
<Streak v-model="showStreakModal" :streakInfo="streakInfo" />
</template>
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import {
Breadcrumbs,
call,
createResource,
TabButtons,
usePageMeta,
} from 'frappe-ui'
import { call, createResource, usePageMeta } from 'frappe-ui'
import { sessionStore } from '@/stores/session'
import StudentHome from '@/pages/Home/StudentHome.vue'
import AdminHome from '@/pages/Home/AdminHome.vue'
@@ -56,10 +47,10 @@ import Streak from '@/pages/Home/Streak.vue'
const user = inject<any>('$user')
const { brand } = sessionStore()
const evalCount = ref(0)
const currentTab = ref<'student' | 'instructor'>('instructor')
const currentTab = ref<'student' | 'instructor'>('student')
const showStreakModal = ref(false)
onMounted(() => {
const fetchEvalCount = () => {
call('frappe.client.get_count', {
doctype: 'LMS Certificate Request',
filters: {
@@ -70,7 +61,7 @@ onMounted(() => {
}).then((data: any) => {
evalCount.value = data
})
})
}
const isAdmin = computed(() => {
return (
@@ -80,6 +71,15 @@ const isAdmin = computed(() => {
)
})
onMounted(() => {
if (isAdmin.value) {
currentTab.value = 'instructor'
} else {
currentTab.value = 'student'
fetchEvalCount()
}
})
const myLiveClasses = createResource({
url: 'lms.lms.api.get_my_live_classes',
auto: !isAdmin.value ? true : false,
@@ -151,11 +151,6 @@ const subtitle = computed(() => {
}
})
const tabs = [
{ label: __('Student'), value: 'student' },
{ label: __('Instructor'), value: 'instructor' },
]
usePageMeta(() => {
return {
title: __('Home'),

View File

@@ -1,17 +1,17 @@
<template>
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-10">
<div class="mt-10 space-y-10">
<UpcomingEvaluations :forHome="true" />
<div v-if="myLiveClasses.data?.length">
<div class="font-semibold text-lg mb-3 text-ink-gray-9">
{{ __('Upcoming Live Classes') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
<div
v-for="cls in myLiveClasses.data"
class="border rounded-md hover:border-outline-gray-3 p-2"
class="border rounded-md hover:border-outline-gray-3 p-3"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
<div class="font-semibold text-ink-gray-9 leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-5 leading-5 mb-4">
@@ -72,7 +72,7 @@
</div>
</div>
<div v-if="myCourses.data?.length">
<div v-if="myCourses.data?.length" class="mt-10">
<div class="flex items-center justify-between mb-3">
<span class="font-semibold text-lg text-ink-gray-9">
{{
@@ -150,7 +150,7 @@ import {
Video,
} from 'lucide-vue-next'
import CourseCard from '@/components/CourseCard.vue'
import BatchCard from '@/components/BatchCard.vue'
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const dayjs = inject<any>('$dayjs')

View File

@@ -4,9 +4,14 @@
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<Button variant="solid" @click="saveJob()">
{{ __('Save') }}
</Button>
<div class="space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button variant="solid" @click="saveJob()">
{{ __('Save') }}
</Button>
</div>
</header>
<div class="py-5">
<div class="container border-b mb-4 pb-5">
@@ -109,15 +114,25 @@
</template>
<script setup>
import {
Badge,
Breadcrumbs,
call,
FormControl,
createResource,
createDocumentResource,
Button,
TextEditor,
usePageMeta,
toast,
} from 'frappe-ui'
import { computed, onMounted, reactive, inject } from 'vue'
import {
computed,
inject,
onMounted,
onBeforeUnmount,
reactive,
ref,
watch,
} from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { escapeHTML, sanitizeHTML } from '@/utils'
@@ -126,6 +141,8 @@ import Uploader from '@/components/Controls/Uploader.vue'
const user = inject('$user')
const router = useRouter()
const { brand } = sessionStore()
const isDirty = ref(false)
const originalJobData = ref(null)
const props = defineProps({
jobName: {
@@ -134,67 +151,6 @@ const props = defineProps({
},
})
const newJob = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'Job Opportunity',
company_logo: job.company_logo,
...job,
},
}
},
})
const updateJob = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
return {
doctype: 'Job Opportunity',
name: props.jobName,
fieldname: {
company_logo: job.company_logo,
...job,
},
}
},
})
const jobDetail = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'Job Opportunity',
name: props.jobName,
}
},
onSuccess(data) {
if (data.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
}
Object.keys(data).forEach((key) => {
if (Object.hasOwn(job, key)) job[key] = data[key]
})
},
})
const job = reactive({
job_title: '',
location: '',
country: '',
type: 'Full Time',
work_mode: 'On-site',
status: 'Open',
company_name: '',
company_website: '',
company_logo: null,
description: '',
company_email_address: '',
})
onMounted(() => {
if (!user.data) {
router.push({
@@ -202,22 +158,64 @@ onMounted(() => {
})
}
if (props.jobName != 'new') jobDetail.reload()
addKeyboardShortcuts()
if (props.jobName != 'new') jobDetails.reload()
window.addEventListener('keydown', keyboardShortcut)
})
const addKeyboardShortcuts = () => {
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveJob()
const job = reactive({
job_title: '',
type: '',
work_mode: '',
location: '',
country: '',
status: 'Open',
description: '',
company_name: '',
company_website: '',
company_email_address: '',
company_logo: '',
})
const jobDetails = createDocumentResource({
doctype: 'Job Opportunity',
name: props.jobName != 'new' ? props.jobName : undefined,
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
auto: props.jobName != 'new',
})
watch(
() => jobDetails?.doc,
() => {
if (!jobDetails.doc) return
if (jobDetails.doc.owner != user.data?.name && !user.data?.is_moderator) {
router.push({
name: 'Jobs',
})
}
})
}
if (jobDetails.doc) {
Object.assign(job, jobDetails.doc)
originalJobData.value = JSON.parse(JSON.stringify(jobDetails.doc))
}
}
)
watch(
job,
() => {
isDirty.value = Object.keys(job).some((key) => {
return job[key] != originalJobData.value?.[key]
})
},
{ deep: true }
)
const saveJob = () => {
validateJobFields()
if (jobDetail.data) {
if (jobDetails?.doc) {
editJobDetails()
} else {
createNewJob()
@@ -225,38 +223,46 @@ const saveJob = () => {
}
const createNewJob = () => {
newJob.submit(
{},
{
onSuccess(data) {
router.push({
name: 'JobDetail',
params: {
job: data.name,
},
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
call('frappe.client.insert', {
doc: {
doctype: 'Job Opportunity',
company_logo: job.company_logo,
...job,
},
})
.then((data) => {
router.push({
name: 'JobDetail',
params: {
job: data.name,
},
})
})
.catch((err) => {
toast.error(err.messages?.[0] || err)
console.error(err)
})
}
const editJobDetails = () => {
updateJob.submit(
{},
jobDetails.setValue.submit(
{
company_logo: job.company_logo,
...job,
},
{
onSuccess(data) {
jobDetails.reload()
router.push({
name: 'JobDetail',
params: {
job: data.name,
job: props.jobName,
},
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
@@ -271,27 +277,38 @@ const validateJobFields = () => {
})
}
const keyboardShortcut = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveJob()
}
}
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const jobTypes = computed(() => {
return [
{ label: 'Full Time', value: 'Full Time' },
{ label: 'Part Time', value: 'Part Time' },
{ label: 'Contract', value: 'Contract' },
{ label: 'Freelance', value: 'Freelance' },
{ label: __('Full Time'), value: 'Full Time' },
{ label: __('Part Time'), value: 'Part Time' },
{ label: __('Contract'), value: 'Contract' },
{ label: __('Freelance'), value: 'Freelance' },
]
})
const workModes = computed(() => {
return [
{ label: 'On site', value: 'On-site' },
{ label: 'Hybrid', value: 'Hybrid' },
{ label: 'Remote', value: 'Remote' },
{ label: __('On site'), value: 'On-site' },
{ label: __('Hybrid'), value: 'Hybrid' },
{ label: __('Remote'), value: 'Remote' },
]
})
const jobStatuses = computed(() => {
return [
{ label: 'Open', value: 'Open' },
{ label: 'Closed', value: 'Closed' },
{ label: __('Open'), value: 'Open' },
{ label: __('Closed'), value: 'Closed' },
]
})
@@ -302,8 +319,11 @@ const breadcrumbs = computed(() => {
route: { name: 'Jobs' },
},
{
label: props.jobName == 'new' ? __('New Job') : __('Edit Job'),
route: { name: 'JobForm' },
label: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
route:
props.jobName == 'new'
? {}
: { name: 'JobDetail', params: { job: props.jobName } },
},
]
return crumbs
@@ -311,7 +331,7 @@ const breadcrumbs = computed(() => {
usePageMeta(() => {
return {
title: props.jobName == 'new' ? __('New Job') : jobDetail.data?.job_title,
title: props.jobName == 'new' ? __('New Job') : jobDetails.doc?.job_title,
icon: brand.favicon,
}
})

View File

@@ -8,7 +8,9 @@
:items="[{ label: __('Jobs'), route: { name: 'Jobs' } }]"
/>
<router-link
v-if="user.data?.name"
v-if="
user.data?.name && settings.data?.allow_job_posting && !readOnlyMode
"
:to="{
name: 'JobForm',
params: {
@@ -16,7 +18,7 @@
},
}"
>
<Button v-if="!readOnlyMode" variant="solid">
<Button variant="solid">
<template #prefix>
<Plus class="h-4 w-4" />
</template>
@@ -123,7 +125,8 @@ import {
usePageMeta,
} from 'frappe-ui'
import { Plus, Search } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import { sessionStore } from '@/stores/session'
import { useSettings } from '@/stores/settings'
import { inject, computed, ref, onMounted, watch } from 'vue'
import JobCard from '@/components/JobCard.vue'
import Link from '@/components/Controls/Link.vue'
@@ -133,6 +136,7 @@ const user = inject('$user')
const jobType = ref(null)
const workMode = ref(null)
const { brand } = sessionStore()
const { settings } = useSettings()
const searchQuery = ref('')
const country = ref(null)
const filters = ref({})

View File

@@ -400,7 +400,7 @@ const sidebarStore = useSidebar()
const plyrSources = ref([])
const showInlineMenu = ref(false)
const currentTab = ref('Notes')
let timerInterval
let timerInterval = null
const tabs = ref([
{
@@ -742,7 +742,7 @@ const updateVideoTime = (video) => {
const startTimer = () => {
if (!lesson.data?.membership) return
let timerInterval = setInterval(() => {
timerInterval = setInterval(() => {
timer.value++
if (timer.value == 30) {
clearInterval(timerInterval)

View File

@@ -70,13 +70,16 @@
<div class="leading-5 mb-4">
{{ badge.badge_description }}
</div>
<div class="flex flex-col mb-4">
<div class="flex flex-col">
<span class="text-xs text-ink-gray-7 font-medium mb-1">
{{ __('Issued on') }}:
</span>
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
</div>
<div class="flex flex-col">
<div
v-if="user.data?.name == profile.data?.name"
class="flex flex-col mt-4"
>
<span class="text-xs text-ink-gray-7 font-medium mb-1">
{{ __('Share on') }}:
</span>
@@ -125,6 +128,7 @@ import DOMPurify from 'dompurify'
import { getLmsRoute } from '@/utils/basePath'
const dayjs = inject('$dayjs')
const user = inject('$user')
const { branding } = sessionStore()
const props = defineProps({
@@ -135,13 +139,9 @@ const props = defineProps({
})
const badges = createResource({
url: 'frappe.client.get_list',
url: 'lms.lms.api.get_badges',
params: {
doctype: 'LMS Badge Assignment',
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
filters: {
member: props.profile.data.name,
},
member: props.profile.data.name,
},
auto: true,
transform(data) {
@@ -160,14 +160,16 @@ const shareOnSocial = (badge, medium) => {
let shareUrl
const url = encodeURIComponent(
`${window.location.origin}${getLmsRoute(
`badges/${badge.badge}/${props.profile.data?.email}`
`user/${props.profile.data?.username}`
)}`
)
const summary = `I am happy to announce that I earned the ${
badge.badge
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
const summary = __(
'I am happy to announce that I earned the {0} badge on {1} at {2}'
).format(
badge.badge,
dayjs(badge.issued_on).format('DD MMM YYYY'),
branding.data?.app_name
}.`
)
if (medium == 'LinkedIn')
shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}`

View File

@@ -42,18 +42,16 @@ const routes = [
{
path: '/batches',
name: 'Batches',
component: () => import('@/pages/Batches.vue'),
component: () => import('@/pages/Batches/Batches.vue'),
},
{
path: '/batches/details/:batchName',
name: 'BatchDetail',
component: () => import('@/pages/BatchDetail.vue'),
props: true,
redirect: (to) => `/batches/${to.params.batchName}`,
},
{
path: '/batches/:batchName',
name: 'Batch',
component: () => import('@/pages/Batch.vue'),
name: 'BatchDetail',
component: () => import('@/pages/Batches/BatchDetail.vue'),
props: true,
},
{
@@ -125,12 +123,6 @@ const routes = [
component: () => import('@/pages/LessonForm.vue'),
props: true,
},
{
path: '/batches/:batchName/edit',
name: 'BatchForm',
component: () => import('@/pages/BatchForm.vue'),
props: true,
},
{
path: '/job-opening/:jobName/edit',
name: 'JobForm',
@@ -147,12 +139,6 @@ const routes = [
name: 'Notifications',
component: () => import('@/pages/Notifications.vue'),
},
{
path: '/badges/:badgeName/:email',
name: 'Badge',
component: () => import('@/pages/Badge.vue'),
props: true,
},
{
path: '/quizzes',
name: 'Quizzes',

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'
import { usersStore } from './user'
import router from '@/router'
import { computed, reactive, ref } from 'vue'
export const sessionStore = defineStore('lms-session', () => {
@@ -22,19 +21,6 @@ export const sessionStore = defineStore('lms-session', () => {
let user = ref(sessionUser())
const isLoggedIn = computed(() => !!user.value)
const login = createResource({
url: 'login',
onError() {
throw new Error('Invalid email or password')
},
onSuccess() {
userResource.reload()
user.value = sessionUser()
login.reset()
router.replace({ path: '/' })
},
})
const logout = createResource({
url: 'logout',
onSuccess() {
@@ -59,7 +45,6 @@ export const sessionStore = defineStore('lms-session', () => {
return {
user,
isLoggedIn,
login,
logout,
brand,
branding,

View File

@@ -4,9 +4,10 @@ import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
import translationPlugin from '../translation'
import { usersStore } from '@/stores/user'
import { call } from 'frappe-ui'
import router from '@/router'
import { useRouter } from 'vue-router'
import { getLmsRoute } from '@/utils/basePath'
const router = useRouter()
export class Assignment {
constructor({ data, api, readOnly }) {
this.data = data

View File

@@ -236,10 +236,10 @@ export function getEditorTools() {
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
},
codesandbox: {
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
regex: /^https:\/\/codesandbox\.io\/(?:(?:p\/(?:sandbox|devbox)\/)|(?:embed\/)|(?:s\/))?([A-Za-z0-9_-]+)(?:[\/\?].*)?$/,
embedUrl:
'https://codesandbox.io/embed/<%= remote_id %>?view=editor+%2B+preview&module=%2Findex.html',
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-mods allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-modals allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
},
},
},
@@ -644,6 +644,7 @@ export const validateFile = async (
showToast = true,
fileType = 'image'
) => {
const extension = file.name.split('.').pop().toLowerCase()
const error = (msg) => {
if (showToast) toast.error(msg)
console.error(msg)
@@ -653,6 +654,16 @@ export const validateFile = async (
return error(__('Only {0} file is allowed.').format(fileType))
}
if (fileType == 'pdf' && extension !== 'pdf') {
return error(__('Only PDF files are allowed.'))
}
if (fileType == 'document' && !['doc', 'docx'].includes(extension)) {
return error(
__('Only document file of type .doc or .docx are allowed.')
)
}
if (file.type === 'image/svg+xml') {
const text = await file.text()
@@ -680,7 +691,6 @@ export const validateFile = async (
export const escapeHTML = (text) => {
if (!text) return ''
let escape_html_mapping = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',

View File

@@ -4,8 +4,10 @@ import { createApp, h } from 'vue'
import { usersStore } from '../stores/user'
import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
import router from '@/router'
import { getLmsRoute } from '@/utils/basePath'
import { useRouter } from 'vue-router'
const router = useRouter()
export class Quiz {
constructor({ data, api, readOnly }) {

View File

@@ -5,7 +5,6 @@ import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(async ({ mode }) => {
const isDev = mode === 'development'
console.log(mode, isDev)
const frappeui = await importFrappeUIPlugin(isDev)
const config = {

View File

@@ -24,7 +24,7 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
@@ -38,7 +38,7 @@
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d"
integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
"@babel/core@^7.11.1":
"@babel/core@^7.24.4":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322"
integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==
@@ -1211,6 +1211,23 @@
dependencies:
"@swc/helpers" "^0.5.0"
"@isaacs/balanced-match@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29"
integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==
"@isaacs/brace-expansion@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz#0ef5a92d91f2fff2a37646ce54da9e5f599f6eff"
integrity sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==
dependencies:
"@isaacs/balanced-match" "^4.0.1"
"@isaacs/cliui@^9.0.0":
version "9.0.0"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3"
integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==
"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
@@ -1371,17 +1388,16 @@
"@babel/helper-module-imports" "^7.10.4"
"@rollup/pluginutils" "^3.1.0"
"@rollup/plugin-node-resolve@^11.2.1":
version "11.2.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz#82aa59397a29cd4e13248b106e6a4a1880362a60"
integrity sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==
"@rollup/plugin-node-resolve@^15.2.3":
version "15.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz#66008953c2524be786aa319d49e32f2128296a78"
integrity sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==
dependencies:
"@rollup/pluginutils" "^3.1.0"
"@types/resolve" "1.17.1"
builtin-modules "^3.1.0"
"@rollup/pluginutils" "^5.0.1"
"@types/resolve" "1.20.2"
deepmerge "^4.2.2"
is-module "^1.0.0"
resolve "^1.19.0"
resolve "^1.22.1"
"@rollup/plugin-replace@^2.4.1":
version "2.4.2"
@@ -1391,6 +1407,15 @@
"@rollup/pluginutils" "^3.1.0"
magic-string "^0.25.7"
"@rollup/plugin-terser@^0.4.3":
version "0.4.4"
resolved "https://registry.yarnpkg.com/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz#15dffdb3f73f121aa4fbb37e7ca6be9aeea91962"
integrity sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==
dependencies:
serialize-javascript "^6.0.1"
smob "^1.0.0"
terser "^5.17.4"
"@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
@@ -1400,6 +1425,15 @@
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@rollup/pluginutils@^5.0.1":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
dependencies:
"@types/estree" "^1.0.0"
estree-walker "^2.0.2"
picomatch "^4.0.2"
"@rollup/rollup-android-arm-eabi@4.57.1":
version "4.57.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088"
@@ -1901,19 +1935,10 @@
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/node@*":
version "25.2.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.3.tgz#9c18245be768bdb4ce631566c7da303a5c99a7f8"
integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==
dependencies:
undici-types "~7.16.0"
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
dependencies:
"@types/node" "*"
"@types/resolve@1.20.2":
version "1.20.2"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975"
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
"@types/trusted-types@^2.0.2", "@types/trusted-types@^2.0.7":
version "2.0.7"
@@ -2267,14 +2292,6 @@ bl@^4.1.0:
inherits "^2.0.4"
readable-stream "^3.4.0"
brace-expansion@^1.1.7:
version "1.1.12"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
brace-expansion@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
@@ -2313,11 +2330,6 @@ buffer@^5.5.0:
base64-js "^1.3.1"
ieee754 "^1.1.13"
builtin-modules@^3.1.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
@@ -2350,9 +2362,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001759:
version "1.0.30001770"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84"
integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==
version "1.0.30001769"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2"
integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==
chalk@^4.1.0:
version "4.1.2"
@@ -2446,11 +2458,6 @@ common-tags@^1.8.0:
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6"
integrity sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
confbox@^0.1.8:
version "0.1.8"
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
@@ -2483,6 +2490,15 @@ crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6:
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
dependencies:
path-key "^3.1.0"
shebang-command "^2.0.0"
which "^2.0.1"
crypto-random-string@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
@@ -2540,7 +2556,7 @@ dayjs@^1.11.13:
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938"
integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==
debug@^4.1.0, debug@^4.3.1, debug@^4.3.4, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1:
debug@^4.1.0, debug@^4.3.1, debug@^4.3.6, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -2863,7 +2879,7 @@ fast-deep-equal@^3.1.3:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@^3.2.12, fast-glob@^3.3.2:
fast-glob@^3.3.2:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
@@ -2933,6 +2949,14 @@ for-each@^0.3.3, for-each@^0.3.5:
dependencies:
is-callable "^1.2.7"
foreground-child@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==
dependencies:
cross-spawn "^7.0.6"
signal-exit "^4.0.1"
fraction.js@^4.1.2:
version "4.3.7"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
@@ -3009,11 +3033,6 @@ fs-extra@^9.0.1:
jsonfile "^6.0.1"
universalify "^2.0.0"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@@ -3103,17 +3122,17 @@ glob-parent@^6.0.2:
dependencies:
is-glob "^4.0.3"
glob@^7.1.6:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
glob@^11.0.1:
version "11.1.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6"
integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.1.1"
once "^1.3.0"
path-is-absolute "^1.0.0"
foreground-child "^3.3.1"
jackspeak "^4.1.1"
minimatch "^10.1.1"
minipass "^7.1.2"
package-json-from-dist "^1.0.0"
path-scurry "^2.0.0"
globalthis@^1.0.4:
version "1.0.4"
@@ -3205,15 +3224,7 @@ ieee754@^1.1.13:
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@^2.0.3, inherits@^2.0.4:
inherits@^2.0.3, inherits@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -3459,6 +3470,18 @@ isarray@^2.0.5:
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
jackspeak@^4.1.1:
version "4.2.3"
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483"
integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==
dependencies:
"@isaacs/cliui" "^9.0.0"
jake@^10.8.5:
version "10.9.4"
resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.4.tgz#d626da108c63d5cfb00ab5c25fadc7e0084af8e6"
@@ -3468,15 +3491,6 @@ jake@^10.8.5:
filelist "^1.0.4"
picocolors "^1.1.1"
jest-worker@^26.2.1:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed"
integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==
dependencies:
"@types/node" "*"
merge-stream "^2.0.0"
supports-color "^7.0.0"
jiti@^1.21.7:
version "1.21.7"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9"
@@ -3599,6 +3613,11 @@ lowlight@^3.3.0:
devlop "^1.0.0"
highlight.js "~11.11.0"
lru-cache@^11.0.0:
version "11.2.6"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.6.tgz#356bf8a29e88a7a2945507b31f6429a65a192c58"
integrity sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -3669,11 +3688,6 @@ mdurl@^2.0.0:
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
@@ -3697,12 +3711,12 @@ mini-svg-data-uri@^1.2.3:
resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
minimatch@^10.1.1:
version "10.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.2.tgz#6c3f289f9de66d628fa3feb1842804396a43d81c"
integrity sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==
dependencies:
brace-expansion "^1.1.7"
"@isaacs/brace-expansion" "^5.0.1"
minimatch@^5.0.1:
version "5.1.6"
@@ -3711,6 +3725,11 @@ minimatch@^5.0.1:
dependencies:
brace-expansion "^2.0.1"
minipass@^7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
mlly@^1.7.4, mlly@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.0.tgz#e074612b938af8eba1eaf43299cbc89cb72d824e"
@@ -3797,13 +3816,6 @@ ohash@^2.0.11:
resolved "https://registry.yarnpkg.com/ohash/-/ohash-2.0.11.tgz#60b11e8cff62ca9dee88d13747a5baa145f5900b"
integrity sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
dependencies:
wrappy "1"
onetime@^5.1.0:
version "5.1.2"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
@@ -3840,21 +3852,34 @@ own-keys@^1.0.1:
object-keys "^1.1.1"
safe-push-apply "^1.0.0"
package-json-from-dist@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==
package-manager-detector@^1.3.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734"
integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
path-key@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
path-scurry@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.1.tgz#4b6572376cfd8b811fca9cd1f5c24b3cbac0fe10"
integrity sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==
dependencies:
lru-cache "^11.0.0"
minipass "^7.1.2"
pathe@^2.0.1, pathe@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
@@ -4006,7 +4031,7 @@ pretty-bytes@^5.3.0:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
pretty-bytes@^6.0.0:
pretty-bytes@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
@@ -4299,7 +4324,7 @@ regjsparser@^0.13.0:
dependencies:
jsesc "~3.1.0"
reka-ui@^2.5.0:
reka-ui@^2.5.0, reka-ui@^2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/reka-ui/-/reka-ui-2.8.0.tgz#612023ad40c5c10999aef304f2b828cdd08da6a8"
integrity sha512-N4JOyIrmDE7w2i06WytqcV2QICubtS2PsK5Uo8FIMAgmO13KhUAgAByP26cXjjm2oF/w7rTyRs8YaqtvaBT+SA==
@@ -4320,7 +4345,7 @@ require-from-string@^2.0.2:
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
resolve@^1.1.7, resolve@^1.19.0, resolve@^1.22.11, resolve@^1.22.8:
resolve@^1.1.7, resolve@^1.22.1, resolve@^1.22.11, resolve@^1.22.8:
version "1.22.11"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262"
integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==
@@ -4342,17 +4367,7 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rollup-plugin-terser@^7.0.0:
version "7.0.2"
resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d"
integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==
dependencies:
"@babel/code-frame" "^7.10.4"
jest-worker "^26.2.1"
serialize-javascript "^4.0.0"
terser "^5.0.0"
rollup@^2.43.1:
rollup@^2.79.2:
version "2.79.2"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090"
integrity sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==
@@ -4448,10 +4463,10 @@ semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
serialize-javascript@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
serialize-javascript@^6.0.1:
version "6.0.2"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
dependencies:
randombytes "^2.1.0"
@@ -4486,6 +4501,18 @@ set-proto@^1.0.0:
es-errors "^1.3.0"
es-object-atoms "^1.0.0"
shebang-command@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
dependencies:
shebang-regex "^3.0.0"
shebang-regex@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
@@ -4531,11 +4558,21 @@ signal-exit@^3.0.2:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
signal-exit@^4.0.1:
version "4.1.0"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
slugify@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
smob@^1.0.0:
version "1.6.1"
resolved "https://registry.yarnpkg.com/smob/-/smob-1.6.1.tgz#930607366738545aee542a93e03e47b54e0303e0"
integrity sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==
socket.io-client@4.7.2:
version "4.7.2"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08"
@@ -4711,7 +4748,7 @@ sucrase@^3.35.0:
tinyglobby "^0.2.11"
ts-interface-checker "^0.1.9"
supports-color@^7.0.0, supports-color@^7.1.0:
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
@@ -4766,7 +4803,7 @@ tempy@^0.6.0:
type-fest "^0.16.0"
unique-string "^2.0.0"
terser@^5.0.0:
terser@^5.17.4:
version "5.46.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695"
integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==
@@ -4800,7 +4837,7 @@ tinyexec@^1.0.1:
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251"
integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==
tinyglobby@^0.2.11, tinyglobby@^0.2.12, tinyglobby@^0.2.14, tinyglobby@^0.2.15:
tinyglobby@^0.2.10, tinyglobby@^0.2.11, tinyglobby@^0.2.12, tinyglobby@^0.2.14, tinyglobby@^0.2.15:
version "0.2.15"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
@@ -4924,11 +4961,6 @@ unbox-primitive@^1.1.0:
has-symbols "^1.1.0"
which-boxed-primitive "^1.1.1"
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2"
@@ -5102,16 +5134,16 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
vite-plugin-pwa@0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-0.15.0.tgz#4e1b87097e97e77e4e4d92743d80606c0345dbcd"
integrity sha512-gpmx3BeubsRIXRBkjPToOTJbo8fknNmZFQs24i0TPZyaNVa0n27YHDo0Y72amnO70WvHKGE3e1fn8SYUP7e8SA==
vite-plugin-pwa@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz#3c7de17d4eed662f273095a0ac52f7a98d0cde36"
integrity sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==
dependencies:
debug "^4.3.4"
fast-glob "^3.2.12"
pretty-bytes "^6.0.0"
workbox-build "^6.5.4"
workbox-window "^6.5.4"
debug "^4.3.6"
pretty-bytes "^6.1.1"
tinyglobby "^0.2.10"
workbox-build "^7.4.0"
workbox-window "^7.4.0"
vite@5.0.11:
version "5.0.11"
@@ -5263,168 +5295,170 @@ which-typed-array@^1.1.16, which-typed-array@^1.1.19:
gopd "^1.2.0"
has-tostringtag "^1.0.2"
workbox-background-sync@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.6.1.tgz#08d603a33717ce663e718c30cc336f74909aff2f"
integrity sha512-trJd3ovpWCvzu4sW0E8rV3FUyIcC0W8G+AZ+VcqzzA890AsWZlUGOTSxIMmIHVusUw/FDq1HFWfy/kC/WTRqSg==
which@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
dependencies:
isexe "^2.0.0"
workbox-background-sync@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz#5fcf83162b540f799966fdd8df0858f91b787d77"
integrity sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==
dependencies:
idb "^7.0.1"
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-broadcast-update@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.6.1.tgz#0fad9454cf8e4ace0c293e5617c64c75d8a8c61e"
integrity sha512-fBhffRdaANdeQ1V8s692R9l/gzvjjRtydBOvR6WCSB0BNE2BacA29Z4r9/RHd9KaXCPl6JTdI9q0bR25YKP8TQ==
workbox-broadcast-update@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz#f0ee7d2af51d199e32213a429dff03f14ba76dea"
integrity sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==
dependencies:
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-build@^6.5.4:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-6.6.1.tgz#6010e9ce550910156761448f2dbea8cfcf759cb0"
integrity sha512-INPgDx6aRycAugUixbKgiEQBWD0MPZqU5r0jyr24CehvNuLPSXp/wGOpdRJmts656lNiXwqV7dC2nzyrzWEDnw==
workbox-build@^7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-7.4.0.tgz#033f88ebbd9c6312983f3fb9c17a4161369d693f"
integrity sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==
dependencies:
"@apideck/better-ajv-errors" "^0.3.1"
"@babel/core" "^7.11.1"
"@babel/core" "^7.24.4"
"@babel/preset-env" "^7.11.0"
"@babel/runtime" "^7.11.2"
"@rollup/plugin-babel" "^5.2.0"
"@rollup/plugin-node-resolve" "^11.2.1"
"@rollup/plugin-node-resolve" "^15.2.3"
"@rollup/plugin-replace" "^2.4.1"
"@rollup/plugin-terser" "^0.4.3"
"@surma/rollup-plugin-off-main-thread" "^2.2.3"
ajv "^8.6.0"
common-tags "^1.8.0"
fast-json-stable-stringify "^2.1.0"
fs-extra "^9.0.1"
glob "^7.1.6"
glob "^11.0.1"
lodash "^4.17.20"
pretty-bytes "^5.3.0"
rollup "^2.43.1"
rollup-plugin-terser "^7.0.0"
rollup "^2.79.2"
source-map "^0.8.0-beta.0"
stringify-object "^3.3.0"
strip-comments "^2.0.1"
tempy "^0.6.0"
upath "^1.2.0"
workbox-background-sync "6.6.1"
workbox-broadcast-update "6.6.1"
workbox-cacheable-response "6.6.1"
workbox-core "6.6.1"
workbox-expiration "6.6.1"
workbox-google-analytics "6.6.1"
workbox-navigation-preload "6.6.1"
workbox-precaching "6.6.1"
workbox-range-requests "6.6.1"
workbox-recipes "6.6.1"
workbox-routing "6.6.1"
workbox-strategies "6.6.1"
workbox-streams "6.6.1"
workbox-sw "6.6.1"
workbox-window "6.6.1"
workbox-background-sync "7.4.0"
workbox-broadcast-update "7.4.0"
workbox-cacheable-response "7.4.0"
workbox-core "7.4.0"
workbox-expiration "7.4.0"
workbox-google-analytics "7.4.0"
workbox-navigation-preload "7.4.0"
workbox-precaching "7.4.0"
workbox-range-requests "7.4.0"
workbox-recipes "7.4.0"
workbox-routing "7.4.0"
workbox-strategies "7.4.0"
workbox-streams "7.4.0"
workbox-sw "7.4.0"
workbox-window "7.4.0"
workbox-cacheable-response@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.6.1.tgz#284c2b86be3f4fd191970ace8c8e99797bcf58e9"
integrity sha512-85LY4veT2CnTCDxaVG7ft3NKaFbH6i4urZXgLiU4AiwvKqS2ChL6/eILiGRYXfZ6gAwDnh5RkuDbr/GMS4KSag==
workbox-cacheable-response@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz#f684380c07dfce4ed1aa555c8a29a2a1f8421d46"
integrity sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==
dependencies:
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-core@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.6.1.tgz#7184776d4134c5ed2f086878c882728fc9084265"
integrity sha512-ZrGBXjjaJLqzVothoE12qTbVnOAjFrHDXpZe7coCb6q65qI/59rDLwuFMO4PcZ7jcbxY+0+NhUVztzR/CbjEFw==
workbox-core@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-7.4.0.tgz#5cb59ae7655f2727423268fb1ba698f37809189d"
integrity sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==
workbox-expiration@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.6.1.tgz#a841fa36676104426dbfb9da1ef6a630b4f93739"
integrity sha512-qFiNeeINndiOxaCrd2DeL1Xh1RFug3JonzjxUHc5WkvkD2u5abY3gZL1xSUNt3vZKsFFGGORItSjVTVnWAZO4A==
workbox-expiration@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-7.4.0.tgz#f7162a45ad8b28de84acea478df421b4d0065e61"
integrity sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==
dependencies:
idb "^7.0.1"
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-google-analytics@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.6.1.tgz#a07a6655ab33d89d1b0b0a935ffa5dea88618c5d"
integrity sha512-1TjSvbFSLmkpqLcBsF7FuGqqeDsf+uAXO/pjiINQKg3b1GN0nBngnxLcXDYo1n/XxK4N7RaRrpRlkwjY/3ocuA==
workbox-google-analytics@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz#208d8e584e8262af8a14140c3a990d13021c8257"
integrity sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==
dependencies:
workbox-background-sync "6.6.1"
workbox-core "6.6.1"
workbox-routing "6.6.1"
workbox-strategies "6.6.1"
workbox-background-sync "7.4.0"
workbox-core "7.4.0"
workbox-routing "7.4.0"
workbox-strategies "7.4.0"
workbox-navigation-preload@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.6.1.tgz#61a34fe125558dd88cf09237f11bd966504ea059"
integrity sha512-DQCZowCecO+wRoIxJI2V6bXWK6/53ff+hEXLGlQL4Rp9ZaPDLrgV/32nxwWIP7QpWDkVEtllTAK5h6cnhxNxDA==
workbox-navigation-preload@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz#3133983b2690dee733d18f56760fdd5182a6ffaf"
integrity sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==
dependencies:
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-precaching@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.6.1.tgz#dedeeba10a2d163d990bf99f1c2066ac0d1a19e2"
integrity sha512-K4znSJ7IKxCnCYEdhNkMr7X1kNh8cz+mFgx9v5jFdz1MfI84pq8C2zG+oAoeE5kFrUf7YkT5x4uLWBNg0DVZ5A==
workbox-precaching@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-7.4.0.tgz#daf486953353acaf84142b78cf28a890c466b242"
integrity sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==
dependencies:
workbox-core "6.6.1"
workbox-routing "6.6.1"
workbox-strategies "6.6.1"
workbox-core "7.4.0"
workbox-routing "7.4.0"
workbox-strategies "7.4.0"
workbox-range-requests@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.6.1.tgz#ddaf7e73af11d362fbb2f136a9063a4c7f507a39"
integrity sha512-4BDzk28govqzg2ZpX0IFkthdRmCKgAKreontYRC5YsAPB2jDtPNxqx3WtTXgHw1NZalXpcH/E4LqUa9+2xbv1g==
workbox-range-requests@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz#1be334d6a7a0b158d6094e8698460393863d28a2"
integrity sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==
dependencies:
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-recipes@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-6.6.1.tgz#ea70d2b2b0b0bce8de0a9d94f274d4a688e69fae"
integrity sha512-/oy8vCSzromXokDA+X+VgpeZJvtuf8SkQ8KL0xmRivMgJZrjwM3c2tpKTJn6PZA6TsbxGs3Sc7KwMoZVamcV2g==
workbox-recipes@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-7.4.0.tgz#217e6394f965bed8fbf15ad83370f03356c885c9"
integrity sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==
dependencies:
workbox-cacheable-response "6.6.1"
workbox-core "6.6.1"
workbox-expiration "6.6.1"
workbox-precaching "6.6.1"
workbox-routing "6.6.1"
workbox-strategies "6.6.1"
workbox-cacheable-response "7.4.0"
workbox-core "7.4.0"
workbox-expiration "7.4.0"
workbox-precaching "7.4.0"
workbox-routing "7.4.0"
workbox-strategies "7.4.0"
workbox-routing@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.6.1.tgz#cba9a1c7e0d1ea11e24b6f8c518840efdc94f581"
integrity sha512-j4ohlQvfpVdoR8vDYxTY9rA9VvxTHogkIDwGdJ+rb2VRZQ5vt1CWwUUZBeD/WGFAni12jD1HlMXvJ8JS7aBWTg==
workbox-routing@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-7.4.0.tgz#4b5bc90256515dc5cf49b356b101721fd135d013"
integrity sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==
dependencies:
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-strategies@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.6.1.tgz#38d0f0fbdddba97bd92e0c6418d0b1a2ccd5b8bf"
integrity sha512-WQLXkRnsk4L81fVPkkgon1rZNxnpdO5LsO+ws7tYBC6QQQFJVI6v98klrJEjFtZwzw/mB/HT5yVp7CcX0O+mrw==
workbox-strategies@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-7.4.0.tgz#59130734400722d39ce4a0a1a22a363e99913946"
integrity sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==
dependencies:
workbox-core "6.6.1"
workbox-core "7.4.0"
workbox-streams@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.6.1.tgz#b2f7ba7b315c27a6e3a96a476593f99c5d227d26"
integrity sha512-maKG65FUq9e4BLotSKWSTzeF0sgctQdYyTMq529piEN24Dlu9b6WhrAfRpHdCncRS89Zi2QVpW5V33NX8PgH3Q==
workbox-streams@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-7.4.0.tgz#e5b8e6b540f08e05f3c51b748c54056d24f20e8c"
integrity sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==
dependencies:
workbox-core "6.6.1"
workbox-routing "6.6.1"
workbox-core "7.4.0"
workbox-routing "7.4.0"
workbox-sw@6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-6.6.1.tgz#d4c4ca3125088e8b9fd7a748ed537fa0247bd72c"
integrity sha512-R7whwjvU2abHH/lR6kQTTXLHDFU2izht9kJOvBRYK65FbwutT4VvnUAJIgHvfWZ/fokrOPhfoWYoPCMpSgUKHQ==
workbox-sw@7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-7.4.0.tgz#05c9659399b8f3716e14406be66eb118fcb3968f"
integrity sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==
workbox-window@6.6.1, workbox-window@^6.5.4:
version "6.6.1"
resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-6.6.1.tgz#f22a394cbac36240d0dadcbdebc35f711bb7b89e"
integrity sha512-wil4nwOY58nTdCvif/KEZjQ2NP8uk3gGeRNy2jPBbzypU4BT4D9L8xiwbmDBpZlSgJd2xsT9FvSNU0gsxV51JQ==
workbox-window@7.4.0, workbox-window@^7.4.0:
version "7.4.0"
resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-7.4.0.tgz#5399a5261b8c34d9d102f2d832d5857ee4d5748a"
integrity sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==
dependencies:
"@types/trusted-types" "^2.0.2"
workbox-core "6.6.1"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
workbox-core "7.4.0"
ws@~8.17.1:
version "8.17.1"

View File

@@ -6,14 +6,15 @@
"hidden": 0,
"icon_type": "App",
"idx": 0,
"label": "Frappe LMS",
"label": "Frappe Learning",
"link": "/lms",
"link_type": "External",
"logo_url": "/assets/lms/frontend/learning.svg",
"modified": "2025-12-15 14:31:50.704854",
"modified_by": "Administrator",
"name": "Frappe LMS",
"name": "Frappe Learning",
"owner": "Administrator",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View File

@@ -5,7 +5,7 @@ from . import __version__ as app_version
app_name = "frappe_lms"
app_title = "Learning"
app_publisher = "Frappe"
app_description = "Frappe LMS App"
app_description = "Open Source Learning Management System built with Frappe Framework"
app_icon_url = "/assets/lms/images/lms-logo.png"
app_icon_title = "Learning"
app_color = "grey"
@@ -86,13 +86,16 @@ after_migrate = [
# -----------
# Permissions evaluated in scripted ways
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
#
# has_permission = {
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }
permission_query_conditions = {
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.get_permission_query_conditions",
}
has_permission = {
"LMS Live Class": "lms.lms.doctype.lms_live_class.lms_live_class.has_permission",
"LMS Batch": "lms.lms.doctype.lms_batch.lms_batch.has_permission",
"LMS Program": "lms.lms.doctype.lms_program.lms_program.has_permission",
"LMS Certificate": "lms.lms.doctype.lms_certificate.lms_certificate.has_permission",
}
# DocType Class
# ---------------

View File

@@ -130,7 +130,7 @@
}
],
"make_attachments_public": 1,
"modified": "2025-12-02 16:58:49.903274",
"modified": "2026-02-19 14:26:14.027340",
"modified_by": "sayali@frappe.io",
"module": "Job",
"name": "Job Opportunity",
@@ -149,24 +149,16 @@
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"select": 1,
"share": 1,
"write": 1
}

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2022, Frappe and contributors
// For license information, please see license.txt
frappe.ui.form.on("Job Settings", {
// refresh: function(frm) {
// }
});

View File

@@ -1,54 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2022-02-07 12:01:41.422955",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"allow_posting",
"title",
"subtitle"
],
"fields": [
{
"default": "0",
"fieldname": "allow_posting",
"fieldtype": "Check",
"label": "Allow Job Posting From Website"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Job Board Title"
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Job Board Subtitle"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-02-11 15:56:38.958317",
"modified_by": "Administrator",
"module": "Job",
"name": "Job Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

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

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2022, Frappe and Contributors
# See license.txt
# import frappe
import unittest
class TestJobSettings(unittest.TestCase):
pass

View File

@@ -31,6 +31,7 @@ from pypika import functions as fn
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from lms.lms.utils import (
LMS_ROLES,
can_modify_batch,
can_modify_course,
get_average_rating,
@@ -41,6 +42,7 @@ from lms.lms.utils import (
get_lms_route,
has_course_instructor_role,
has_evaluator_role,
has_lms_role,
has_moderator_role,
)
@@ -393,7 +395,7 @@ def get_all_users():
@frappe.whitelist(allow_guest=True)
def get_sidebar_settings():
lms_settings = frappe.get_single("LMS Settings")
if not lms_settings.allow_guest_access:
if frappe.session.user == "Guest" and not lms_settings.allow_guest_access:
return []
sidebar_items = frappe._dict()
@@ -473,7 +475,7 @@ def delete_lesson(lesson: str, chapter: str):
update_index(lessons, chapter)
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
frappe.db.delete("Course Lesson", lesson)
frappe.delete_doc("Course Lesson", lesson)
@frappe.whitelist()
@@ -606,12 +608,7 @@ def check_app_permission():
if frappe.session.user == "Administrator":
return True
roles = frappe.get_roles()
lms_roles = ["Moderator", "Course Creator", "Batch Evaluator", "LMS Student"]
if any(role in roles for role in lms_roles):
return True
return False
return has_lms_role()
@frappe.whitelist()
@@ -1296,6 +1293,7 @@ def get_lms_settings():
"contact_us_url",
"livecode_url",
"disable_pwa",
"allow_job_posting",
]
settings = frappe._dict()
@@ -1308,7 +1306,6 @@ def get_lms_settings():
@frappe.whitelist()
def cancel_evaluation(evaluation: dict):
evaluation = frappe._dict(evaluation)
print(evaluation.member, frappe.session.user)
if evaluation.member != frappe.session.user:
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
@@ -1369,6 +1366,9 @@ def get_certification_details(course: str):
@frappe.whitelist()
def save_role(user: str, role: str, value: int):
frappe.only_for("Moderator")
if role not in LMS_ROLES:
frappe.throw(_("You do not have permission to modify this role."), frappe.PermissionError)
if cint(value):
doc = frappe.get_doc(
{
@@ -1716,8 +1716,12 @@ def get_profile_details(username: str):
],
as_dict=True,
)
details.roles = frappe.get_roles(details.name)
roles = frappe.get_roles(details.name)
if not has_lms_role():
frappe.throw(
_("User does not have permission to access this user's profile details."), frappe.PermissionError
)
details.roles = roles
return details
@@ -2204,3 +2208,17 @@ def get_assessment_from_lesson(course: str, assessmentType: str):
assessments.append(quiz_name)
return assessments
@frappe.whitelist()
def get_badges(member: str):
if not has_lms_role():
frappe.throw(_("You do not have permission to access badges."), frappe.PermissionError)
badges = frappe.get_all(
"LMS Badge Assignment",
{"member": member},
["name", "member", "badge", "badge_image", "badge_description", "issued_on"],
)
return badges

View File

@@ -4,25 +4,12 @@
import frappe
from frappe.model.document import Document
from lms.lms.utils import get_course_progress, get_lesson_count
from lms.lms.utils import get_lesson_count
class CourseChapter(Document):
def on_update(self):
self.recalculate_course_progress()
self.update_lesson_count()
frappe.enqueue(method=self.recalculate_course_progress, queue="short", timeout=300, is_async=True)
def recalculate_course_progress(self):
"""Recalculate course progress if a new lesson is added or removed"""
previous_lessons = self.get_doc_before_save() and self.get_doc_before_save().as_dict().lessons
current_lessons = self.lessons
if previous_lessons and previous_lessons != current_lessons:
enrolled_members = frappe.get_all("LMS Enrollment", {"course": self.course}, ["member", "name"])
for enrollment in enrolled_members:
new_progress = get_course_progress(self.course, enrollment.member)
frappe.db.set_value("LMS Enrollment", enrollment.name, "progress", new_progress)
def update_lesson_count(self):
"""Update lesson count in the course"""

View File

@@ -83,7 +83,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-10 11:41:51.802016",
"modified": "2026-02-23 14:50:11.733278",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "Course Evaluator",
@@ -125,6 +125,18 @@
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",

View File

@@ -67,7 +67,6 @@
{
"fieldname": "body",
"fieldtype": "Markdown Editor",
"ignore_xss_filter": 1,
"label": "Body"
},
{
@@ -161,7 +160,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-04-10 15:19:22.400932",
"modified": "2026-02-20 13:49:25.599827",
"modified_by": "Administrator",
"module": "LMS",
"name": "Course Lesson",

View File

@@ -9,15 +9,39 @@ from frappe.model.document import Document
from frappe.realtime import get_website_room
from frappe.utils.telemetry import capture
from lms.lms.utils import get_course_progress
from lms.lms.utils import get_course_progress, recalculate_course_progress
from ...md import find_macros
class CourseLesson(Document):
def after_insert(self):
self.validate_progress_recalculation()
def after_delete(self):
self.validate_progress_recalculation()
def on_update(self):
self.validate_quiz_id()
def validate_progress_recalculation(self):
if not self.course or not self.chapter:
return
enrollments = frappe.db.get_all(
"LMS Enrollment",
filters={"course": self.course},
fields=["name", "member"],
)
if not len(enrollments):
return
frappe.enqueue(method=self.recalculate_progress, queue="long", is_async=True, enrollments=enrollments)
def recalculate_progress(self, enrollments):
for enrollment in enrollments:
recalculate_course_progress(self.course, enrollment.member)
def validate_quiz_id(self):
if self.quiz_id and not frappe.db.exists("LMS Quiz", self.quiz_id):
frappe.throw(_("Invalid Quiz ID"))

View File

@@ -5,7 +5,7 @@ frappe.ui.form.on("LMS Badge", {
refresh: (frm) => {
frm.events.set_field_options(frm);
if (frm.doc.event == "Auto Assign") {
if (frm.doc.event == "Manual Assignment" && frm.doc.enabled) {
add_assign_button(frm);
}
},
@@ -49,11 +49,13 @@ const add_assign_button = (frm) => {
frappe.call({
method: "lms.lms.doctype.lms_badge.lms_badge.assign_badge",
args: {
badge: frm.doc,
badge_name: frm.doc.name,
},
callback: function (r) {
if (r.message) {
frappe.msgprint(r.message);
if (r.message == "success") {
frappe.toast(__("Badge assigned successfully"));
} else {
frappe.toast(__("Failed to assign badge"));
}
},
});

View File

@@ -52,14 +52,14 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Event",
"options": "New\nValue Change\nAuto Assign",
"options": "New\nValue Change\nManual Assignment",
"reqd": 1
},
{
"fieldname": "condition",
"fieldtype": "Code",
"label": "Condition",
"mandatory_depends_on": "eval:doc.event == \"Auto Assign\""
"reqd": 1
},
{
"depends_on": "eval:doc.event == 'Value Change'",
@@ -100,7 +100,7 @@
"link_fieldname": "badge"
}
],
"modified": "2026-02-03 10:52:37.122370",
"modified": "2026-02-20 17:58:25.924109",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge",
@@ -131,15 +131,6 @@
"role": "Moderator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",

View File

@@ -10,7 +10,7 @@ from frappe.model.document import Document
class LMSBadge(Document):
def on_update(self):
if self.event == "Auto Assign" and self.condition:
if self.event == "Manual Assignment" and self.condition:
try:
json.loads(self.condition)
except ValueError:
@@ -54,6 +54,7 @@ def award(doc, member):
}
)
assignment.save()
return assignment.name
def eval_condition(doc, condition):
@@ -61,16 +62,30 @@ def eval_condition(doc, condition):
@frappe.whitelist()
def assign_badge(badge: str, user: str):
badge = frappe._dict(json.loads(badge))
if not badge.event == "Auto Assign":
def assign_badge(badge_name: str):
assignments = []
badge = frappe.db.get_value(
"LMS Badge",
badge_name,
["name", "event", "reference_doctype", "condition", "user_field"],
as_dict=True,
)
if not badge:
frappe.throw(_("Badge {0} not found").format(badge_name), frappe.DoesNotExistError)
if not badge.event == "Manual Assignment":
return
fields = ["name"]
fields.append(badge.user_field)
list = frappe.get_all(badge.reference_doctype, filters=badge.condition, fields=fields)
for doc in list:
award(badge, doc.get(badge.user_field))
docs = frappe.get_all(badge.reference_doctype, filters=json.loads(badge.condition), fields=fields)
for doc in docs:
assignment_name = award(badge, doc.get(badge.user_field))
if assignment_name:
assignments.append(assignment_name)
return "success" if assignments else "failed"
def process_badges(doc, state):

View File

@@ -84,7 +84,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-04 17:06:26.090276",
"modified": "2026-02-19 15:06:08.389081",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Badge Assignment",
@@ -120,10 +120,6 @@
"read": 1,
"role": "LMS Student"
},
{
"read": 1,
"role": "LMS Student"
},
{
"create": 1,
"delete": 1,

View File

@@ -10,17 +10,17 @@ from lms.lms.doctype.lms_badge.lms_badge import eval_condition
class LMSBadgeAssignment(Document):
def validate(self):
self.validate_owner()
self.validate_duplicate_badge_assignment()
self.validate_badge_criteria()
self.validate_owner()
def validate_owner(self):
if self.owner == self.member:
return
roles = frappe.get_roles(self.owner)
if "Moderator" not in roles:
frappe.throw(_("You must be a Moderator to assign badges to users."))
event = frappe.db.get_value("LMS Badge", self.badge, "event")
if event == "Manual Assignment":
roles = frappe.get_roles(frappe.session.user)
admins = ["Moderator", "Course Creator", "Batch Evaluator"]
if not any(role in roles for role in admins):
frappe.throw(_("You must be an Admin to assign badges to users."))
def validate_duplicate_badge_assignment(self):
grant_only_once = frappe.db.get_value("LMS Badge", self.badge, "grant_only_once")
@@ -40,25 +40,27 @@ class LMSBadgeAssignment(Document):
"LMS Badge", self.badge, ["reference_doctype", "user_field", "condition", "enabled"], as_dict=True
)
if badge_details:
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
user_fieldname = frappe.db.get_value(
"DocField",
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field},
"fieldname",
if not badge_details:
return
if badge_details.reference_doctype and badge_details.user_field and badge_details.condition:
user_fieldname = frappe.db.get_value(
"DocField",
{"parent": badge_details.reference_doctype, "fieldname": badge_details.user_field},
"fieldname",
)
documents = frappe.get_all(
badge_details.reference_doctype,
{user_fieldname: self.member},
)
for document in documents:
reference_value = eval_condition(
frappe.get_doc(badge_details.reference_doctype, document.name),
badge_details.condition,
)
if reference_value:
return
documents = frappe.get_all(
badge_details.reference_doctype,
{user_fieldname: self.member},
)
for document in documents:
reference_value = eval_condition(
frappe.get_doc(badge_details.reference_doctype, document.name),
badge_details.condition,
)
if reference_value:
return
frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))
frappe.throw(_("Member does not meet the criteria for the badge {0}.").format(self.badge))

View File

@@ -50,7 +50,7 @@ frappe.ui.form.on("LMS Batch", {
refresh: (frm) => {
const lmsPath = frappe.boot.lms_path || "lms";
frm.add_web_link(
`/${lmsPath}/batches/details/${frm.doc.name}`,
`/${lmsPath}/batches/${frm.doc.name}`,
"See on website"
);
},

View File

@@ -9,41 +9,43 @@
"engine": "InnoDB",
"field_order": [
"section_break_earo",
"published",
"title",
"start_date",
"end_date",
"column_break_4",
"allow_self_enrollment",
"start_time",
"end_time",
"timezone",
"section_break_wuxt",
"seat_count",
"column_break_uamg",
"category",
"section_break_cssv",
"published",
"evaluation",
"evaluation_end_date",
"column_break_wfkz",
"allow_self_enrollment",
"column_break_vnrp",
"certification",
"section_break_6",
"description",
"column_break_hlqw",
"instructors",
"zoom_account",
"column_break_hlqw",
"batch_details",
"section_break_rgfj",
"medium",
"category",
"confirmation_email_template",
"column_break_flwy",
"seat_count",
"evaluation_end_date",
"zoom_account",
"notification_sent",
"section_break_jedp",
"video_link",
"column_break_kpct",
"meta_image",
"section_break_khcn",
"batch_details",
"batch_details_raw",
"section_break_jgji",
"courses",
"section_break_khcn",
"batch_details_raw",
"assessment_tab",
"assessment",
"schedule_tab",
@@ -297,6 +299,7 @@
"label": "Allow accessing future dates"
},
{
"depends_on": "evaluation",
"fieldname": "evaluation_end_date",
"fieldtype": "Date",
"label": "Evaluation End Date"
@@ -341,10 +344,6 @@
"fieldname": "column_break_wfkz",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_vnrp",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "certification",
@@ -358,7 +357,8 @@
},
{
"fieldname": "section_break_cssv",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Certification"
},
{
"fieldname": "zoom_account",
@@ -385,6 +385,20 @@
{
"fieldname": "column_break_kpct",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "evaluation",
"fieldtype": "Check",
"label": "Evaluation"
},
{
"fieldname": "section_break_wuxt",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_uamg",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -407,7 +421,7 @@
"link_fieldname": "payment_for_document"
}
],
"modified": "2026-01-13 18:50:27.420712",
"modified": "2026-02-13 14:23:51.913875",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch",

View File

@@ -20,6 +20,7 @@ from lms.lms.utils import (
get_lesson_url,
get_lms_route,
get_quiz_details,
guest_access_allowed,
update_payment_record,
)
@@ -165,7 +166,7 @@ def send_email_notification_for_published_batch(batch):
"medium": batch.medium,
"timezone": batch.timezone,
"instructors": instructors,
"batch_url": frappe.utils.get_url(get_lms_route(f"batches/details/{batch.name}")),
"batch_url": frappe.utils.get_url(get_lms_route(f"batches/{batch.name}")),
}
frappe.sendmail(
@@ -194,7 +195,7 @@ def send_system_notification_for_published_batch(batch):
"document_name": batch.name,
"from_user": instructors[0] if instructors else None,
"type": "Alert",
"link": get_lms_route(f"batches/details/{batch.name}"),
"link": get_lms_route(f"batches/{batch.name}"),
}
)
make_notification_logs(notification, students)
@@ -213,6 +214,10 @@ def create_live_class(
auto_recording: str,
description: str = None,
):
roles = frappe.get_roles()
if not any(role in roles for role in ["Moderator", "Batch Evaluator"]):
frappe.throw(_("You do not have permission to create a live class."))
payload = {
"topic": title,
"start_time": format_datetime(f"{date} {time}", "yyyy-MM-ddTHH:mm:ssZ"),
@@ -391,3 +396,26 @@ def send_mail(batch, student):
args=args,
header=[_(f"Batch Start Reminder: {batch.title}"), "orange"],
)
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
if user == "Guest" and not guest_access_allowed():
return False
roles = frappe.get_roles(user)
if "Moderator" in roles or "Batch Evaluator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
is_enrolled = frappe.db.exists("LMS Batch Enrollment", {"batch": doc.name, "member": user})
if is_enrolled:
return True
is_batch_published = frappe.db.get_value("LMS Batch", doc.name, "published")
if is_batch_published:
return True
return False

View File

@@ -9,6 +9,7 @@
"member",
"member_name",
"member_username",
"member_image",
"column_break_sjzm",
"batch",
"payment",
@@ -70,11 +71,17 @@
"label": "Batch",
"options": "LMS Batch",
"reqd": 1
},
{
"fetch_from": "member.user_image",
"fieldname": "member_image",
"fieldtype": "Attach Image",
"label": "Member Image"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-03 10:51:28.475356",
"modified": "2026-02-10 16:07:28.315982",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch Enrollment",

View File

@@ -123,7 +123,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-17 16:50:31.128747",
"modified": "2026-02-20 17:32:34.580862",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Certificate",
@@ -153,27 +153,6 @@
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"delete": 1,
@@ -197,6 +176,15 @@
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
}
],
"row_format": "Dynamic",

View File

@@ -12,6 +12,7 @@ from frappe.utils.telemetry import capture
class LMSCertificate(Document):
def validate(self):
self.validate_criteria()
self.validate_duplicate_certificate()
def autoname(self):
@@ -54,6 +55,43 @@ class LMSCertificate(Document):
header=[subject, "green"],
)
def validate_criteria(self):
self.validate_role_of_owner()
self.validate_batch_enrollment()
self.validate_course_enrollment()
def validate_role_of_owner(self):
roles = frappe.get_roles()
is_admin = any(role in roles for role in ["Moderator", "Course Creator", "Batch Evaluator"])
if not self.course and not self.batch_name and not is_admin:
frappe.throw(_("Course or Batch is required to issue a certificate."))
def validate_batch_enrollment(self):
if self.batch_name:
is_enrolled = frappe.db.exists(
"LMS Batch Enrollment", {"batch": self.batch_name, "member": self.member}
)
if not is_enrolled:
frappe.throw(_("Certification cannot be issued as the member is not enrolled in this batch."))
def validate_course_enrollment(self):
if self.course:
is_enrolled = frappe.db.exists("LMS Enrollment", {"course": self.course, "member": self.member})
if not is_enrolled:
frappe.throw(
_("Certification cannot be issued as the member is not enrolled in this course.")
)
completion_certificate = frappe.db.get_value("LMS Course", self.course, "enable_certification")
if completion_certificate:
progress = frappe.db.get_value(
"LMS Enrollment", {"course": self.course, "member": self.member}, "progress"
)
if progress < 100:
frappe.throw(
_("Certification cannot be issued as the member has not completed the course.")
)
def validate_duplicate_certificate(self):
self.validate_course_duplicates()
self.validate_batch_duplicates()
@@ -177,3 +215,23 @@ def validate_certification_eligibility(course):
)
if progress < 100:
frappe.throw(_("You have not completed the course yet."))
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
return True
if doc.owner == user:
return True
if ptype not in ("read", "select", "print"):
return False
return doc.published
def get_permission_query_conditions(user):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles or "Batch Evaluator" in roles:
return None
return """(`tabLMS Certificate`.published = 1)"""

View File

@@ -157,7 +157,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-10 11:40:50.679211",
"modified": "2026-02-23 14:45:44.994705",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Certificate Request",
@@ -192,6 +192,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -209,6 +210,18 @@
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",

View File

@@ -13,6 +13,7 @@ from frappe.utils import (
format_time,
get_datetime,
get_fullname,
get_system_timezone,
get_time,
getdate,
nowtime,
@@ -118,16 +119,7 @@ class LMSCertificateRequest(Document):
def validate_timezone(self):
if self.timezone:
return
if self.batch_name:
timezone = frappe.db.get_value("LMS Batch", self.batch_name, "timezone")
if timezone:
self.timezone = timezone
return
if self.course:
timezone = frappe.db.get_value("LMS Course", self.course, "timezone")
if timezone:
self.timezone = timezone
return
self.timezone = get_system_timezone()
def send_notification(self):
outgoing_email_account = frappe.get_cached_value(

View File

@@ -34,9 +34,9 @@
"pricing_tab",
"pricing_section",
"paid_course",
"enable_certification",
"paid_certificate",
"column_break_acoj",
"enable_certification",
"section_break_vqbh",
"course_price",
"currency",
@@ -168,7 +168,7 @@
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"mandatory_depends_on": "paid_course",
"mandatory_depends_on": "eval: doc.paid_course || doc.paid_certificate",
"options": "Currency"
},
{
@@ -181,7 +181,7 @@
"fieldname": "course_price",
"fieldtype": "Currency",
"label": "Amount",
"mandatory_depends_on": "paid_course"
"mandatory_depends_on": "eval: doc.paid_course || doc.paid_certificate"
},
{
"fieldname": "column_break_acoj",
@@ -314,7 +314,7 @@
}
],
"make_attachments_public": 1,
"modified": "2026-01-13 18:48:56.069280",
"modified": "2026-02-19 11:41:57.038869",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Course",

View File

@@ -4,20 +4,9 @@
import frappe
from frappe.model.document import Document
from lms.lms.doctype.lms_enrollment.lms_enrollment import update_program_progress
from lms.lms.utils import get_course_progress
from lms.lms.utils import recalculate_course_progress
class LMSCourseProgress(Document):
def after_delete(self):
progress = get_course_progress(self.course, self.member)
membership = frappe.db.get_value(
"LMS Enrollment",
{
"member": self.member,
"course": self.course,
},
"name",
)
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
update_program_progress(self.member)
recalculate_course_progress(self.course, self.member)

View File

@@ -63,6 +63,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,

View File

@@ -8,7 +8,7 @@ from frappe.utils import ceil
class LMSEnrollment(Document):
def validate(self):
def before_insert(self):
self.validate_duplicate_enrollment()
self.validate_course_enrollment_eligibility()
self.validate_owner()
@@ -27,10 +27,11 @@ class LMSEnrollment(Document):
{
"course": self.course,
"member": self.member,
"name": ["!=", self.name],
},
)
if existing_enrollment:
if existing_enrollment and existing_enrollment != self.name:
frappe.throw(_("Student is already enrolled in this course."))
def validate_course_enrollment_eligibility(self):
@@ -49,7 +50,10 @@ class LMSEnrollment(Document):
)
if self.enrollment_from_batch:
return
if frappe.db.exists(
"LMS Batch Enrollment", {"batch": self.enrollment_from_batch, "member": self.member}
):
return
if not course_details.published and not is_admin():
frappe.throw(_("You cannot enroll in an unpublished course."))

View File

@@ -169,3 +169,18 @@ def get_minutes(duration_in_seconds):
if duration_in_seconds:
return int(duration_in_seconds) // 60
return 0
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
roles = frappe.get_roles(user)
if "Moderator" in roles or "Batch Evaluator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
return frappe.db.exists(
"LMS Batch Enrollment",
{"batch": doc.batch_name, "member": user},
)

View File

@@ -5,6 +5,8 @@ import frappe
from frappe import _
from frappe.model.document import Document
from lms.lms.utils import guest_access_allowed
class LMSProgram(Document):
def validate(self):
@@ -41,3 +43,27 @@ class LMSProgram(Document):
if self.member_count != member_count:
self.member_count = member_count
def has_permission(doc, ptype="read", user=None):
user = user or frappe.session.user
if user == "Guest" and not guest_access_allowed():
return False
roles = frappe.get_roles(user)
if "Moderator" in roles or "Course Creator" in roles:
return True
if ptype not in ("read", "select", "print"):
return False
is_enrolled = frappe.db.exists("LMS Program Member", {"parent": doc.name, "member": user})
if is_enrolled:
return True
is_program_published = frappe.db.get_value("LMS Program", doc.name, "published")
if is_program_published:
return True
return False

View File

@@ -88,7 +88,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-06-24 14:42:08.288983",
"modified": "2026-02-20 14:43:56.587110",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Programming Exercise Submission",
@@ -146,6 +146,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,

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