Merge branch 'frappe:develop' into fix/chapter-deletion

This commit is contained in:
Raizaaa
2026-02-02 16:28:06 +05:30
committed by GitHub
104 changed files with 3206 additions and 2520 deletions

1
.gitignore vendored
View File

@@ -12,4 +12,5 @@ node_modules
package-lock.json
lms/public/frontend
lms/www/lms.html
lms/www/_lms.html
frappe-ui

View File

@@ -1,5 +1,5 @@
{
"branches": ["develop"],
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"

View File

@@ -11,7 +11,6 @@ describe("Course Creation", () => {
cy.get("button").contains("Create").click();
cy.get("span").contains("New Course").click();
cy.wait(500);
cy.url().should("include", "/courses/new/edit");
cy.get("label").contains("Title").type("Test Course");
cy.get("label")
@@ -35,21 +34,6 @@ describe("Course Creation", () => {
});
});
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
/* Instructor */
cy.get("label")
.contains("Instructors")
@@ -69,13 +53,32 @@ describe("Course Creation", () => {
});
});
cy.button("Create").last().click();
// Edit Course Details
cy.wait(500);
cy.get("label")
.contains("Preview Video")
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
cy.get("label")
.contains("Category")
.parent()
.within(() => {
cy.get("button").click();
});
cy.get("[id^=headlessui-combobox-option-")
.should("be.visible")
.first()
.click();
cy.get("label").contains("Published").click();
cy.get("label").contains("Published On").type("2021-01-01");
cy.button("Save").click();
// Add Chapter
cy.wait(1000);
cy.button("Add Chapter").click();
cy.button("Add").click();
cy.wait(1000);
cy.get("[data-dismissable-layer]")

View File

@@ -54,7 +54,6 @@ declare module 'vue' {
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
@@ -94,6 +93,7 @@ declare module 'vue' {
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
NumberChartGraph: typeof import('./src/components/NumberChartGraph.vue')['default']
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.vue')['default']

View File

@@ -7,7 +7,7 @@
"dev": "vite",
"serve": "vite preview",
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry && yarn copy-colors-json",
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html",
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/_lms.html",
"copy-colors-json": "cp node_modules/frappe-ui/tailwind/colors.json src/utils/frappe-ui-colors.json"
},
"dependencies": {
@@ -25,8 +25,7 @@
"@editorjs/paragraph": "2.11.3",
"@editorjs/simple-image": "1.6.0",
"@editorjs/table": "2.4.2",
"@vueuse/core": "10.4.1",
"@vueuse/router": "12.7.0",
"@vueuse/core": "^14.1.0",
"ace-builds": "1.36.2",
"apexcharts": "4.3.0",
"chart.js": "4.4.1",
@@ -34,7 +33,7 @@
"dayjs": "1.11.10",
"dompurify": "3.2.6",
"feather-icons": "4.28.0",
"frappe-ui": "^0.1.256",
"frappe-ui": "^0.1.261",
"highlight.js": "11.11.1",
"lucide-vue-next": "0.383.0",
"markdown-it": "14.0.0",
@@ -43,11 +42,11 @@
"socket.io-client": "4.7.2",
"thememirror": "2.0.1",
"typescript": "5.7.2",
"vue": "^3.5.0",
"vue": "^3.5.27",
"vue-chartjs": "5.3.0",
"vue-codemirror": "6.1.1",
"vue-draggable-next": "2.2.1",
"vue-router": "4.2.2",
"vue-router": "^4.6.4",
"vue3-apexcharts": "1.8.0",
"vuedraggable": "4.1.0"
},

View File

@@ -35,7 +35,7 @@
<AxisChart
v-if="showProgressChart"
class="border"
class="border rounded-lg p-3 min-h-[300px]"
:config="{
data: filteredChartData,
title: __('Batch Summary'),

View File

@@ -65,6 +65,7 @@ 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'
const show = ref(false)
const quiz = ref(null)
@@ -94,7 +95,10 @@ const addAssessment = () => {
}
const redirectToForm = () => {
if (props.type == 'quiz') window.open('/lms/quizzes?new=true', '_blank')
else window.open('/lms/assignments?new=true', '_blank')
if (props.type == 'quiz') {
window.open(getLmsRoute('quizzes?new=true'), '_blank')
} else {
window.open(getLmsRoute('assignments?new=true'), '_blank')
}
}
</script>

View File

@@ -26,8 +26,8 @@
</div>
<div class="flex flex-col overflow-y-auto">
<div class="p-5">
<div class="flex items-center justify-between mb-4">
<div class="p-5 space-y-5">
<div class="flex items-center justify-between">
<div class="font-semibold text-ink-gray-9">
{{ __('Submission') }}
</div>
@@ -53,7 +53,7 @@
!['Pass', 'Fail'].includes(submissionResource.doc?.status) &&
submissionResource.doc?.owner == user.data?.name
"
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm mb-4"
class="bg-surface-blue-2 text-ink-blue-2 p-3 rounded-md leading-5 text-sm"
>
{{ __("You've successfully submitted the assignment.") }}
{{
@@ -63,12 +63,17 @@
}}
{{ __('Feel free to make edits to your submission if needed.') }}
</div>
<div v-if="showUploader()">
<div class="text-xs text-ink-gray-5 mt-1 mb-2">
{{ __('Add your assignment as {0}').format(assignment.data.type) }}
<div v-if="showUploader()" class="border rounded-lg p-3">
<div class="font-semibold mb-2">
{{ __('Upload Assignment') }}
</div>
<div class="text-ink-gray-5 text-sm mt-1 mb-4">
{{
__('You can only upload {0} files').format(assignment.data.type)
}}
</div>
<FileUploader
v-if="!submissionFile"
v-if="!submissionResource.doc?.assignment_attachment"
:fileTypes="getType()"
:uploadArgs="{
private: true,
@@ -87,21 +92,24 @@
</template>
</FileUploader>
<div v-else>
<div class="flex text-ink-gray-7">
<div class="border self-start rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<div class="flex items-center text-ink-gray-7">
<a
:href="submissionFile.file_url"
:href="submissionResource.doc.assignment_attachment"
target="_blank"
class="flex flex-col cursor-pointer !no-underline"
class="cursor-pointer !no-underline text-sm leading-5"
>
<span class="text-sm leading-5">
{{ submissionFile.file_name }}
</span>
<span class="text-sm text-ink-gray-5 mt-1">
{{ getFileSize(submissionFile.file_size) }}
</span>
<div class="flex items-center">
<div class="border rounded-md p-2 mr-2">
<FileText class="h-5 w-5 stroke-1.5" />
</div>
<span>
{{
submissionResource.doc.assignment_attachment
.split('/')
.pop()
}}
</span>
</div>
</a>
<X
v-if="canModifyAssignment"
@@ -142,13 +150,13 @@
user.data?.name == submissionResource.doc?.owner &&
submissionResource.doc?.comments
"
class="mt-8 p-3 bg-surface-blue-2 rounded-md"
class="mt-8 p-3 border rounded-lg"
>
<div class="text-sm text-ink-gray-5 font-medium mb-2">
{{ __('Comments by Evaluator') }}:
<div class="text-ink-gray-5 mb-4">
{{ __('Comments by Evaluator') }}
</div>
<div
class="leading-5 text-ink-gray-9"
class="leading-6 text-ink-gray-9"
v-html="submissionResource.doc.comments"
></div>
</div>
@@ -204,10 +212,8 @@ import {
} from 'frappe-ui'
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { FileText, X } from 'lucide-vue-next'
import { getFileSize } from '@/utils'
import { useRouter } from 'vue-router'
const submissionFile = ref(null)
const answer = ref(null)
const comments = ref(null)
const router = useRouter()
@@ -266,9 +272,7 @@ const newSubmission = createResource({
assignment: props.assignmentID,
member: user.data?.name,
}
if (showUploader()) {
doc.assignment_attachment = submissionFile.value.file_url
} else {
if (!showUploader()) {
doc.answer = answer.value
}
return {
@@ -277,19 +281,6 @@ const newSubmission = createResource({
},
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
submissionFile.value = data
},
})
const submissionResource = createDocumentResource({
doctype: 'LMS Assignment Submission',
name: props.submissionName,
@@ -302,11 +293,6 @@ const submissionResource = createDocumentResource({
watch(submissionResource, () => {
if (submissionResource.doc) {
if (submissionResource.doc.assignment_attachment) {
imageResource.reload({
image: submissionResource.doc.assignment_attachment,
})
}
if (submissionResource.doc.answer) {
answer.value = submissionResource.doc.answer
}
@@ -315,7 +301,10 @@ watch(submissionResource, () => {
}
if (submissionResource.isDirty) {
isDirty.value = true
} else if (showUploader() && !submissionFile.value) {
} else if (
showUploader() &&
!submissionResource.doc.assignment_attachment
) {
isDirty.value = true
} else if (!showUploader() && !answer.value) {
isDirty.value = true
@@ -325,11 +314,17 @@ watch(submissionResource, () => {
}
})
watch(submissionFile, () => {
if (props.submissionName == 'new' && submissionFile.value) {
isDirty.value = true
watch(
() => submissionResource.doc,
() => {
if (
props.submissionName == 'new' &&
submissionResource.doc?.assignment_attachment
) {
isDirty.value = true
}
}
})
)
const submitAssignment = () => {
if (props.submissionName != 'new') {
@@ -341,13 +336,13 @@ const submitAssignment = () => {
submissionResource.setValue.submit(
{
...submissionResource.doc,
assignment_attachment: submissionFile.value?.file_url,
evaluator: evaluator,
comments: comments.value,
answer: answer.value,
},
{
onSuccess(data) {
isDirty.value = false
toast.success(__('Changes saved successfully'))
},
}
@@ -388,7 +383,7 @@ const addNewSubmission = () => {
const saveSubmission = (file) => {
isDirty.value = true
submissionFile.value = file
submissionResource.doc.assignment_attachment = file.file_url
}
const markLessonProgress = () => {
@@ -439,7 +434,7 @@ const validateFile = (file) => {
const removeSubmission = () => {
isDirty.value = true
submissionFile.value = null
submissionResource.doc.assignment_attachment = ''
}
const canGradeSubmission = computed(() => {

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
<div
v-if="batch.data.seat_count && seats_left > 0"
v-if="batch.data.seat_count && batch.data.seats_left > 0"
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
:class="
batch.data.amount || batch.data.courses.length
@@ -9,16 +9,16 @@
: 'w-fit mb-4'
"
>
{{ seats_left }}
<span v-if="seats_left > 1">
{{ batch.data.seats_left }}
<span v-if="batch.data.seats_left > 1">
{{ __('Seats Left') }}
</span>
<span v-else-if="seats_left == 1">
<span v-else-if="batch.data.seats_left == 1">
{{ __('Seat Left') }}
</span>
</div>
<div
v-else-if="batch.data.seat_count && seats_left <= 0"
v-else-if="batch.data.seat_count && batch.data.seats_left <= 0"
class="text-xs bg-red-100 text-red-700 float-right px-2 py-0.5 rounded-md"
>
{{ __('Sold Out') }}
@@ -54,6 +54,7 @@
{{ batch.data.timezone }}
</span>
</div>
<div v-if="!readOnlyMode">
<router-link
v-if="canAccessBatch"
@@ -190,15 +191,10 @@ const enrollInBatch = () => {
)
}
const seats_left = computed(() => {
if (props.batch.data?.seat_count) {
return props.batch.data?.seat_count - props.batch.data?.students?.length
}
return null
})
const isStudent = computed(() => {
return props.batch.data?.students?.includes(user.data?.name)
return user.data
? props.batch.data?.students?.includes(user.data?.name)
: false
})
const isModerator = computed(() => {
@@ -218,6 +214,9 @@ const isInstructor = computed(() => {
})
const canAccessBatch = computed(() => {
if (!user.data) {
return false
}
return isModerator.value || isStudent.value || isEvaluator.value
})

View File

@@ -19,9 +19,16 @@
showOptions = true
}
"
@click="
(e) => {
showOptions = true
nextTick(() => {
setFocus()
})
}
"
@focus="
() => {
showOptions = true
if (!filterOptions.data || filterOptions.data.length === 0) {
reload('')
}
@@ -33,10 +40,10 @@
<template #body="{ isOpen, close }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
class="flex flex-col mt-1 rounded-lg bg-surface-white py-1 text-base border-2 max-h-[13rem]"
>
<ComboboxOptions
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
class="flex-1 my-1 overflow-y-auto px-1.5"
:class="options.length ? 'min-h-[6rem]' : 'min-h-[3.8rem]'"
static
>
@@ -55,7 +62,11 @@
>
<div class="flex flex-col gap-1 p-1">
<div class="text-base font-medium text-ink-gray-8">
{{ option.description }}
{{
option.value == option.label
? option.description
: option.label
}}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
@@ -66,22 +77,19 @@
<div v-else class="text-ink-gray-7 px-4">
{{ __('No results found') }}
</div>
<div
v-if="attrs.onCreate"
class="absolute bottom-2 left-1 w-[95%] pt-2 bg-white border-t"
>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</ComboboxOptions>
<div v-if="attrs.onCreate" class="px-1 pt-2 bg-white border-t">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
@click="attrs.onCreate(close)"
>
<template #prefix>
<Plus class="h-4 w-4 stroke-1.5" />
</template>
</Button>
</div>
</div>
</div>
</template>
@@ -115,7 +123,7 @@ import {
} from '@headlessui/vue'
import { createResource, Popover, Button } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue'
import { watchDebounced } from '@vueuse/core'
import { set, watchDebounced } from '@vueuse/core'
import { X, Plus } from 'lucide-vue-next'
const props = defineProps({
@@ -149,18 +157,20 @@ const props = defineProps({
const values = defineModel()
const attrs = useAttrs()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const emit = defineEmits(['update:modelValue'])
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
val?.value && addValue(val.value)
showOptions.value = false
emit('update:modelValue', values.value)
},
})
@@ -232,6 +242,7 @@ const addValue = (value) => {
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
emit('update:modelValue', values.value)
}
function setFocus() {

View File

@@ -34,7 +34,12 @@
<img
v-if="type == 'image'"
:src="modelValue"
class="border rounded-md w-44 h-auto"
:class="[
'border object-cover',
shape === 'circle'
? 'w-20 h-20 rounded-full'
: 'w-44 h-auto min-h-20 rounded-md',
]"
/>
<video v-else controls class="border rounded-md w-44 h-auto">
<source :src="modelValue" />
@@ -67,11 +72,12 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
modelValue: string
modelValue: string | null
label?: string
description?: string
type?: 'image' | 'video'
required?: boolean
shape?: 'square' | 'circle'
}>(),
{
modelValue: '',
@@ -79,6 +85,7 @@ const props = withDefaults(
description: '',
type: 'image',
required: true,
shape: 'square',
}
)

View File

@@ -37,7 +37,7 @@
<CertificationLinks :courseName="course.data.name" class="w-full" />
</div>
<router-link
v-else-if="course.data.paid_course"
v-else-if="course.data.paid_course && !isAdmin"
:to="{
name: 'Billing',
params: {
@@ -56,14 +56,15 @@
</Button>
</router-link>
<Badge
v-else-if="course.data.disable_self_learning"
v-else-if="course.data.disable_self_learning && !isAdmin"
theme="blue"
size="lg"
class="mb-4"
>
{{ __('Contact the Administrator to enroll for this course.') }}
{{ __('Contact the Administrator to enroll for this course') }}
</Badge>
<Button
v-else-if="!user.data?.is_moderator && !is_instructor()"
v-else-if="!isAdmin"
@click="enrollStudent()"
variant="solid"
class="w-full"
@@ -88,40 +89,11 @@
</template>
{{ __('Get Certificate') }}
</Button>
<Button
v-if="user.data?.is_moderator || is_instructor()"
class="w-full mt-2"
size="md"
@click="showProgressSummary"
>
<template #prefix>
<TrendingUp class="size-4 stroke-1.5" />
{{ __('Progress Summary') }}
</template>
</Button>
<router-link
v-if="user?.data?.is_moderator || is_instructor()"
:to="{
name: 'CourseForm',
params: {
courseName: course.data.name,
},
}"
>
<Button variant="subtle" class="w-full mt-2" size="md">
<template #prefix>
<Pencil class="size-4 stroke-1.5" />
</template>
<span>
{{ __('Edit') }}
</span>
</Button>
</router-link>
</div>
<div class="space-y-4">
<div
class="font-medium text-ink-gray-9"
:class="{ 'mt-8': !readOnlyMode }"
:class="{ 'mt-8': course.data.membership && !readOnlyMode }"
>
{{ __('This course has:') }}
</div>
@@ -168,12 +140,6 @@
</div>
</div>
</div>
<CourseProgressSummary
v-if="user.data?.is_moderator || is_instructor()"
v-model="showProgressModal"
:courseName="course.data.name"
:enrollments="course.data.enrollments"
/>
</template>
<script setup>
import {
@@ -191,12 +157,10 @@ import { Badge, Button, call, createResource, toast } from 'frappe-ui'
import { formatAmount } from '@/utils/'
import { useRouter } from 'vue-router'
import CertificationLinks from '@/components/CertificationLinks.vue'
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
import { useTelemetry } from 'frappe-ui/frappe'
const router = useRouter()
const user = inject('$user')
const showProgressModal = ref(false)
const readOnlyMode = window.read_only_mode
const { capture } = useTelemetry()
@@ -295,7 +259,7 @@ const fetchCertificate = () => {
})
}
const showProgressSummary = () => {
showProgressModal.value = true
}
const isAdmin = computed(() => {
return user.data?.is_moderator || is_instructor()
})
</script>

View File

@@ -15,7 +15,10 @@
{{ __(title) }}
</div>
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
{{ __('Add Chapter') }}
<template #prefix>
<Plus class="size-4 stroke-1.5" />
</template>
{{ __('Add') }}
</Button>
</div>
<div
@@ -174,6 +177,7 @@ import {
FilePenLine,
HelpCircle,
MonitorPlay,
Plus,
Trash2,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'

View File

@@ -23,10 +23,8 @@
(value, close) => {
close()
router.push({
name: 'CourseForm',
params: {
courseName: 'new',
},
name: 'Courses',
query: { newCourse: '1' },
})
}
"

View File

@@ -1,231 +0,0 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Course Progress Summary'),
size: '5xl',
}"
>
<template #body-content>
<div
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
>
<div class="w-full">
<div class="flex items-center justify-between space-x-5 mb-4">
<FormControl
v-model="searchFilter"
:placeholder="__('Search by Member')"
type="text"
class="w-full"
/>
</div>
<div class="max-h-[70vh] overflow-y-auto">
<ListView
v-if="progressList.loading || progressList.data?.length"
:columns="progressColumns"
:rows="progressList.data"
rowKey="name"
:options="{
selectable: false,
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 progressColumns"
:key="item.key"
>
<template #prefix="{ item }">
<FeatherIcon
:name="item.icon?.toString()"
class="h-4 w-4"
/>
</template>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<ListRow :row="row">
<template #default="{ column, item }">
<ListRowItem
:item="row[column.key]"
:align="column.align"
>
<template #prefix>
<div v-if="column.key == 'member_name'">
<Avatar
class="flex items-center"
:image="row['member_image']"
:label="item"
size="sm"
/>
</div>
</template>
<div>
{{ row[column.key].toString() }}
</div>
</ListRowItem>
</template>
</ListRow>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-5"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="mb-4 self-start w-full space-y-5">
<div
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Enrollments'),
value: memberCount || 0,
}"
/>
<NumberChart
class="border rounded-md w-full"
:config="{
title: __('Average Progress %'),
value: chartDetails.data?.average_progress || 0,
}"
/>
</div>
<DonutChart
:config="{
data: chartDetails.data?.progress_distribution || [],
title: __('Progress Distribution'),
categoryColumn: 'category',
valueColumn: 'count',
colors: [
getColor('red', 400),
getColor('amber', 400),
getColor('pink', 400),
getColor('blue', 400),
getColor('green', 400),
],
}"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
Dialog,
DonutChart,
FeatherIcon,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
NumberChart,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { getColor } from '@/utils'
const show = defineModel<boolean>({ default: false })
const searchFilter = ref<string | null>(null)
type Filters = {
course: string | undefined
member_name?: string[]
}
const props = defineProps<{
courseName?: string
enrollments?: number
}>()
const memberCount = ref<number>(props.enrollments || 0)
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
params: {
course: props.courseName,
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.courseName,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
],
pageLength: 50,
auto: true,
})
watch([searchFilter], () => {
let filterApplied = false
let filters: Filters = {
course: props.courseName,
}
if (searchFilter.value) {
filters.member_name = ['like', `%${searchFilter.value}%`]
filterApplied = true
}
progressList.update({
filters: filters,
})
progressList.reload(
{},
{
onSuccess(data: any[]) {
memberCount.value = filterApplied ? data.length : props.enrollments || 0
},
}
)
})
const progressColumns = computed(() => {
return [
{
label: __('Member'),
key: 'member_name',
width: '60%',
icon: 'user',
},
{
label: __('Progress'),
key: 'progress',
align: 'right',
icon: 'trending-up',
},
]
})
</script>

View File

@@ -1,17 +1,25 @@
<template>
<Dialog
v-model="show"
:options="{
size: '3xl',
}"
>
<template #body-header>
<div class="flex items-center mb-5">
<div class="flex items-center justify-between mb-5">
<div class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{ __('Edit Profile') }}
</div>
<Badge v-if="isDirty" class="ml-4" theme="orange">
{{ __('Not Saved') }}
</Badge>
<div class="space-x-2">
<Badge v-if="isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<div class="pb-5 float-right">
<Button variant="solid" @click="saveProfile()">
{{ __('Save') }}
</Button>
</div>
</div>
</div>
</template>
<template #body-content>
@@ -19,52 +27,13 @@
<div class="grid grid-cols-2 gap-10">
<div class="space-y-4">
<div class="space-y-4">
<div>
<div class="text-xs text-ink-gray-5 mb-1">
{{ __('Profile Image') }}
</div>
<FileUploader
v-if="!profile.image"
:fileTypes="['image/*']"
:validateFile="validateFile"
@success="(file) => saveImage(file)"
>
<template
v-slot="{ file, progress, uploading, openFileSelector }"
>
<div class="mb-4">
<Button @click="openFileSelector" :loading="uploading">
{{
uploading
? `Uploading ${progress}%`
: 'Upload a profile image'
}}
</Button>
</div>
</template>
</FileUploader>
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="profile.image?.file_url"
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
/>
<Uploader
v-model="profile.image"
:label="__('Profile Image')"
:required="true"
shape="circle"
/>
<div class="text-base flex flex-col ml-2">
<span>
{{ profile.image?.file_name }}
</span>
<span class="text-sm text-ink-gray-4 mt-1">
{{ getFileSize(profile.image?.file_size) }}
</span>
</div>
<X
@click="removeImage()"
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
/>
</div>
</div>
</div>
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
@@ -115,13 +84,6 @@
</div>
</div>
</template>
<template #actions="{ close }">
<div class="pb-5 float-right">
<Button variant="solid" @click="saveProfile(close)">
{{ __('Save') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup>
@@ -131,15 +93,14 @@ import {
createResource,
Dialog,
FormControl,
FileUploader,
TextEditor,
toast,
} from 'frappe-ui'
import { ref, reactive, watch } from 'vue'
import { X } from 'lucide-vue-next'
import { getFileSize, sanitizeHTML } from '@/utils'
import { sanitizeHTML } from '@/utils'
import Link from '@/components/Controls/Link.vue'
const show = defineModel()
const reloadProfile = defineModel('reloadProfile')
const hasLanguageChanged = ref(false)
const isDirty = ref(false)
@@ -163,19 +124,6 @@ const profile = reactive({
twitter: '',
})
const imageResource = createResource({
url: 'lms.lms.api.get_file_info',
makeParams(values) {
return {
file_url: values.image,
}
},
auto: false,
onSuccess(data) {
profile.image = data
},
})
const updateProfile = createResource({
url: 'frappe.client.set_value',
makeParams(values) {
@@ -183,7 +131,7 @@ const updateProfile = createResource({
doctype: 'User',
name: props.profile.data.name,
fieldname: {
user_image: profile.image?.file_url || null,
user_image: profile.image || null,
...profile,
},
}
@@ -193,13 +141,13 @@ const updateProfile = createResource({
},
})
const saveProfile = (close) => {
const saveProfile = () => {
profile.bio = sanitizeHTML(profile.bio)
updateProfile.submit(
{},
{
onSuccess() {
close()
show.value = false
reloadProfile.value.reload()
if (hasLanguageChanged.value) {
hasLanguageChanged.value = false
@@ -213,21 +161,6 @@ const saveProfile = (close) => {
)
}
const validateFile = (file) => {
let extension = file.name.split('.').pop().toLowerCase()
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
return 'Only image file is allowed.'
}
}
const saveImage = (file) => {
profile.image = file
}
const removeImage = () => {
profile.image = null
}
watch(
() => profile,
(newVal) => {
@@ -240,7 +173,7 @@ watch(
return
}
}
if (profile.image?.file_url !== props.profile.data.user_image) {
if (profile.image !== props.profile.data.user_image) {
isDirty.value = true
return
}
@@ -262,7 +195,7 @@ watch(
profile.linkedin = newVal.linkedin
profile.github = newVal.github
profile.twitter = newVal.twitter
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
profile.image = newVal.user_image
isDirty.value = false
}
}

View File

@@ -0,0 +1,20 @@
<template>
<div class="border rounded-lg p-3 space-y-2">
<div class="text-ink-gray-5">
{{ __(title) }}
</div>
<div class="flex items-center space-x-2">
<slot name="prefix" />
<div class="font-semibold text-2xl">
{{ value }}
</div>
<slot name="suffix" />
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
title: string
value: number | string
}>()
</script>

View File

@@ -1,6 +1,9 @@
<template>
<Tooltip :text="`${props.progress}%`">
<div class="w-full bg-surface-gray-3 rounded-full h-1">
<div
class="w-full bg-surface-gray-3 rounded-full h-1"
:class="$attrs.class"
>
<div
class="bg-surface-gray-7 rounded-full"
:class="progressBarHeight"

View File

@@ -1,16 +1,25 @@
<template>
<div class="flex flex-col justify-between h-full">
<div class="flex flex-col h-full">
<div>
<div class="flex items-center justify-between">
<div class="font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<div class="space-x-2">
<Badge
v-if="isDirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
<Button
variant="solid"
:loading="saveSettings.loading"
@click="update"
>
{{ __('Update') }}
</Button>
</div>
</div>
<div class="text-xs text-ink-gray-5">
{{ __(description) }}
@@ -19,11 +28,6 @@
<div class="overflow-y-auto">
<SettingFields :sections="sections" :data="branding.data" />
</div>
<div class="flex flex-row-reverse mt-auto">
<Button variant="solid" :loading="saveSettings.loading" @click="update">
{{ __('Update') }}
</Button>
</div>
</div>
</template>
<script setup>

View File

@@ -186,8 +186,9 @@ const openProfile = (username: string) => {
}
const deleteEvaluator = (evaluator: string) => {
call('lms.lms.api.delete_evaluator', {
evaluator: evaluator,
call('frappe.client.delete', {
doctype: 'Course Evaluator',
name: evaluator,
})
.then(() => {
toast.success(__('Evaluator deleted successfully'))

View File

@@ -8,22 +8,24 @@
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
{{ __('Settings') }}
</h1>
<div v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
<nav class="space-y-1">
<div v-for="item in tab.items" @click="activeTab = item">
<SidebarLink
:link="item"
:key="item.label"
:activeTab="activeTab?.label"
/>
<div class="space-y-5">
<div v-for="tab in tabs" :key="tab.label">
<div
v-if="!tab.hideLabel"
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
>
<span>{{ __(tab.label) }}</span>
</div>
</nav>
<nav class="space-y-1">
<div v-for="item in tab.items" @click="activeTab = item">
<SidebarLink
:link="item"
:key="item.label"
:activeTab="activeTab?.label"
/>
</div>
</nav>
</div>
</div>
</div>
<div

View File

@@ -1,12 +1,33 @@
<template>
<div class="flex flex-col h-full text-base">
<div class="flex items-center space-x-2 mb-8 -ml-1.5">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="emit('updateStep', 'list')"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ __('Transaction Details') }}
<div class="flex items-center justify-between mb-10 -ml-1.5">
<div class="flex items-center space-x-2">
<ChevronLeft
class="size-5 stroke-1.5 text-ink-gray-7 cursor-pointer"
@click="emit('updateStep', 'list')"
/>
<div class="text-xl font-semibold text-ink-gray-9">
{{ __('Transaction Details') }}
</div>
</div>
<div class="space-x-2">
<Button
v-if="
transactionData?.payment_for_document_type &&
transactionData?.payment_for_document
"
@click="openDetails()"
>
{{ __('Open the ') }}
{{
transactionData.payment_for_document_type == 'LMS Course'
? __('Course')
: __('Batch')
}}
</Button>
<Button variant="solid" @click="saveTransaction()">
{{ __('Save') }}
</Button>
</div>
</div>
<div v-if="transactionData" class="overflow-y-auto">
@@ -21,6 +42,12 @@
type="checkbox"
v-model="transactionData.payment_for_certificate"
/>
<FormControl
:label="__('Member Consent')"
type="checkbox"
v-model="transactionData.member_consent"
:disabled="true"
/>
</div>
<div class="grid grid-cols-3 gap-5 mt-5">
@@ -28,22 +55,27 @@
:label="__('Member')"
doctype="User"
v-model="transactionData.member"
:required="true"
/>
<FormControl
:label="__('Billing Name')"
v-model="transactionData.billing_name"
:required="true"
/>
<Link
:label="__('Source')"
v-model="transactionData.source"
doctype="LMS Source"
/>
<Link
<FormControl
type="select"
:options="documentTypeOptions"
:label="__('Payment For Document Type')"
v-model="transactionData.payment_for_document_type"
doctype="DocType"
/>
<Link
v-if="transactionData.payment_for_document_type"
:label="__('Payment For Document')"
v-model="transactionData.payment_for_document"
:doctype="transactionData.payment_for_document_type"
@@ -58,8 +90,13 @@
:label="__('Currency')"
v-model="transactionData.currency"
doctype="Currency"
:required="true"
/>
<FormControl
:label="__('Amount')"
v-model="transactionData.amount"
:required="true"
/>
<FormControl :label="__('Amount')" v-model="transactionData.amount" />
<FormControl
v-if="transactionData.amount_with_gst"
:label="__('Amount with GST')"
@@ -103,6 +140,7 @@
:label="__('Address')"
v-model="transactionData.address"
doctype="Address"
:required="true"
/>
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
@@ -116,25 +154,12 @@
/>
</div>
</div>
<div class="space-x-2 mt-auto ml-auto">
<Button @click="openDetails()">
{{ __('Open the ') }}
{{
data.payment_for_document_type == 'LMS Course'
? __('Course')
: __('Batch')
}}
</Button>
<Button variant="solid" @click="saveTransaction()">
{{ __('Save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, FormControl } from 'frappe-ui'
import { Button, FormControl, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { ChevronLeft } from 'lucide-vue-next'
import Link from '@/components/Controls/Link.vue'
@@ -148,21 +173,40 @@ const props = defineProps<{
data: any
}>()
watch(
() => props.data,
(newVal) => {
transactionData.value = newVal ? { ...newVal } : null
},
{ immediate: true }
)
const saveTransaction = () => {
if (props.data?.name) {
updateTransaction()
} else {
createTransaction()
}
}
const saveTransaction = (close: () => void) => {
props.transactions.value.setValue
const createTransaction = () => {
console.log(props.transactions)
props.transactions.insert
.submit({
...transactionData.value,
})
.then(() => {
close()
toast.success(__('Transaction created successfully'))
})
.catch((err: any) => {
toast.error(__(err.messages?.[0] || err))
console.error(err)
})
}
const updateTransaction = () => {
props.transactions.setValue
.submit({
...transactionData.value,
})
.then(() => {
toast.success(__('Transaction updated successfully'))
})
.catch((err: any) => {
toast.error(__(err.messages?.[0] || err))
console.error(err)
})
}
@@ -181,4 +225,48 @@ const openDetails = () => {
show.value = false
}
}
const emptyTransactionData = {
payment_received: false,
payment_for_certificate: false,
member: null,
billing_name: null,
source: null,
payment_for_document_type: null,
payment_for_document: null,
member_consent: false,
currency: null,
amount: null,
amount_with_gst: null,
coupon: null,
coupon_code: null,
discount_amount: null,
original_amount: null,
order_id: null,
payment_id: null,
gstin: null,
pan: null,
address: null,
}
watch(
() => props.data,
(newVal) => {
transactionData.value = newVal ? { ...newVal } : emptyTransactionData
},
{ immediate: true }
)
const documentTypeOptions = computed(() => {
return [
{
label: __('Course'),
value: 'LMS Course',
},
{
label: __('Batch'),
value: 'LMS Batch',
},
]
})
</script>

View File

@@ -1,12 +1,20 @@
<template>
<div class="flex min-h-0 flex-col text-base">
<div class="mb-5">
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
{{ __(label) }}
</div>
<div class="text-ink-gray-6 leading-5">
{{ __(description) }}
</div>
</div>
<Button @click="emit('updateStep', 'new', null)">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4 stroke-1.5" />
</template>
{{ __('Add Transaction') }}
</Button>
</div>
<div class="flex items-center space-x-5 mb-4">

View File

@@ -1,6 +1,13 @@
<template>
<TransactionDetails
v-if="step == 'new'"
:transactions="transactions"
:data="data"
v-model:show="show"
@updateStep="updateStep"
/>
<TransactionList
v-if="step === 'list'"
v-else-if="step === 'list'"
:label="props.label"
:description="props.description"
:transactions="transactions"
@@ -33,6 +40,8 @@ const updateStep = (newStep: 'list' | 'new' | 'edit', newData: any) => {
step.value = newStep
if (newData) {
data.value = newData
} else {
data.value = null
}
}

View File

@@ -269,12 +269,13 @@ const iconProps = {
onMounted(() => {
setUpOnboarding()
addKeyboardShortcut()
updateSidebarLinks()
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
})
const setSidebarLinks = () => {
const updateSidebarLinksVisibility = () => {
sidebarSettings.reload(
{},
{
@@ -405,9 +406,13 @@ const steps = reactive([
minimize.value = true
let course = await getFirstCourse()
if (course) {
router.push({ name: 'CourseForm', params: { courseName: course } })
router.push({
name: 'CourseDetail',
params: { courseName: course },
hash: '#settings',
})
} else {
router.push({ name: 'CourseForm' })
router.push({ name: 'Courses', query: { newCourse: '1' } })
}
},
},
@@ -422,11 +427,12 @@ const steps = reactive([
let course = await getFirstCourse()
if (course) {
router.push({
name: 'CourseForm',
name: 'CourseDetail',
params: { courseName: course },
hash: '#settings',
})
} else {
router.push({ name: 'Courses' })
router.push({ name: 'Courses', query: { newCourse: '1' } })
}
},
},
@@ -591,10 +597,18 @@ watch(userResource, async () => {
await programs.reload()
setUpOnboarding()
}
sidebarLinks.value = getSidebarLinks()
setSidebarLinks()
updateSidebarLinks()
})
watch(settingsStore.settings, () => {
updateSidebarLinks()
})
const updateSidebarLinks = () => {
sidebarLinks.value = getSidebarLinks()
updateSidebarLinksVisibility()
}
const redirectToWebsite = () => {
window.open('https://frappe.io/learning', '_blank')
}

View File

@@ -137,11 +137,12 @@ import {
} from 'lucide-vue-next'
import { inject, ref, getCurrentInstance, computed } from 'vue'
import { formatTime } from '@/utils'
import { Button, createResource, call } from 'frappe-ui'
import { Button, createResource, createListResource, call } from 'frappe-ui'
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
const dayjs = inject('$dayjs')
const user = inject('$user')
const showEvalModal = ref(false)
const app = getCurrentInstance()
const { $dialog } = app.appContext.config.globalProperties
@@ -165,12 +166,26 @@ const props = defineProps({
},
})
const upcoming_evals = createResource({
url: 'lms.lms.utils.get_upcoming_evals',
params: {
courses: props.courses.map((course) => course.course),
batch: props.batch,
const upcoming_evals = createListResource({
doctype: 'LMS Certificate Request',
filters: {
course: props.courses?.length
? ['in', props.courses.map((course) => course.course)]
: undefined,
batch_name: props.batch || undefined,
status: 'Upcoming',
member: user?.data?.name,
date: ['>=', dayjs().format('YYYY-MM-DD')],
},
fields: [
'name',
'date',
'start_time',
'evaluator_name',
'course_title',
'google_meet_link',
],
orderBy: 'date',
auto: true,
})

View File

@@ -140,9 +140,6 @@ const assignmentFilter = computed(() => {
if (typeFilter.value) {
filters.type = typeFilter.value
}
if (!user.data?.is_moderator) {
filters.owner = user.data?.email
}
return filters
})

View File

@@ -248,6 +248,7 @@ 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)
@@ -357,7 +358,9 @@ const isStudent = computed(() => {
})
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/batches/${props.batchName}`
window.location.href = `/login?redirect-to=${getLmsRoute(
`batches/${props.batchName}`
)}`
}
const openAnnouncementModal = () => {

View File

@@ -207,14 +207,18 @@
:text="access.data.message"
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
:buttonLink="
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
type == 'course'
? getLmsRoute(`courses/${name}`)
: getLmsRoute(`batches/${name}`)
"
/>
</div>
<div v-else-if="!user.data?.name">
<NotPermitted
text="Please login to access this page."
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
:buttonLink="`/login?redirect-to=${getLmsRoute(
`billing/${type}/${name}`
)}`"
/>
</div>
</div>
@@ -235,6 +239,7 @@ import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { X } from 'lucide-vue-next'
import { useTelemetry } from 'frappe-ui/frappe'
import { getLmsRoute } from '@/utils/basePath'
const user = inject('$user')
const { brand } = sessionStore()
@@ -441,11 +446,11 @@ const changeCurrency = (country) => {
const redirectTo = computed(() => {
if (props.type == 'course') {
return `/lms/courses/${props.name}`
return getLmsRoute(`courses/${props.name}`)
} else if (props.type == 'batch') {
return `/lms/batches/${props.name}`
return getLmsRoute(`batches/${props.name}`)
} else if (props.type == 'certificate') {
return `/lms/courses/${props.name}/certification`
return getLmsRoute(`courses/${props.name}/certification`)
}
})

View File

@@ -134,6 +134,7 @@ import {
import { computed, inject, onMounted, ref } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import { useRouter } from 'vue-router'
import EmptyState from '@/components/EmptyState.vue'
import UserAvatar from '@/components/UserAvatar.vue'
@@ -145,8 +146,14 @@ const hiring = ref(false)
const { brand } = sessionStore()
const memberCount = ref(0)
const dayjs = inject('$dayjs')
const user = inject('$user')
const router = useRouter()
onMounted(() => {
if (!user.data) {
router.push({ name: 'Courses' })
return
}
setFiltersFromQuery()
updateParticipants()
})
@@ -171,7 +178,7 @@ const categories = createListResource({
doctype: 'LMS Certificate',
url: 'lms.lms.api.get_certification_categories',
cache: ['certification_categories'],
auto: true,
auto: user.data ? true : false,
transform(data) {
data.unshift({ label: __(' '), value: ' ' })
return data

View File

@@ -1,194 +0,0 @@
<template>
<div v-if="course.data">
<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" />
</header>
<div class="m-5">
<div class="flex justify-between w-full space-x-5">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ course.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ course.data.short_introduction }}
</div>
<div class="flex items-center">
<Tooltip
v-if="parseInt(course.data.rating) > 0"
:text="__('Average Rating')"
class="flex items-center"
>
<Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7">
{{ course.data.rating }}
</span>
</Tooltip>
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
>&middot;</span
>
<Tooltip
v-if="course.data.enrollment_count"
:text="__('Enrolled Students')"
class="flex items-center"
>
<Users class="h-4 w-4 text-ink-gray-7" />
<span class="ml-1">
{{ course.data.enrollment_count_formatted }}
</span>
</Tooltip>
<span v-if="course.data.enrollment_count" class="mx-3"
>&middot;</span
>
<div class="flex items-center">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': course.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in course.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors :instructors="course.data.instructors" />
</div>
</div>
<div v-if="course.data.tags" class="flex my-4 w-fit">
<Badge
theme="gray"
size="lg"
class="mr-2 text-ink-gray-9"
v-for="tag in course.data.tags.split(', ')"
>
{{ tag }}
</Badge>
</div>
<div class="md:hidden my-4">
<CourseCardOverlay :course="course" />
</div>
<div
v-html="course.data.description"
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"
></div>
<div class="mt-10">
<CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
:getProgress="course.data.membership ? true : false"
/>
</div>
<CourseReviews
:courseName="course.data.name"
:avg_rating="course.data.rating"
:membership="course.data.membership"
/>
</div>
<div class="hidden md:block">
<CourseCardOverlay :course="course" />
</div>
</div>
<RelatedCourses :courseName="course.data.name" />
</div>
</div>
</template>
<script setup>
import {
createResource,
Breadcrumbs,
Badge,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, watch } from 'vue'
import { Users, Star } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue'
const { brand } = sessionStore()
const router = useRouter()
const user = inject('$user')
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
const course = createResource({
url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
watch(
() => props.courseName,
() => {
course.reload()
}
)
watch(course, () => {
if (
!isInstructor() &&
!user.data?.is_moderator &&
!course.data?.published &&
!course.data?.upcoming
) {
router.push({
name: 'Courses',
})
}
})
const isInstructor = () => {
let user_is_instructor = false
course.data?.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
})
return items
})
usePageMeta(() => {
return {
title: course?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
</style>

View File

@@ -38,7 +38,7 @@
import { computed, inject, onMounted, ref } from 'vue'
import { Breadcrumbs, call, createResource, usePageMeta } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { sessionStore } from '../stores/session'
import { sessionStore } from '../../stores/session'
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
const courseTitle = ref(null)

View File

@@ -0,0 +1,400 @@
<template>
<div class="p-5">
<div class="grid grid-cols-4 gap-5 mb-5">
<NumberChartGraph
:title="__('Enrolled')"
:value="formatAmount(course.data?.enrollments)"
/>
<NumberChartGraph
:title="__('Average Completion Rate')"
:value="averageCompletionRate"
/>
<NumberChartGraph
:title="__('Average Rating')"
:value="course.data?.rating || 0"
>
<template #prefix>
<Star class="size-5 text-transparent fill-amber-500" />
</template>
</NumberChartGraph>
<NumberChartGraph :title="__('Lessons')" :value="course.data?.lessons" />
</div>
<div class="grid grid-cols-[2fr_1fr] gap-5 items-start">
<div v-if="course.data?.enrollments" class="border rounded-lg py-3 px-4">
<div class="flex items-center justify-between mb-3">
<div class="text-lg 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="progressList.loading || progressList.data?.length"
class="max-h-[63vh] overflow-y-auto"
>
<ListView
:columns="progressColumns"
:rows="progressList.data"
rowKey="name"
:options="{
selectable: false,
showTooltip: false,
}"
>
<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 progressColumns"
:key="item.key"
>
</ListHeaderItem>
</ListHeader>
<ListRows v-for="row in progressList.data" class="max-h-[500px]">
<router-link
:to="{
name: 'Profile',
params: { username: row.member_username },
}"
>
<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>
</router-link>
</ListRows>
</ListView>
<div
v-if="progressList.data && progressList.hasNextPage"
class="flex justify-center my-3"
>
<Button @click="progressList.next()">
{{ __('Load More') }}
</Button>
</div>
</div>
</div>
<div class="space-y-5">
<div
v-if="chartDetails.data?.average_progress > 0"
class="border rounded-lg p-4"
>
<div class="text-ink-gray-5 mb-4">
{{ __('Progress Summary') }}
</div>
<div class="grid grid-cols-[2fr_1fr] items-center justify-between">
<div class="flex flex-col space-y-4 flex-1 text-sm">
<div
class="flex items-center"
v-for="row in chartDetails.data?.progress_distribution"
>
<div
class="size-2 rounded"
:style="{
backgroundColor:
colors[theme][
row.name.startsWith('Just')
? 'red'
: row.name.startsWith('In')
? 'amber'
: 'green'
][400],
}"
></div>
<Tooltip :text="row.name.split('(')[1].replace(')', '')">
<div class="ml-2">
{{ row.name.split('(')[0] }}
</div>
</Tooltip>
<div class="ml-auto">
{{
Math.round((row.value / course.data?.enrollments) * 100)
}}%
</div>
</div>
</div>
<ECharts
class="w-40 h-20"
:options="{
color: progressColors,
series: [
{
type: 'pie',
radius: ['50%', '70%'],
center: ['50%', '50%'],
label: {
show: false,
},
labelLine: {
show: false,
},
emphasis: {
label: {
show: false,
},
scale: false,
},
legend: {
show: false,
},
data: chartDetails.data?.progress_distribution || [],
},
],
showInlineLabels: false,
}"
/>
</div>
</div>
<div
v-if="lessonProgress.data?.length"
class="border rounded-lg pt-4 px-4"
>
<div class="flex items-center justify-between mb-4">
<div class="text-ink-gray-5">
{{ __('Lesson Completion') }}
</div>
<Select
:options="lessonProgressSortingOptions"
@update:modelValue="(value: string) => updateLessonProgress(value)"
:placeholder="__('Sort by')"
class="!w-32"
/>
</div>
<div class="divide-y max-h-[43vh] overflow-y-auto">
<div
v-for="progress in lessonProgress.data"
class="flex justify-between text-sm py-2 my-1"
>
<div class="">
<span class="mr-3 text-xs">
{{ progress.chapter_idx }}.{{ progress.idx }}
</span>
<span>
{{ progress.title }}
</span>
</div>
<Tooltip :text="progress.completion_count">
<div>
{{
Math.ceil(
(progress.completion_count / course.data?.enrollments) *
100
)
}}%
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
<CourseEnrollmentModal
v-if="showEnrollmentModal"
v-model="showEnrollmentModal"
:course="course"
/>
</template>
<script setup lang="ts">
import {
Avatar,
Button,
createListResource,
createResource,
dayjs,
Dropdown,
ECharts,
FormControl,
ListView,
ListHeader,
ListHeaderItem,
ListRows,
ListRow,
ListRowItem,
Select,
Tooltip,
} from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { ChevronDown, Plus, Star } from 'lucide-vue-next'
import { formatAmount } from '@/utils'
import colors from '@/utils/frappe-ui-colors.json'
import CourseEnrollmentModal from '@/pages/Courses/CourseEnrollmentModal.vue'
import NumberChartGraph from '@/components/NumberChartGraph.vue'
import ProgressBar from '@/components/ProgressBar.vue'
const props = defineProps<{
course: any
}>()
const showEnrollmentModal = ref(false)
const searchFilter = ref<string | null>(null)
const theme = ref<'darkMode' | 'lightMode'>(
localStorage.getItem('theme') == 'dark' ? 'darkMode' : 'lightMode'
)
type Filters = {
course: string | undefined
member_name?: string[]
}
const chartDetails = createResource({
url: 'lms.lms.api.get_course_progress_distribution',
makeParams() {
return {
course: props.course.data?.name,
}
},
auto: true,
})
const progressList = createListResource({
doctype: 'LMS Enrollment',
filters: {
course: props.course.data?.name,
},
fields: [
'name',
'member',
'member_name',
'member_image',
'member_username',
'progress',
'creation',
],
pageLength: 100,
auto: true,
})
const lessonProgress = createResource({
url: 'lms.lms.api.get_lesson_completion_stats',
params: {
course: props.course.data?.name,
},
auto: true,
})
const updateLessonProgress = (value: string) => {
if (value == 'completion_rate') {
lessonProgress.data?.sort((a: any, b: any) => {
const rateA = a.completion_count / (props.course.data?.enrollments || 1)
const rateB = b.completion_count / (props.course.data?.enrollments || 1)
return rateB - rateA
})
} else if (value == 'index') {
lessonProgress.data?.sort((a: any, b: any) => {
return a.chapter_idx - b.chapter_idx || a.idx - b.idx
})
}
}
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({
filters: filters,
})
progressList.reload()
})
const averageCompletionRate = computed(() => {
let value = Math.ceil(chartDetails.data?.average_progress) || 0
return value + '%'
})
const progressColors = computed(() => {
let colorList = []
colorList.push(colors[theme.value]['red'][400])
colorList.push(colors[theme.value]['amber'][400])
colorList.push(colors[theme.value]['green'][400])
return colorList
})
const progressColumns = computed(() => {
return [
{
label: __('Name'),
key: 'member_name',
width: '40%',
},
{
label: __('Progress'),
key: 'progress',
width: '30%',
},
{
label: __('Start Date'),
key: 'creation',
align: 'right',
},
]
})
const lessonProgressSortingOptions = [
{
label: __('Lesson Index'),
value: 'index',
onClick() {
updateLessonProgress('index')
},
},
{
label: __('Completion Rate'),
value: 'completion_rate',
onClick() {
updateLessonProgress('completion_rate')
},
},
]
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div v-if="course.data">
<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 v-if="tabIndex == 2" class="flex items-center space-x-2">
<Badge v-if="childRef?.isDirty" theme="orange">
{{ __('Not Saved') }}
</Badge>
<Button @click="childRef.trashCourse()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="childRef.submitCourse()">
{{ __('Save') }}
</Button>
</div>
</header>
<CourseOverview v-if="!isAdmin" :course="course" />
<div v-else>
<Tabs :tabs="tabs" v-model="tabIndex">
<template #tab-panel="{ tab }">
<component :is="tab.component" :course="course" ref="childRef" />
</template>
</Tabs>
</div>
</div>
</template>
<script setup>
import {
Badge,
Button,
createResource,
Breadcrumbs,
Tabs,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, markRaw, onMounted, ref, watch } from 'vue'
import { sessionStore } from '@/stores/session'
import { useRouter, useRoute } from 'vue-router'
import { List, Settings2, Trash2, TrendingUp } from 'lucide-vue-next'
import CourseOverview from '@/pages/Courses/CourseOverview.vue'
import CourseDashboard from '@/pages/Courses/CourseDashboard.vue'
import CourseForm from '@/pages/Courses/CourseForm.vue'
const { brand } = sessionStore()
const router = useRouter()
const route = useRoute()
const user = inject('$user')
const tabIndex = ref(0)
const childRef = ref(null)
const props = defineProps({
courseName: {
type: String,
required: true,
},
})
onMounted(() => {
updateTabIndex()
})
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 course = createResource({
url: 'lms.lms.utils.get_course_details',
cache: ['course', props.courseName],
makeParams() {
return {
course: props.courseName,
}
},
auto: true,
})
const tabs = ref([
{
label: __('Overview'),
component: markRaw(CourseOverview),
icon: List,
},
{
label: __('Dashboard'),
component: markRaw(CourseDashboard),
icon: TrendingUp,
},
{
label: __('Settings'),
component: markRaw(CourseForm),
icon: Settings2,
},
])
watch(
() => props.courseName,
() => {
course.reload()
}
)
watch(course, () => {
if (!isAdmin.value && !course.data?.published && !course.data?.upcoming) {
router.push({
name: 'Courses',
})
}
})
const isInstructor = () => {
let user_is_instructor = false
course.data?.instructors.forEach((instructor) => {
if (!user_is_instructor && instructor.name == user.data?.name) {
user_is_instructor = true
}
})
return user_is_instructor
}
const isAdmin = computed(() => {
return user.data?.is_moderator || isInstructor()
})
const breadcrumbs = computed(() => {
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
items.push({
label: course?.data?.title,
route: { name: 'CourseDetail', params: { courseName: course?.data?.name } },
})
return items
})
usePageMeta(() => {
return {
title: course?.data?.title,
icon: brand.favicon,
}
})
</script>
<style>
.avatar-group {
display: inline-flex;
align-items: center;
}
.avatar-group .avatar {
transition: margin 0.1s ease-in-out;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Enroll a Student'),
size: 'xl',
}"
>
<template #body-content>
<div class="space-y-4">
<FormControl
type="checkbox"
:label="__('Purchased Certificate')"
v-model="purchasedCertificate"
/>
<Link
doctype="User"
:label="__('Student')"
placeholder=" "
v-model="student"
:required="true"
:allowCreate="true"
@create="
() => {
openSettings('Members')
show = false
}
"
/>
<Link
v-if="purchasedCertificate"
doctype="LMS Payment"
:label="__('Payment')"
placeholder=" "
v-model="payment"
:allowCreate="true"
@create="
() => {
openSettings('Transactions')
show = false
}
"
/>
</div>
</template>
<template #actions="{ close }">
<div class="text-right">
<Button variant="solid" @click="enrollStudent(close)">
{{ __('Enroll') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
import { Link } from 'frappe-ui/frappe'
import { ref } from 'vue'
import { openSettings } from '@/utils'
const show = defineModel<boolean>({ required: true, default: false })
const student = ref<string | null>(null)
const payment = ref<string | null>(null)
const purchasedCertificate = ref<boolean>(false)
const props = defineProps<{
course: any
}>()
const enrollStudent = (close: () => void) => {
let validationPassed = validateData()
if (!validationPassed) return
call('frappe.client.insert', {
doc: {
doctype: 'LMS Enrollment',
course: props.course.data?.name,
member: student.value,
payment: purchasedCertificate.value ? payment.value : null,
purchased_certificate: purchasedCertificate.value,
},
})
.then(() => {
toast.success(__('Student enrolled successfully'))
close()
})
.catch((err: any) => {
toast.error(__(err.messages?.[0] || err))
console.error(err)
})
}
const validateData = (): boolean => {
if (!student.value) {
toast.error(__('Please select a student to enroll.'))
return false
}
if (purchasedCertificate.value && !payment.value) {
toast.error(__('Please select a payment for the purchased certificate.'))
return false
}
return true
}
</script>

View File

@@ -1,40 +1,25 @@
<template>
<div class="h-full">
<div class="grid grid-cols-1 md:grid-cols-[70%,30%] h-full">
<div>
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md: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 mt-3 md:mt-0">
<Button v-if="courseResource.data?.name" @click="trashCourse()">
<template #icon>
<Trash2 class="w-4 h-4 stroke-1.5" />
</template>
</Button>
<Button variant="solid" @click="submitCourse()" class="ml-2">
<span>
{{ __('Save') }}
</span>
</Button>
</div>
</header>
<div class="mt-5 mb-5">
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
<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="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">
{{ __('Details') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormControl
v-model="course.title"
v-model="courseResource.doc.title"
:label="__('Title')"
:required="true"
@input="makeFormDirty()"
/>
<Link
doctype="LMS Category"
v-model="course.category"
v-model="courseResource.doc.category"
:label="__('Category')"
:onCreate="(value, close) => openSettings('Categories', close)"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
@@ -45,6 +30,7 @@
:filters="{ ignore_user_type: 1 }"
:onCreate="(close) => openSettings('Members', close)"
:required="true"
@update:modelValue="makeFormDirty()"
/>
<div>
<div class="text-xs text-ink-gray-5">
@@ -60,8 +46,8 @@
<div>
<div class="flex items-center flex-wrap gap-2">
<div
v-if="course.tags"
v-for="tag in course.tags?.split(', ')"
v-if="courseResource.doc.tags"
v-for="tag in courseResource.doc.tags?.split(', ')"
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
>
{{ tag }}
@@ -76,21 +62,23 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<Uploader
v-model="course.image"
v-model="courseResource.doc.image"
:label="__('Course Image')"
:required="false"
@update:modelValue="makeFormDirty()"
/>
<ColorSwatches
v-model="course.card_gradient"
v-model="courseResource.doc.card_gradient"
:label="__('Color')"
:description="__('Choose a color for the course card')"
class="w-full"
@update:modelValue="makeFormDirty()"
/>
</div>
</div>
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
<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') }}
</div>
@@ -101,41 +89,46 @@
>
<FormControl
type="checkbox"
v-model="course.published"
v-model="courseResource.doc.published"
:label="__('Published')"
@change="makeFormDirty()"
/>
<FormControl
v-model="course.published_on"
v-model="courseResource.doc.published_on"
:label="__('Published On')"
type="date"
@change="makeFormDirty()"
/>
</div>
<div class="flex flex-col space-y-5">
<FormControl
type="checkbox"
v-model="course.upcoming"
v-model="courseResource.doc.upcoming"
:label="__('Upcoming')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.featured"
v-model="courseResource.doc.featured"
:label="__('Featured')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.disable_self_learning"
v-model="courseResource.doc.disable_self_learning"
:label="__('Disable Self Enrollment')"
@change="makeFormDirty()"
/>
</div>
</div>
</div>
<div class="px-5 md:px-10 pb-5 mb-5 space-y-5 border-b">
<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">
{{ __('About the Course') }}
</div>
<FormControl
v-model="course.short_introduction"
v-model="courseResource.doc.short_introduction"
type="textarea"
:rows="5"
:label="__('Short Introduction')"
@@ -145,6 +138,7 @@
)
"
:required="true"
@change="makeFormDirty()"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
@@ -152,8 +146,13 @@
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val) => (course.description = val)"
:content="courseResource.doc.description"
@change="
(val) => {
courseResource.doc.description = val
makeFormDirty()
}
"
: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]"
@@ -161,92 +160,113 @@
</div>
<FormControl
v-model="course.video_link"
v-model="courseResource.doc.video_link"
:label="__('Preview Video')"
:placeholder="
__(
'Paste the youtube link of a short video introducing the course'
)
"
@input="makeFormDirty()"
/>
<MultiSelect
v-model="related_courses"
doctype="LMS Course"
:label="__('Related Courses')"
:filters="{ name: ['!=', courseResource.data?.name] }"
:filters="{ name: ['!=', courseResource.doc?.name] }"
:onCreate="
(close) => {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
name: 'Courses',
query: { newCourse: '1' },
})
}
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="px-5 md:px-10 pb-5 space-y-5 border-b">
<div class="pr-5 md:pr-10 pb-5 space-y-5 border-b">
<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="course.paid_course"
v-model="courseResource.doc.paid_course"
:label="__('Paid Course')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.enable_certification"
v-model="courseResource.doc.enable_certification"
:label="__('Completion Certificate')"
@change="makeFormDirty()"
/>
<FormControl
type="checkbox"
v-model="course.paid_certificate"
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">
<FormControl
v-if="course.paid_course || course.paid_certificate"
v-model="course.course_price"
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
v-model="courseResource.doc.course_price"
:label="__('Amount')"
:required="course.paid_course || course.paid_certificate"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@input="makeFormDirty()"
/>
<Link
v-if="course.paid_certificate"
v-if="courseResource.doc.paid_certificate"
doctype="Course Evaluator"
v-model="course.evaluator"
v-model="courseResource.doc.evaluator"
:label="__('Evaluator')"
:required="course.paid_certificate"
:required="courseResource.doc.paid_certificate"
:onCreate="
(value, close) => openSettings('Evaluators', close)
"
@update:modelValue="makeFormDirty()"
/>
</div>
<div class="space-y-5">
<Link
v-if="course.paid_course || course.paid_certificate"
v-if="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
doctype="Currency"
v-model="course.currency"
v-model="courseResource.doc.currency"
:filters="{ enabled: 1 }"
:label="__('Currency')"
:required="course.paid_course || course.paid_certificate"
:required="
courseResource.doc.paid_course ||
courseResource.doc.paid_certificate
"
@update:modelValue="makeFormDirty()"
/>
<FormControl
v-if="course.paid_certificate"
v-model="course.timezone"
v-if="courseResource.doc.paid_certificate"
v-model="courseResource.doc.timezone"
:label="__('Timezone')"
:required="course.paid_certificate"
:required="courseResource.doc.paid_certificate"
:placeholder="__('e.g. IST, UTC, GMT...')"
@input="makeFormDirty()"
/>
</div>
</div>
</div>
<div class="px-5 md:px-10 pb-5 space-y-5">
<div class="pr-5 md:pr-10 pb-5 space-y-5">
<div class="text-lg font-semibold mt-5 text-ink-gray-9">
{{ __('Meta Tags') }}
</div>
@@ -256,6 +276,7 @@
:label="__('Meta Description')"
type="textarea"
:rows="7"
@input="makeFormDirty()"
/>
<FormControl
v-model="meta.keywords"
@@ -263,16 +284,17 @@
type="textarea"
:rows="7"
:placeholder="__('Comma separated keywords for SEO')"
@input="makeFormDirty()"
/>
</div>
</div>
</div>
</div>
<div class="border-l">
<div class="border-l h-[88vh] overflow-y-auto">
<CourseOutline
v-if="courseResource.data"
:courseName="courseResource.data.name"
:title="__('Course Outline')"
v-if="courseResource.doc"
:courseName="courseResource.doc.name"
:title="__('Chapters')"
:allowEdit="true"
/>
</div>
@@ -281,10 +303,10 @@
</template>
<script setup>
import {
Breadcrumbs,
TextEditor,
Button,
createResource,
createDocumentResource,
FormControl,
usePageMeta,
toast,
@@ -293,7 +315,6 @@ import {
inject,
onMounted,
onBeforeUnmount,
computed,
ref,
reactive,
watch,
@@ -308,8 +329,7 @@ import {
} from '@/utils'
import { Trash2, X } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { sessionStore } from '../stores/session'
import { sessionStore } from '../../stores/session'
import Link from '@/components/Controls/Link.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
@@ -323,39 +343,15 @@ const router = useRouter()
const instructors = ref([])
const related_courses = ref([])
const app = getCurrentInstance()
const { capture } = useTelemetry()
const { updateOnboardingStep } = useOnboarding('learning')
const { $dialog } = app.appContext.config.globalProperties
const isDirty = ref(false)
const props = defineProps({
courseName: {
type: String,
course: {
type: Object,
},
})
const course = reactive({
title: '',
short_introduction: '',
description: '',
video_link: '',
image: null,
card_gradient: '',
tags: '',
category: '',
published: false,
published_on: '',
featured: false,
upcoming: false,
disable_self_learning: false,
enable_certification: false,
paid_course: false,
paid_certificate: false,
course_price: '',
currency: '',
evaluator: '',
timezone: '',
})
const meta = reactive({
description: '',
keywords: '',
@@ -365,18 +361,92 @@ onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
router.push({ name: 'Courses' })
}
if (props.courseName !== 'new') {
fetchCourseInfo()
} else {
capture('course_form_opened')
}
window.addEventListener('keydown', keyboardShortcut)
})
const fetchCourseInfo = () => {
courseResource.reload()
getMetaInfo('courses', props.courseName, meta)
const courseResource = createDocumentResource({
doctype: 'LMS Course',
name: props.course.data?.name,
auto: true,
})
watch(
() => courseResource.doc,
() => {
check_permission()
getMetaInfo('courses', courseResource.doc?.name, meta)
updateCourseData()
}
)
const updateCourseData = () => {
Object.keys(courseResource.doc).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
courseResource.doc.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (key == 'related_courses') {
related_courses.value = []
courseResource.doc.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
}
})
let checkboxes = [
'published',
'upcoming',
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
'paid_certificate',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
courseResource.doc[key] = courseResource.doc[key] ? true : false
}
}
const submitCourse = () => {
validateFields()
updateCourse()
}
const validateFields = () => {
courseResource.doc.description = sanitizeHTML(courseResource.doc.description)
Object.keys(courseResource.doc).forEach((key) => {
if (key != 'description' && typeof courseResource.doc[key] === 'string') {
courseResource.doc[key] = escapeHTML(courseResource.doc[key])
}
})
}
const updateCourse = () => {
courseResource.setValue.submit(
{
...courseResource.doc,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
},
{
onSuccess() {
updateMetaInfo('courses', courseResource.doc?.name, meta)
toast.success(__('Course updated successfully'))
isDirty.value = false
courseResource.reload()
},
onError(err) {
toast.error(err.messages?.[0] || err)
console.error(err)
},
}
)
}
const keyboardShortcut = (e) => {
@@ -394,151 +464,11 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
const courseCreationResource = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Course',
image: course.image,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...values,
},
}
},
})
const courseEditResource = createResource({
url: 'frappe.client.set_value',
auto: false,
makeParams(values) {
return {
doctype: 'LMS Course',
name: values.course,
fieldname: {
image: course.image,
instructors: instructors.value.map((instructor) => ({
instructor: instructor,
})),
related_courses: related_courses.value.map((course) => ({
course: course,
})),
...course,
},
}
},
})
const courseResource = createResource({
url: 'frappe.client.get',
makeParams(values) {
return {
doctype: 'LMS Course',
name: props.courseName,
}
},
auto: false,
onSuccess(data) {
Object.keys(data).forEach((key) => {
if (key == 'instructors') {
instructors.value = []
data.instructors.forEach((instructor) => {
instructors.value.push(instructor.instructor)
})
} else if (key == 'related_courses') {
related_courses.value = []
data.related_courses.forEach((course) => {
related_courses.value.push(course.course)
})
} else if (Object.hasOwn(course, key)) course[key] = data[key]
})
let checkboxes = [
'published',
'upcoming',
'disable_self_learning',
'paid_course',
'featured',
'enable_certification',
'paid_certificate',
]
for (let idx in checkboxes) {
let key = checkboxes[idx]
course[key] = course[key] ? true : false
}
check_permission()
},
})
const validateFields = () => {
course.description = sanitizeHTML(course.description)
Object.keys(course).forEach((key) => {
if (key != 'description' && typeof course[key] === 'string') {
course[key] = escapeHTML(course[key])
}
})
}
const submitCourse = () => {
validateFields()
if (courseResource.data) {
editCourse()
} else {
createCourse()
}
}
const createCourse = () => {
courseCreationResource.submit(course, {
onSuccess(data) {
updateMetaInfo('courses', data.name, meta)
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
capture('course_created')
toast.success(__('Course created successfully'))
router.push({
name: 'CourseForm',
params: { courseName: data.name },
})
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
})
}
const editCourse = () => {
courseEditResource.submit(
{
course: courseResource.data.name,
},
{
onSuccess() {
updateMetaInfo('courses', props.courseName, meta)
toast.success(__('Course updated successfully'))
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
const deleteCourse = createResource({
url: 'lms.lms.api.delete_course',
makeParams(values) {
return {
course: props.courseName,
course: courseResource.doc?.name,
}
},
onSuccess() {
@@ -567,28 +497,23 @@ const trashCourse = () => {
})
}
watch(
() => props.courseName !== 'new',
(newVal) => {
if (newVal) {
fetchCourseInfo()
}
}
)
const updateTags = () => {
if (newTag.value) {
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
courseResource.doc.tags = courseResource.doc.tags
? `${courseResource.doc.tags}, ${newTag.value}`
: newTag.value
newTag.value = ''
makeFormDirty()
}
}
const removeTag = (tag) => {
course.tags = course.tags
courseResource.doc.tags = courseResource.doc.tags
?.split(', ')
.filter((t) => t !== tag)
.join(', ')
newTag.value = ''
makeFormDirty()
}
const check_permission = () => {
@@ -606,30 +531,20 @@ const check_permission = () => {
}
}
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Courses',
route: { name: 'Courses' },
},
]
if (courseResource.data) {
crumbs.push({
label: course.title,
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
})
}
crumbs.push({
label: props.courseName == 'new' ? 'New Course' : 'Edit Course',
route: { name: 'CourseForm', params: { courseName: props.courseName } },
})
return crumbs
})
const makeFormDirty = () => {
isDirty.value = true
}
usePageMeta(() => {
return {
title: courseResource.data?.title || __('New Course'),
title: courseResource.doc?.title,
icon: brand.favicon,
}
})
defineExpose({
submitCourse,
trashCourse,
isDirty,
})
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="p-5">
<div class="flex justify-between w-full space-x-5">
<div class="md:w-2/3">
<div class="text-3xl font-semibold text-ink-gray-9">
{{ course.data.title }}
</div>
<div class="my-3 leading-6 text-ink-gray-7">
{{ course.data.short_introduction }}
</div>
<div class="flex items-center">
<Tooltip
v-if="parseInt(course.data.rating) > 0"
:text="__('Average Rating')"
class="flex items-center"
>
<Star class="size-4 text-transparent fill-yellow-500" />
<span class="ml-1 text-ink-gray-7">
{{ course.data.rating }}
</span>
</Tooltip>
<span v-if="parseInt(course.data.rating) > 0" class="mx-3"
>&middot;</span
>
<Tooltip
v-if="course.data.enrollment_count"
:text="__('Enrolled Students')"
class="flex items-center"
>
<Users class="h-4 w-4 text-ink-gray-7" />
<span class="ml-1">
{{ course.data.enrollment_count_formatted }}
</span>
</Tooltip>
<span v-if="course.data.enrollment_count" class="mx-3">&middot;</span>
<div class="flex items-center">
<span
class="h-6 mr-1"
:class="{
'avatar-group overlap': course.data.instructors.length > 1,
}"
>
<UserAvatar
v-for="instructor in course.data.instructors"
:user="instructor"
/>
</span>
<CourseInstructors :instructors="course.data.instructors" />
</div>
</div>
<div v-if="course.data.tags" class="flex my-4 w-fit">
<Badge
theme="gray"
size="lg"
class="mr-2 text-ink-gray-9"
v-for="tag in course.data.tags.split(', ')"
>
{{ tag }}
</Badge>
</div>
<div class="md:hidden my-4">
<CourseCardOverlay :course="course" />
</div>
<div
v-html="course.data.description"
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"
></div>
<div class="mt-10">
<CourseOutline
:title="__('Course Outline')"
:courseName="course.data.name"
:showOutline="true"
:getProgress="course.data.membership ? true : false"
/>
</div>
<CourseReviews
:courseName="course.data.name"
:avg_rating="course.data.rating"
:membership="course.data.membership"
/>
</div>
<div class="hidden md:block">
<CourseCardOverlay :course="course" />
</div>
</div>
<RelatedCourses :courseName="course.data.name" />
</div>
</template>
<script setup lang="ts">
import { Star, Users } from 'lucide-vue-next'
import { Badge, Tooltip } from 'frappe-ui'
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
import CourseOutline from '@/components/CourseOutline.vue'
import CourseReviews from '@/components/CourseReviews.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import CourseInstructors from '@/components/CourseInstructors.vue'
import RelatedCourses from '@/components/RelatedCourses.vue'
const props = defineProps<{
course: any
}>()
</script>

View File

@@ -5,7 +5,7 @@
<Breadcrumbs :items="breadcrumbs" />
<Dropdown
placement="start"
placement="right"
side="bottom"
v-if="canCreateCourse()"
:options="[
@@ -13,10 +13,7 @@
label: __('New Course'),
icon: 'book-open',
onClick() {
router.push({
name: 'CourseForm',
params: { courseName: 'new' },
})
showCourseModal = true
},
},
{
@@ -109,6 +106,11 @@
</Button>
</div>
</div>
<NewCourseModal
v-if="showCourseModal"
v-model="showCourseModal"
:courses="courses"
/>
</template>
<script setup>
import {
@@ -128,13 +130,19 @@ import { sessionStore } from '@/stores/session'
import { canCreateCourse } from '@/utils'
import CourseCard from '@/components/CourseCard.vue'
import EmptyState from '@/components/EmptyState.vue'
import router from '../router'
import { useRouter } from 'vue-router'
import NewCourseModal from '@/pages/Courses/NewCourseModal.vue'
const user = inject('$user')
const dayjs = inject('$dayjs')
const start = ref(0)
const pageLength = ref(30)
const categories = ref([])
const categories = ref([
{
label: '',
value: null,
},
])
const currentCategory = ref(null)
const title = ref('')
const certification = ref(false)
@@ -142,17 +150,13 @@ const filters = ref({})
const currentTab = ref('Live')
const { brand } = sessionStore()
const courseCount = ref(0)
const router = useRouter()
const showCourseModal = ref(false)
onMounted(() => {
setFiltersFromQuery()
updateCourses()
getCourseCount()
categories.value = [
{
label: '',
value: null,
},
]
})
const setFiltersFromQuery = () => {
@@ -160,6 +164,9 @@ const setFiltersFromQuery = () => {
title.value = queries.get('title') || ''
currentCategory.value = queries.get('category') || null
certification.value = queries.get('certification') || false
if (queries.get('newCourse') == '1') {
showCourseModal.value = true
}
}
const courses = createListResource({

View File

@@ -0,0 +1,156 @@
<template>
<Dialog
v-model="show"
:options="{
title: __('Create Course'),
size: '3xl',
}"
>
<template #body-content>
<div class="text-base">
<div class="grid grid-cols-2 gap-5 border-b mb-5">
<FormControl
v-model="course.title"
:label="__('Title')"
:required="true"
/>
<Link
doctype="LMS Category"
v-model="course.category"
:label="__('Category')"
:allowCreate="true"
@create="
() => {
openSettings('Categories')
show = false
}
"
/>
<MultiSelect
v-model="course.instructors"
doctype="User"
:label="__('Instructors')"
:filters="{ ignore_user_type: 1 }"
:onCreate="(close: () => void) => openSettings('Members', close)"
:required="true"
/>
<Uploader
v-model="course.image"
:label="__('Course Image')"
:required="false"
/>
</div>
<div class="space-y-4">
<FormControl
v-model="course.short_introduction"
:label="__('Short Introduction')"
type="textarea"
:required="true"
:rows="4"
/>
<div class="">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course Description') }}
<span class="text-ink-red-3">*</span>
</div>
<TextEditor
:content="course.description"
@change="(val: string) => (course.description = 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="saveCourse(close)">
{{ __('Create') }}
</Button>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { Link, useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { openSettings } from '@/utils'
import MultiSelect from '@/components/Controls/MultiSelect.vue'
import Uploader from '@/components/Controls/Uploader.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<{
courses: any
}>()
const course = ref({
title: '',
short_introduction: '',
description: '',
instructors: [],
category: null,
image: null,
})
const saveCourse = (close: () => void = () => {}) => {
props.courses.insert.submit(
{
...course.value,
instructors: course.value.instructors.map((instructor) => ({
instructor: instructor,
})),
},
{
onSuccess(data: any) {
toast.success(__('Course created successfully'))
close()
capture('course_created')
router.push({
name: 'CourseDetail',
params: { courseName: data.name },
hash: '#settings',
})
if (user.data?.is_system_manager) {
updateOnboardingStep('create_first_course', true, false, () => {
localStorage.setItem('firstCourse', data.name)
})
}
},
}
)
}
const keyboardShortcut = (e: KeyboardEvent) => {
if (
e.key === 's' &&
(e.ctrlKey || e.metaKey) &&
e.target &&
e.target instanceof HTMLElement &&
!e.target.classList.contains('ProseMirror')
) {
saveCourse()
e.preventDefault()
}
}
onMounted(() => {
window.addEventListener('keydown', keyboardShortcut)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(show, () => {
capture('course_form_opened')
})
</script>

View File

@@ -74,7 +74,7 @@
}}
</div>
<router-link
:to="{ name: 'CourseForm', params: { courseName: 'new' } }"
:to="{ name: 'Courses', query: { newCourse: '1' } }"
class="mt-4"
>
<Button>

View File

@@ -60,8 +60,15 @@ const currentTab = ref<'student' | 'instructor'>('instructor')
const showStreakModal = ref(false)
onMounted(() => {
call('lms.lms.utils.get_upcoming_evals').then((data: any) => {
evalCount.value = data.length
call('frappe.client.get_count', {
doctype: 'LMS Certificate Request',
filters: {
member: user?.data?.name,
status: 'Upcoming',
date: ['>=', inject<any>('$dayjs')().format('YYYY-MM-DD')],
},
}).then((data: any) => {
evalCount.value = data
})
})

View File

@@ -1,5 +1,77 @@
<template>
<div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-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
v-for="cls in myLiveClasses.data"
class="border rounded-md hover:border-outline-gray-3 p-2"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-5 leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-4 text-ink-gray-7">
<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>
{{ formatTime(cls.time) }} -
{{ 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>
</div>
<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">
@@ -63,78 +135,6 @@
</router-link>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-5 mt-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
v-for="cls in myLiveClasses.data"
class="border rounded-md hover:border-outline-gray-3 p-2"
>
<div class="font-semibold text-ink-gray-9 text-lg leading-5 mb-1">
{{ cls.title }}
</div>
<div class="text-ink-gray-7 text-sm leading-5 mb-4">
{{ cls.description }}
</div>
<div class="mt-auto space-y-3 text-ink-gray-7 text-sm">
<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>
{{ formatTime(cls.time) }} -
{{ 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>
</div>
</div>
</template>
<script setup lang="ts">

View File

@@ -326,6 +326,7 @@
@updateNotes="updateNotes"
/>
<VideoStatistics
v-if="showStatsDialog"
v-model="showStatsDialog"
:lessonName="lesson.data?.name"
:lessonTitle="lesson.data?.title"
@@ -378,6 +379,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Notes from '@/components/Notes/Notes.vue'
import InlineLessonMenu from '@/components/Notes/InlineLessonMenu.vue'
import { getLmsRoute } from '@/utils/basePath'
const user = inject('$user')
const socket = inject('$socket')
@@ -870,6 +872,7 @@ const scrollDiscussionsIntoView = () => {
}
const updateNotes = () => {
if (!user.data) return
notes.update({
filters: {
lesson: lesson.data?.name,
@@ -902,7 +905,9 @@ watch(allowDiscussions, () => {
})
const redirectToLogin = () => {
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
window.location.href = `/login?redirect-to=${getLmsRoute(
`courses/${props.courseName}`
)}`
}
usePageMeta(() => {

View File

@@ -471,7 +471,11 @@ const breadcrumbs = computed(() => {
},
{
label: lessonDetails.data?.course_title,
route: { name: 'CourseForm', params: { courseName: props.courseName } },
route: {
name: 'CourseDetail',
params: { courseName: props.courseName },
hash: '#settings',
},
},
]
@@ -665,7 +669,8 @@ iframe {
padding: 8px;
}
.codex-editor--narrow .ce-toolbox .ce-popover {
.codex-editor--narrow .ce-toolbox .ce-popover,
.codex-editor--narrow .ce-toolbar__actions .ce-popover {
right: unset;
left: initial;
}

View File

@@ -185,10 +185,9 @@ const unReadNotifications = createListResource({
doctype: 'Notification Log',
url: 'lms.lms.api.get_notifications',
filters: {
for_user: user.data?.name,
read: 0,
},
auto: true,
auto: user.data ? true : false,
cache: 'Unread Notifications',
})
@@ -196,18 +195,17 @@ const readNotifications = createListResource({
doctype: 'Notification Log',
url: 'lms.lms.api.get_notifications',
filters: {
for_user: user.data?.name,
read: 1,
},
auto: true,
auto: user.data ? true : false,
cache: 'Read Notifications',
})
const markAsRead = createResource({
url: 'lms.lms.api.mark_as_read',
url: 'frappe.desk.doctype.notification_log.notification_log.mark_as_read',
makeParams(values) {
return {
name: values.name,
docname: values.name,
}
},
onSuccess(data) {
@@ -217,7 +215,7 @@ const markAsRead = createResource({
})
const markAllAsRead = createResource({
url: 'lms.lms.api.mark_all_as_read',
url: 'frappe.desk.doctype.notification_log.notification_log.mark_all_as_read',
onSuccess(data) {
unReadNotifications.reload()
readNotifications.reload()

View File

@@ -122,6 +122,7 @@ import { X, LinkedinIcon, Twitter } from 'lucide-vue-next'
import { sessionStore } from '@/stores/session'
import { decodeEntities } from '@/utils'
import DOMPurify from 'dompurify'
import { getLmsRoute } from '@/utils/basePath'
const dayjs = inject('$dayjs')
const { branding } = sessionStore()
@@ -158,7 +159,9 @@ const badges = createResource({
const shareOnSocial = (badge, medium) => {
let shareUrl
const url = encodeURIComponent(
`${window.location.origin}/lms/badges/${badge.badge}/${props.profile.data?.email}`
`${window.location.origin}${getLmsRoute(
`badges/${badge.badge}/${props.profile.data?.email}`
)}`
)
const summary = `I am happy to announce that I earned the ${
badge.badge

View File

@@ -158,6 +158,7 @@ import { sessionStore } from '@/stores/session'
import { useRouter } from 'vue-router'
import { openSettings } from '@/utils'
import { useSettings } from '@/stores/settings'
import { getLmsRoute } from '@/utils/basePath'
const user = inject<any>('$user')
const code = ref<string | null>('')
@@ -255,7 +256,10 @@ const updateBoilerPlate = () => {
const checkIfUserIsPermitted = (doc: any = null) => {
if (!user.data) {
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
const redirectPath = getLmsRoute(
`programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
)
window.location.href = `/login?redirect-to=${redirectPath}`
}
if (!doc) return

View File

@@ -226,7 +226,6 @@ import {
onMounted,
inject,
onBeforeUnmount,
watch,
} from 'vue'
import { sessionStore } from '../stores/session'
import { ClipboardList, ListChecks, Plus, Trash2 } from 'lucide-vue-next'
@@ -252,7 +251,9 @@ const props = defineProps({
},
})
const questions = ref([])
const questions = computed(() => {
return quizDetails.doc?.questions || []
})
onMounted(() => {
if (!user.data?.is_moderator && !user.data?.is_instructor) {
@@ -273,24 +274,10 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', keyboardShortcut)
})
watch(
() => props.quizID !== 'new',
(newVal) => {
if (newVal) {
quizDetails.reload()
}
}
)
const quizDetails = createDocumentResource({
doctype: 'LMS Quiz',
name: props.quizID,
auto: false,
onSuccess(doc) {
if (doc.questions && doc.questions.length > 0) {
questions.value = doc.questions.map((question) => question)
}
},
})
const validateTitle = () => {

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import { usersStore } from './stores/user'
import { sessionStore } from './stores/session'
import { useSettings } from './stores/settings'
import { getLmsBasePath } from './utils/basePath'
const routes = [
{
@@ -12,12 +13,12 @@ const routes = [
{
path: '/courses',
name: 'Courses',
component: () => import('@/pages/Courses.vue'),
component: () => import('@/pages/Courses/Courses.vue'),
},
{
path: '/courses/:courseName',
name: 'CourseDetail',
component: () => import('@/pages/CourseDetail.vue'),
component: () => import('@/pages/Courses/CourseDetail.vue'),
props: true,
},
{
@@ -29,7 +30,7 @@ const routes = [
{
path: '/courses/:courseName/certification',
name: 'CourseCertification',
component: () => import('@/pages/CourseCertification.vue'),
component: () => import('@/pages/Courses/CourseCertification.vue'),
props: true,
},
{
@@ -118,12 +119,6 @@ const routes = [
component: () => import('@/pages/JobApplications.vue'),
props: true,
},
{
path: '/courses/:courseName/edit',
name: 'CourseForm',
component: () => import('@/pages/CourseForm.vue'),
props: true,
},
{
path: '/courses/:courseName/learn/:chapterNumber-:lessonNumber/edit',
name: 'LessonForm',
@@ -268,7 +263,7 @@ const routes = [
]
let router = createRouter({
history: createWebHistory('/lms'),
history: createWebHistory(`/${getLmsBasePath()}`),
routes,
})

View File

@@ -13,6 +13,8 @@ export const sessionStore = defineStore('lms-session', () => {
let _sessionUser = cookies.get('user_id')
if (_sessionUser === 'Guest') {
_sessionUser = null
} else {
userResource.reload()
}
return _sessionUser
}

View File

@@ -9,7 +9,6 @@ export const usersStore = defineStore('lms-users', () => {
window.location.href = '/login'
}
},
auto: true,
})
const allUsers = createResource({

View File

@@ -5,6 +5,7 @@ import translationPlugin from '../translation'
import { usersStore } from '@/stores/user'
import { call } from 'frappe-ui'
import router from '@/router'
import { getLmsRoute } from '@/utils/basePath'
export class Assignment {
constructor({ data, api, readOnly }) {
@@ -53,7 +54,10 @@ export class Assignment {
fieldname: ['name'],
}).then((data) => {
let submission = data.name || 'new'
this.wrapper.innerHTML = `<iframe src="/lms/assignment-submission/${assignment}/${submission}?fromLesson=1" class="w-full h-[500px]"></iframe>`
const submissionPath = getLmsRoute(
`assignment-submission/${assignment}/${submission}?fromLesson=1`
)
this.wrapper.innerHTML = `<iframe src="${submissionPath}" class="w-full h-[500px]"></iframe>`
})
return
}

View File

@@ -0,0 +1,12 @@
export function getLmsBasePath() {
return window.lms_path || 'lms'
}
export function getLmsRoute(path = '') {
const base = getLmsBasePath()
if (!path) {
return base
}
const normalized = path.startsWith('/') ? path.slice(1) : path
return `/${base}/${normalized}`
}

View File

@@ -465,7 +465,6 @@ const getSidebarItems = () => {
'Courses',
'CourseDetail',
'Lesson',
'CourseForm',
'LessonForm',
],
},
@@ -490,6 +489,9 @@ const getSidebarItems = () => {
icon: 'GraduationCap',
to: 'CertifiedParticipants',
activeFor: ['CertifiedParticipants'],
condition: () => {
return userResource?.data
},
},
{
label: 'Jobs',

View File

@@ -4,6 +4,7 @@ import translationPlugin from '@/translation'
import ProgrammingExerciseModal from '@/pages/ProgrammingExercises/ProgrammingExerciseModal.vue';
import { call } from 'frappe-ui';
import { usersStore } from '@/stores/user'
import { getLmsRoute } from '@/utils/basePath'
export class Program {
@@ -73,7 +74,10 @@ export class Program {
fieldname: ['name'],
}).then((data: { name: string }) => {
let submission = data.name || 'new'
this.wrapper.innerHTML = `<iframe src="/lms/programming-exercises/${exercise}/submission/${submission}?fromLesson=1" class="w-full h-[900px] border rounded-md"></iframe>`
const submissionPath = getLmsRoute(
`programming-exercises/${exercise}/submission/${submission}?fromLesson=1`
)
this.wrapper.innerHTML = `<iframe src="${submissionPath}" class="w-full h-[900px] border rounded-md"></iframe>`
})
return
}
@@ -100,4 +104,4 @@ export class Program {
exercise: this.data.exercise,
}
}
}
}

View File

@@ -5,6 +5,7 @@ import { usersStore } from '../stores/user'
import translationPlugin from '../translation'
import { CircleHelp } from 'lucide-vue-next'
import router from '@/router'
import { getLmsRoute } from '@/utils/basePath'
export class Quiz {
constructor({ data, api, readOnly }) {
@@ -42,7 +43,8 @@ export class Quiz {
renderQuiz(quiz) {
if (this.readOnly) {
this.wrapper.innerHTML = `<iframe src="/lms/quiz/${quiz}?fromLesson=1" class="w-full h-[500px]"></iframe>`
const quizPath = getLmsRoute(`quiz/${quiz}?fromLesson=1`)
this.wrapper.innerHTML = `<iframe src="${quizPath}" class="w-full h-[500px]"></iframe>`
return
}
this.wrapper.innerHTML = `<div class='border rounded-md p-4 text-center bg-surface-menu-bar mb-4'>

View File

@@ -5,6 +5,7 @@ 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 = {
@@ -17,7 +18,7 @@ export default defineConfig(async ({ mode }) => {
lucideIcons: true,
jinjaBootData: true,
buildConfig: {
indexHtmlPath: '../lms/www/lms.html',
indexHtmlPath: '../lms/www/_lms.html',
},
}),
vue(),

View File

@@ -920,16 +920,16 @@
crelt "^1.0.5"
"@codemirror/state@6.x", "@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0":
version "6.5.3"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.3.tgz#256e256d466f49ed0879d462031de8bd541e1403"
integrity sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==
version "6.5.4"
resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.4.tgz#f5be4b8c0d2310180d5f15a9f641c21ca69faf19"
integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==
dependencies:
"@marijn/find-cluster-break" "^1.0.0"
"@codemirror/view@6.x", "@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.37.0":
version "6.39.10"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.10.tgz#ae0dfcb635fd307aa3b800e305c9f46152503dba"
integrity sha512-QfT/PXhiiP76PxMnX0RQVPDQrqfRt9wr9QhInNHnEUu4PWoNS8QwwcIDEneXFChJv22y+Yu/Cz5lFMTPz+h16w==
version "6.39.11"
resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.39.11.tgz#200aebef2074bfbbb7a3d5f0644c1b560d876b39"
integrity sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==
dependencies:
"@codemirror/state" "^6.5.0"
crelt "^1.0.6"
@@ -1400,130 +1400,130 @@
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@rollup/rollup-android-arm-eabi@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz#76e0fef6533b3ce313f969879e61e8f21f0eeb28"
integrity sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==
"@rollup/rollup-android-arm-eabi@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz#067cfcd81f1c1bfd92aefe3ad5ef1523549d5052"
integrity sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==
"@rollup/rollup-android-arm64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz#d3cfc675a40bbdec97bda6d7fe3b3b05f0e1cd93"
integrity sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==
"@rollup/rollup-android-arm64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz#85e39a44034d7d4e4fee2a1616f0bddb85a80517"
integrity sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==
"@rollup/rollup-darwin-arm64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz#eb912b8f59dd47c77b3c50a78489013b1d6772b4"
integrity sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==
"@rollup/rollup-darwin-arm64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz#17d92fe98f2cc277b91101eb1528b7c0b6c00c54"
integrity sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==
"@rollup/rollup-darwin-x64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz#e7d0839fdfd1276a1d34bc5ebbbd0dfd7d0b81a0"
integrity sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==
"@rollup/rollup-darwin-x64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz#89ae6c66b1451609bd1f297da9384463f628437d"
integrity sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==
"@rollup/rollup-freebsd-arm64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz#7ff8118760f7351e48fd0cd3717ff80543d6aac8"
integrity sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==
"@rollup/rollup-freebsd-arm64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz#cdbdb9947b26e76c188a31238c10639347413628"
integrity sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==
"@rollup/rollup-freebsd-x64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz#49d330dadbda1d4e9b86b4a3951b59928a9489a9"
integrity sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==
"@rollup/rollup-freebsd-x64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz#9b1458d07b6e040be16ee36d308a2c9520f7f7cc"
integrity sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==
"@rollup/rollup-linux-arm-gnueabihf@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz#98c5f1f8b9776b4a36e466e2a1c9ed1ba52ef1b6"
integrity sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==
"@rollup/rollup-linux-arm-gnueabihf@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz#1d50ded7c965d5f125f5832c971ad5b287befef7"
integrity sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==
"@rollup/rollup-linux-arm-musleabihf@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz#b9acecd3672e742f70b0c8a94075c816a91ff040"
integrity sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==
"@rollup/rollup-linux-arm-musleabihf@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz#53597e319b7e65990d3bc2a5048097384814c179"
integrity sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==
"@rollup/rollup-linux-arm64-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz#7a6ab06651bc29e18b09a50ed1a02bc972977c9b"
integrity sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==
"@rollup/rollup-linux-arm64-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz#597002909dec198ca4bdccb25f043d32db3d6283"
integrity sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==
"@rollup/rollup-linux-arm64-musl@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz#3c8c9072ba4a4d4ef1156b85ab9a2cbb57c1fad0"
integrity sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==
"@rollup/rollup-linux-arm64-musl@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz#286f0e0f799545ce288bdc5a7c777261fcba3d54"
integrity sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==
"@rollup/rollup-linux-loong64-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz#17a7af13530f4e4a7b12cd26276c54307a84a8b0"
integrity sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==
"@rollup/rollup-linux-loong64-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz#1fab07fa1a4f8d3697735b996517f1bae0ba101b"
integrity sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==
"@rollup/rollup-linux-loong64-musl@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz#5cd7a900fd7b077ecd753e34a9b7ff1157fe70c1"
integrity sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==
"@rollup/rollup-linux-loong64-musl@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz#efc2cb143d6c067f95205482afb177f78ed9ea3d"
integrity sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==
"@rollup/rollup-linux-ppc64-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz#03a097e70243ddf1c07b59d3c20f38e6f6800539"
integrity sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==
"@rollup/rollup-linux-ppc64-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz#e8de8bd3463f96b92b7dfb7f151fd80ffe8a937c"
integrity sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==
"@rollup/rollup-linux-ppc64-musl@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz#a5389873039d4650f35b4fa060d286392eb21a94"
integrity sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==
"@rollup/rollup-linux-ppc64-musl@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz#8c508fe28a239da83b3a9da75bcf093186e064b4"
integrity sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==
"@rollup/rollup-linux-riscv64-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz#789e60e7d6e2b76132d001ffb24ba80007fb17d0"
integrity sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==
"@rollup/rollup-linux-riscv64-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz#ff6d51976e0830732880770a9e18553136b8d92b"
integrity sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==
"@rollup/rollup-linux-riscv64-musl@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz#3556fa88d139282e9a73c337c9a170f3c5fe7aa4"
integrity sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==
"@rollup/rollup-linux-riscv64-musl@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz#325fb35eefc7e81d75478318f0deee1e4a111493"
integrity sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==
"@rollup/rollup-linux-s390x-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz#c085995b10143c16747a67f1a5487512b2ff04b2"
integrity sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==
"@rollup/rollup-linux-s390x-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz#37410fabb5d3ba4ad34abcfbe9ba9b6288413f30"
integrity sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==
"@rollup/rollup-linux-x64-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz#9563a5419dd2604841bad31a39ccfdd2891690fb"
integrity sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==
"@rollup/rollup-linux-x64-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz#8ef907a53b2042068fc03fcc6a641e2b02276eca"
integrity sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==
"@rollup/rollup-linux-x64-musl@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz#691bb06e6269a8959c13476b0cd2aa7458facb31"
integrity sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==
"@rollup/rollup-linux-x64-musl@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz#61b9ba09ea219e0174b3f35a6ad2afc94bdd5662"
integrity sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==
"@rollup/rollup-openbsd-x64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz#223e71224746a59ce6d955bbc403577bb5a8be9d"
integrity sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==
"@rollup/rollup-openbsd-x64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz#fc4e54133134c1787d0b016ffdd5aeb22a5effd3"
integrity sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==
"@rollup/rollup-openharmony-arm64@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz#0817e5d8ecbfeb8b7939bf58f8ce3c9dd67fce77"
integrity sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==
"@rollup/rollup-openharmony-arm64@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz#959ae225b1eeea0cc5b7c9f88e4834330fb6cd09"
integrity sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==
"@rollup/rollup-win32-arm64-msvc@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz#de56d8f2013c84570ef5fb917aae034abda93e4a"
integrity sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==
"@rollup/rollup-win32-arm64-msvc@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz#842acd38869fa1cbdbc240c76c67a86f93444c27"
integrity sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==
"@rollup/rollup-win32-ia32-msvc@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz#659aff5244312475aeea2c9479a6c7d397b517bf"
integrity sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==
"@rollup/rollup-win32-ia32-msvc@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz#7ab654def4042df44cb29f8ed9d5044e850c66d5"
integrity sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==
"@rollup/rollup-win32-x64-gnu@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz#2cb09549cbb66c1b979f9238db6dd454cac14a88"
integrity sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==
"@rollup/rollup-win32-x64-gnu@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz#7426cdec1b01d2382ffd5cda83cbdd1c8efb3ca6"
integrity sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==
"@rollup/rollup-win32-x64-msvc@4.55.1":
version "4.55.1"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz#f79437939020b83057faf07e98365b1fa51c458b"
integrity sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==
"@rollup/rollup-win32-x64-msvc@4.56.0":
version "4.56.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz#9eec0212732a432c71bde0350bc40b673d15b2db"
integrity sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==
"@socket.io/component-emitter@~3.1.0":
version "3.1.2"
@@ -1896,9 +1896,9 @@
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
"@types/node@*":
version "25.0.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.8.tgz#e54e00f94fe1db2497b3e42d292b8376a2678c8d"
integrity sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==
version "25.0.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.10.tgz#4864459c3c9459376b8b75fd051315071c8213e7"
integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==
dependencies:
undici-types "~7.16.0"
@@ -1919,11 +1919,6 @@
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
"@types/web-bluetooth@^0.0.17":
version "0.0.17"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.17.tgz#5c9f3c617f64a9735d7b72a7cc671e166d900c40"
integrity sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==
"@types/web-bluetooth@^0.0.20":
version "0.0.20"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
@@ -1953,100 +1948,90 @@
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.3.tgz#164b36653910d27c130cf6c945b4bd9bde5bcbee"
integrity sha512-b8S5dVS40rgHdDrw+DQi/xOM9ed+kSRZzfm1T74bMmBDCd8XO87NKlFYInzCtwvtWwXZvo1QxE2OSspTATWrbA==
"@vue/compiler-core@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz#1a91ea90980528bedff7b1c292690bfb30612485"
integrity sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==
"@vue/compiler-core@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.27.tgz#ce4402428e26095586eb889c41f6e172eb3960bd"
integrity sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==
dependencies:
"@babel/parser" "^7.28.5"
"@vue/shared" "3.5.26"
"@vue/shared" "3.5.27"
entities "^7.0.0"
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz#66c36b6ed8bdf43236d7188ea332bc9d078eb286"
integrity sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==
"@vue/compiler-dom@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz#32b2bc87f0a652c253986796ace0ed6213093af8"
integrity sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==
dependencies:
"@vue/compiler-core" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/compiler-core" "3.5.27"
"@vue/shared" "3.5.27"
"@vue/compiler-sfc@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz#fb1c6c4bf9a9e22bb169e039e19437cb6995917a"
integrity sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==
"@vue/compiler-sfc@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz#84651b8816bf8e7d6e62fddd14db86efd6d6f1b6"
integrity sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==
dependencies:
"@babel/parser" "^7.28.5"
"@vue/compiler-core" "3.5.26"
"@vue/compiler-dom" "3.5.26"
"@vue/compiler-ssr" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/compiler-core" "3.5.27"
"@vue/compiler-dom" "3.5.27"
"@vue/compiler-ssr" "3.5.27"
"@vue/shared" "3.5.27"
estree-walker "^2.0.2"
magic-string "^0.30.21"
postcss "^8.5.6"
source-map-js "^1.2.1"
"@vue/compiler-ssr@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz#f6e94bccbb5339180779036ddfb614f998a197ea"
integrity sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==
"@vue/compiler-ssr@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz#b480cad09dacf8f3d9c82b9843402f1a803baee7"
integrity sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==
dependencies:
"@vue/compiler-dom" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/compiler-dom" "3.5.27"
"@vue/shared" "3.5.27"
"@vue/devtools-api@^6.5.0":
"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.4":
version "6.6.4"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/reactivity@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.26.tgz#59a1edf566dc80133c1c26c93711c877e8602c48"
integrity sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==
"@vue/reactivity@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.27.tgz#d870557de1389a27b8abcb7cbfa30978dc69a000"
integrity sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==
dependencies:
"@vue/shared" "3.5.26"
"@vue/shared" "3.5.27"
"@vue/runtime-core@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz#3f2c040bcf8018c03a1ab5adb0d788c13c986f0e"
integrity sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==
"@vue/runtime-core@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.27.tgz#bb43744ed070166c7d581b849ac22b71a9ccf127"
integrity sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==
dependencies:
"@vue/reactivity" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/reactivity" "3.5.27"
"@vue/shared" "3.5.27"
"@vue/runtime-dom@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz#5954848614883948ecc1f631a67b32cc32f81936"
integrity sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==
"@vue/runtime-dom@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz#392513252c7ca7e5277240fdc70b8093449127f5"
integrity sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==
dependencies:
"@vue/reactivity" "3.5.26"
"@vue/runtime-core" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/reactivity" "3.5.27"
"@vue/runtime-core" "3.5.27"
"@vue/shared" "3.5.27"
csstype "^3.2.3"
"@vue/server-renderer@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz#269055497fcc75b3984063f866f17c748b565ef4"
integrity sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==
"@vue/server-renderer@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.27.tgz#8137d0d7ec3b59d5992bb04c553775d209dddba7"
integrity sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==
dependencies:
"@vue/compiler-ssr" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/compiler-ssr" "3.5.27"
"@vue/shared" "3.5.27"
"@vue/shared@3.5.26":
version "3.5.26"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.26.tgz#1e02ef2d64aced818cd31d81ce5175711dc90a9f"
integrity sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==
"@vueuse/core@10.4.1":
version "10.4.1"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-10.4.1.tgz#fc2c8a83a571c207aaedbe393b22daa6d35123f2"
integrity sha512-DkHIfMIoSIBjMgRRvdIvxsyboRZQmImofLyOHADqiVbQVilP8VVHDhBX2ZqoItOgu7dWa8oXiNnScOdPLhdEXg==
dependencies:
"@types/web-bluetooth" "^0.0.17"
"@vueuse/metadata" "10.4.1"
"@vueuse/shared" "10.4.1"
vue-demi ">=0.14.5"
"@vue/shared@3.5.27":
version "3.5.27"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.27.tgz#33a63143d8fb9ca1b3efbc7ecf9bd0ab05f7e06e"
integrity sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==
"@vueuse/core@^10.11.0", "@vueuse/core@^10.4.1":
version "10.11.1"
@@ -2068,28 +2053,29 @@
"@vueuse/shared" "12.8.2"
vue "^3.5.13"
"@vueuse/core@^14.1.0":
version "14.1.0"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-14.1.0.tgz#274e98e591a505333b7dfb2bcaf7b4530a10b9c9"
integrity sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==
dependencies:
"@types/web-bluetooth" "^0.0.21"
"@vueuse/metadata" "14.1.0"
"@vueuse/shared" "14.1.0"
"@vueuse/metadata@10.11.1":
version "10.11.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.11.1.tgz#209db7bb5915aa172a87510b6de2ca01cadbd2a7"
integrity sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==
"@vueuse/metadata@10.4.1":
version "10.4.1"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-10.4.1.tgz#9d2ff5c67abf17a8c07865c2413fbd0e92f7b7d7"
integrity sha512-2Sc8X+iVzeuMGHr6O2j4gv/zxvQGGOYETYXEc41h0iZXIRnRbJZGmY/QP8dvzqUelf8vg0p/yEA5VpCEu+WpZg==
"@vueuse/metadata@12.8.2":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
"@vueuse/router@12.7.0":
version "12.7.0"
resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-12.7.0.tgz#b349b66e337057bb489b6d64d2dab044d41ca74d"
integrity sha512-Jp6dIel54oc2nh++zqjY06ipCcTT6YWDCNQ8dSSnqRwx90wIl7w7MQP7Wpp1wrDwXEoqhelfeZf2gjfrkAhq3g==
dependencies:
"@vueuse/shared" "12.7.0"
vue "^3.5.13"
"@vueuse/metadata@14.1.0":
version "14.1.0"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-14.1.0.tgz#70fc2e94775e4a07369f11f86f6f0a465b04a381"
integrity sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==
"@vueuse/shared@10.11.1", "@vueuse/shared@^10.11.0":
version "10.11.1"
@@ -2098,20 +2084,6 @@
dependencies:
vue-demi ">=0.14.8"
"@vueuse/shared@10.4.1":
version "10.4.1"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-10.4.1.tgz#d5ce33033c156efb60664b5d6034d6cd4e2f530c"
integrity sha512-vz5hbAM4qA0lDKmcr2y3pPdU+2EVw/yzfRsBdu+6+USGa4PxqSQRYIUC9/NcT06y+ZgaTsyURw2I9qOFaaXHAg==
dependencies:
vue-demi ">=0.14.5"
"@vueuse/shared@12.7.0":
version "12.7.0"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.7.0.tgz#0c573789069818a2e25ddae3ab64b536c614537b"
integrity sha512-coLlUw2HHKsm7rPN6WqHJQr18WymN4wkA/3ThFaJ4v4gWGWAQQGK+MJxLuJTBs4mojQiazlVWAKNJNpUWGRkNw==
dependencies:
vue "^3.5.13"
"@vueuse/shared@12.8.2", "@vueuse/shared@^12.5.0":
version "12.8.2"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
@@ -2119,6 +2091,11 @@
dependencies:
vue "^3.5.13"
"@vueuse/shared@14.1.0":
version "14.1.0"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-14.1.0.tgz#49b2face86a9c0c52e20eaf4c732a0223276c11f"
integrity sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==
"@yr/monotone-cubic-spline@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz#7272d89f8e4f6fb7a1600c28c378cc18d3b577b9"
@@ -2288,9 +2265,9 @@ base64-js@^1.3.1:
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.9.0:
version "2.9.14"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz#3b6af0bc032445bca04de58caa9a87cfe921cbb3"
integrity sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==
version "2.9.17"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz#9d6019766cd7eba738cb5f32c84b9f937cc87780"
integrity sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==
binary-extensions@^2.0.0:
version "2.3.0"
@@ -2328,7 +2305,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.19.1, browserslist@^4.24.0, browserslist@^4.28.0:
browserslist@^4.19.1, browserslist@^4.24.0, browserslist@^4.28.1:
version "4.28.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95"
integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==
@@ -2389,9 +2366,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.30001764"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz#03206c56469f236103b90f9ae10bcb8b9e1f6005"
integrity sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==
version "1.0.30001765"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz#4a78d8a797fd4124ebaab2043df942eb091648ee"
integrity sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==
chalk@^4.1.0:
version "4.1.2"
@@ -2506,16 +2483,16 @@ convert-source-map@^2.0.0:
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
core-js-compat@^3.43.0:
version "3.47.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.47.0.tgz#698224bbdbb6f2e3f39decdda4147b161e3772a3"
integrity sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==
version "3.48.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.48.0.tgz#7efbe1fc1cbad44008190462217cc5558adaeaa6"
integrity sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==
dependencies:
browserslist "^4.28.0"
browserslist "^4.28.1"
core-js@^3.1.3, core-js@^3.26.1:
version "3.47.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.47.0.tgz#436ef07650e191afeb84c24481b298bd60eb4a17"
integrity sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==
version "3.48.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.48.0.tgz#1f813220a47bbf0e667e3885c36cd6f0593bf14d"
integrity sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==
crelt@^1.0.0, crelt@^1.0.5, crelt@^1.0.6:
version "1.0.6"
@@ -2689,9 +2666,9 @@ ejs@^3.1.6:
jake "^10.8.5"
electron-to-chromium@^1.5.263:
version "1.5.267"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz#5d84f2df8cdb6bfe7e873706bb21bd4bfb574dc7"
integrity sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==
version "1.5.277"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz#7164191a07bf32a7e646e68334f402dd60629821"
integrity sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==
engine.io-client@~6.5.2:
version "6.5.4"
@@ -2726,9 +2703,9 @@ entities@^4.4.0:
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
entities@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.0.tgz#2ae4e443f3f17d152d3f5b0f79b932c1e59deb7a"
integrity sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==
version "7.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9:
version "1.24.1"
@@ -2977,10 +2954,10 @@ fraction.js@^4.1.2:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.256:
version "0.1.256"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.256.tgz#c14756eda75ca01ada034559e8bd2f91bcfe6dff"
integrity sha512-zj8n6KXpMv/0h1NcaCsjFLP8QBnofDEBJgQa+xECU0/jbq4gSqNhFOkcx788qNL+vmBo9frywTeXwDpl7hUCZA==
frappe-ui@^0.1.261:
version "0.1.261"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.261.tgz#d6919c713a37ed8a2bdb667707dba9ece4956c6d"
integrity sha512-sEdEAgjAkrTERYWk5HBOQuKa7/xuex/X8/Y/hCYFbEThwwy2ZWmQOCsTNyOCjXAn7lyV49Ues/TW01koIq/ysQ==
dependencies:
"@floating-ui/vue" "^1.1.6"
"@headlessui/vue" "^1.7.14"
@@ -3613,9 +3590,9 @@ lodash.sortby@^4.7.0:
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash@^4.17.20:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
log-symbols@^4.1.0:
version "4.1.0"
@@ -4032,9 +4009,9 @@ postcss@^8.4.32, postcss@^8.4.47, postcss@^8.5.6:
source-map-js "^1.2.1"
prettier@^3.3.2:
version "3.7.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
pretty-bytes@^5.3.0:
version "5.6.0"
@@ -4115,9 +4092,9 @@ prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3:
w3c-keyname "^2.2.0"
prosemirror-markdown@^1.13.1:
version "1.13.2"
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz#863eb3fd5f57a444e4378174622b562735b1c503"
integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==
version "1.13.3"
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.3.tgz#cf38e98f10c432b906bfcc7179c2e3ab58f49362"
integrity sha512-3E+Et6cdXIH0EgN2tGYQ+EBT7N4kMiZFsW+hzx+aPtOmADDHWCdd2uUQb7yklJrfUYUOjEEu22BiN6UFgPe4cQ==
dependencies:
"@types/markdown-it" "^14.0.0"
markdown-it "^14.0.0"
@@ -4185,16 +4162,16 @@ prosemirror-trailing-node@^3.0.0:
escape-string-regexp "^4.0.0"
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.7.3:
version "1.10.5"
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz#4cf9fe5dcbdbfebd62499f24386e7cec9bc9979b"
integrity sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==
version "1.11.0"
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz#f5c5050354423dc83c6b083f6f1959ec86a3f9ba"
integrity sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==
dependencies:
prosemirror-model "^1.21.0"
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.41.4:
version "1.41.4"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.4.tgz#4e1b3e90accc0eebe3bddb497a40ce54e4de722d"
integrity sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==
version "1.41.5"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.5.tgz#3e152d14af633f2f5a73aba24e6130c63f643b2b"
integrity sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==
dependencies:
prosemirror-model "^1.20.0"
prosemirror-state "^1.0.0"
@@ -4395,37 +4372,37 @@ rollup@^2.43.1:
fsevents "~2.3.2"
rollup@^4.2.0:
version "4.55.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.55.1.tgz#4ec182828be440648e7ee6520dc35e9f20e05144"
integrity sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==
version "4.56.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.56.0.tgz#65959d13cfbd7e48b8868c05165b1738f0143862"
integrity sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==
dependencies:
"@types/estree" "1.0.8"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.55.1"
"@rollup/rollup-android-arm64" "4.55.1"
"@rollup/rollup-darwin-arm64" "4.55.1"
"@rollup/rollup-darwin-x64" "4.55.1"
"@rollup/rollup-freebsd-arm64" "4.55.1"
"@rollup/rollup-freebsd-x64" "4.55.1"
"@rollup/rollup-linux-arm-gnueabihf" "4.55.1"
"@rollup/rollup-linux-arm-musleabihf" "4.55.1"
"@rollup/rollup-linux-arm64-gnu" "4.55.1"
"@rollup/rollup-linux-arm64-musl" "4.55.1"
"@rollup/rollup-linux-loong64-gnu" "4.55.1"
"@rollup/rollup-linux-loong64-musl" "4.55.1"
"@rollup/rollup-linux-ppc64-gnu" "4.55.1"
"@rollup/rollup-linux-ppc64-musl" "4.55.1"
"@rollup/rollup-linux-riscv64-gnu" "4.55.1"
"@rollup/rollup-linux-riscv64-musl" "4.55.1"
"@rollup/rollup-linux-s390x-gnu" "4.55.1"
"@rollup/rollup-linux-x64-gnu" "4.55.1"
"@rollup/rollup-linux-x64-musl" "4.55.1"
"@rollup/rollup-openbsd-x64" "4.55.1"
"@rollup/rollup-openharmony-arm64" "4.55.1"
"@rollup/rollup-win32-arm64-msvc" "4.55.1"
"@rollup/rollup-win32-ia32-msvc" "4.55.1"
"@rollup/rollup-win32-x64-gnu" "4.55.1"
"@rollup/rollup-win32-x64-msvc" "4.55.1"
"@rollup/rollup-android-arm-eabi" "4.56.0"
"@rollup/rollup-android-arm64" "4.56.0"
"@rollup/rollup-darwin-arm64" "4.56.0"
"@rollup/rollup-darwin-x64" "4.56.0"
"@rollup/rollup-freebsd-arm64" "4.56.0"
"@rollup/rollup-freebsd-x64" "4.56.0"
"@rollup/rollup-linux-arm-gnueabihf" "4.56.0"
"@rollup/rollup-linux-arm-musleabihf" "4.56.0"
"@rollup/rollup-linux-arm64-gnu" "4.56.0"
"@rollup/rollup-linux-arm64-musl" "4.56.0"
"@rollup/rollup-linux-loong64-gnu" "4.56.0"
"@rollup/rollup-linux-loong64-musl" "4.56.0"
"@rollup/rollup-linux-ppc64-gnu" "4.56.0"
"@rollup/rollup-linux-ppc64-musl" "4.56.0"
"@rollup/rollup-linux-riscv64-gnu" "4.56.0"
"@rollup/rollup-linux-riscv64-musl" "4.56.0"
"@rollup/rollup-linux-s390x-gnu" "4.56.0"
"@rollup/rollup-linux-x64-gnu" "4.56.0"
"@rollup/rollup-linux-x64-musl" "4.56.0"
"@rollup/rollup-openbsd-x64" "4.56.0"
"@rollup/rollup-openharmony-arm64" "4.56.0"
"@rollup/rollup-win32-arm64-msvc" "4.56.0"
"@rollup/rollup-win32-ia32-msvc" "4.56.0"
"@rollup/rollup-win32-x64-gnu" "4.56.0"
"@rollup/rollup-win32-x64-msvc" "4.56.0"
fsevents "~2.3.2"
rope-sequence@^1.3.0:
@@ -4797,9 +4774,9 @@ tempy@^0.6.0:
unique-string "^2.0.0"
terser@^5.0.0:
version "5.44.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.44.1.tgz#e391e92175c299b8c284ad6ded609e37303b0a9c"
integrity sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==
version "5.46.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695"
integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==
dependencies:
"@jridgewell/source-map" "^0.3.3"
acorn "^8.15.0"
@@ -4940,9 +4917,9 @@ uc.micro@^2.0.0, uc.micro@^2.1.0:
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
ufo@^1.6.1:
version "1.6.2"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.2.tgz#aaf4d46b98425b2fb5031abe8d65ca069e93e755"
integrity sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==
version "1.6.3"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f"
integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==
unbox-primitive@^1.1.0:
version "1.1.0"
@@ -5169,7 +5146,7 @@ vue-codemirror@6.1.1:
"@codemirror/state" "6.x"
"@codemirror/view" "6.x"
vue-demi@*, vue-demi@>=0.13.0, vue-demi@>=0.14.5, vue-demi@>=0.14.8:
vue-demi@*, vue-demi@>=0.13.0, vue-demi@>=0.14.8:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
@@ -5179,28 +5156,28 @@ vue-draggable-next@2.2.1:
resolved "https://registry.yarnpkg.com/vue-draggable-next/-/vue-draggable-next-2.2.1.tgz#adbe98c74610cca8f4eb63f92042681f96920451"
integrity sha512-EAMS1IRHF0kZO0o5PMOinsQsXIqsrKT1hKmbICxG3UEtn7zLFkLxlAtajcCcUTisNvQ6TtCB5COjD9a1raNADw==
vue-router@4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.2.2.tgz#b0097b66d89ca81c0986be03da244c7b32a4fd81"
integrity sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ==
vue-router@^4.6.4:
version "4.6.4"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.6.4.tgz#a0a9cb9ef811a106d249e4bb9313d286718020d8"
integrity sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==
dependencies:
"@vue/devtools-api" "^6.5.0"
"@vue/devtools-api" "^6.6.4"
vue3-apexcharts@1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/vue3-apexcharts/-/vue3-apexcharts-1.8.0.tgz#1984648d966aa91bc4dc3e87fa847f5289f7f1cf"
integrity sha512-5tSD4mXTBbIJ9ir+58qHE6oNtIe0RNgqIRYMKpcsIaxkKtwUww4JhvPkpUFlmiW4OJbbdklgjleXq1lfcM4gdA==
vue@^3.5.0, vue@^3.5.13:
version "3.5.26"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.26.tgz#03a0b17311e0e593d34b9358fa249b85e3a6d9fb"
integrity sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==
vue@^3.5.13, vue@^3.5.27:
version "3.5.27"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.27.tgz#e55fd941b614459ab2228489bc19d1692e05876c"
integrity sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==
dependencies:
"@vue/compiler-dom" "3.5.26"
"@vue/compiler-sfc" "3.5.26"
"@vue/runtime-dom" "3.5.26"
"@vue/server-renderer" "3.5.26"
"@vue/shared" "3.5.26"
"@vue/compiler-dom" "3.5.27"
"@vue/compiler-sfc" "3.5.27"
"@vue/runtime-dom" "3.5.27"
"@vue/server-renderer" "3.5.27"
"@vue/shared" "3.5.27"
vuedraggable@4.1.0:
version "4.1.0"
@@ -5281,9 +5258,9 @@ which-collection@^1.0.2:
is-weakset "^2.0.3"
which-typed-array@^1.1.16, which-typed-array@^1.1.19:
version "1.1.19"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956"
integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==
version "1.1.20"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122"
integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==
dependencies:
available-typed-arrays "^1.0.7"
call-bind "^1.0.8"

View File

@@ -45,6 +45,8 @@ ALLOWED_PATHS = [
"/api/method/frappe.desk.search.search_link",
"/api/method/frappe.core.doctype.communication.email.make",
"/api/method/frappe.core.doctype.user.user.reset_password",
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_as_read",
"/api/method/frappe.desk.doctype.notification_log.notification_log.mark_all_as_read",
]

View File

@@ -1,3 +1,5 @@
import frappe
from . import __version__ as app_version
app_name = "frappe_lms"
@@ -6,11 +8,17 @@ app_publisher = "Frappe"
app_description = "Frappe LMS App"
app_icon_url = "/assets/lms/images/lms-logo.png"
app_icon_title = "Learning"
app_icon_route = "/lms"
app_color = "grey"
app_email = "jannat@frappe.io"
app_license = "AGPL"
def get_lms_path():
return (frappe.conf.get("lms_path") or "lms").strip("/")
app_icon_route = f"/{get_lms_path()}"
# Includes in <head>
# ------------------
@@ -163,7 +171,8 @@ override_whitelisted_methods = {
# Add all simple route rules here
website_route_rules = [
{"from_route": "/lms/<path:app_path>", "to_route": "lms"},
{"from_route": f"/{get_lms_path()}/<path:app_path>", "to_route": "_lms"},
{"from_route": f"/{get_lms_path()}", "to_route": "_lms"},
{
"from_route": "/courses/<course_name>/<certificate_id>",
"to_route": "certificate",
@@ -172,24 +181,25 @@ website_route_rules = [
website_redirects = [
{"source": "/update-profile", "target": "/edit-profile"},
{"source": "/courses", "target": "/lms/courses"},
{"source": "/courses", "target": f"/{get_lms_path()}/courses"},
{
"source": r"^/courses/.*$",
"target": "/lms/courses",
"target": f"/{get_lms_path()}/courses",
},
{"source": "/batches", "target": "/lms/batches"},
{"source": "/batches", "target": f"/{get_lms_path()}/batches"},
{
"source": r"/batches/(.*)",
"target": "/lms/batches",
"target": f"/{get_lms_path()}/batches",
"match_with_query_string": True,
},
{"source": "/job-openings", "target": "/lms/job-openings"},
{"source": "/job-openings", "target": f"/{get_lms_path()}/job-openings"},
{
"source": r"/job-openings/(.*)",
"target": "/lms/job-openings",
"target": f"/{get_lms_path()}/job-openings",
"match_with_query_string": True,
},
{"source": "/statistics", "target": "/lms/statistics"},
{"source": "/statistics", "target": f"/{get_lms_path()}/statistics"},
{"source": "_lms", "target": f"/{get_lms_path()}"},
]
update_website_context = [
@@ -198,16 +208,20 @@ update_website_context = [
jinja = {
"methods": [
"lms.lms.utils.get_tags",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.lms.utils.get_lms_route",
"lms.lms.utils.is_instructor",
"lms.lms.utils.get_palette",
],
"filters": [],
}
extend_bootinfo = [
"lms.lms.utils.extend_bootinfo",
]
## Specify the additional tabs to be included in the user profile page.
## Each entry must be a subclass of lms.lms.plugins.ProfileTab
# profile_tabs = []
@@ -256,7 +270,7 @@ add_to_apps_screen = [
"name": "lms",
"logo": "/assets/lms/frontend/learning.svg",
"title": "Learning",
"route": "/lms",
"route": f"/{get_lms_path()}",
"has_permission": "lms.lms.api.check_app_permission",
}
]

View File

@@ -27,18 +27,25 @@ from frappe.utils import (
now,
)
from frappe.utils.response import Response
from pypika import functions as fn
from lms.lms.doctype.course_lesson.course_lesson import save_progress
from lms.lms.utils import (
can_modify_batch,
can_modify_course,
get_average_rating,
get_batch_details,
get_course_details,
get_instructors,
get_lesson_count,
get_lms_route,
has_course_instructor_role,
has_evaluator_role,
has_moderator_role,
)
@frappe.whitelist(allow_guest=True)
@frappe.whitelist()
def get_user_info():
if frappe.session.user == "Guest":
return None
@@ -222,7 +229,6 @@ def get_chart_details():
return details
@frappe.whitelist()
def get_file_info(file_url):
"""Get file info for the given file URL."""
file_info = frappe.db.get_value(
@@ -234,17 +240,20 @@ def get_file_info(file_url):
@frappe.whitelist(allow_guest=True)
def get_branding():
"""Get branding details."""
website_settings = frappe.get_single("Website Settings")
image_fields = ["banner_image", "footer_logo", "favicon"]
fields = ["app_name"]
image_fields = ["banner_image", "footer_logo", "favicon", "app_logo"]
fields = fields + image_fields
settings = frappe._dict()
for field in image_fields:
if website_settings.get(field):
file_info = get_file_info(website_settings.get(field))
website_settings.update({field: json.loads(json.dumps(file_info))})
for field in fields:
value = frappe.get_cached_value("Website Settings", None, field)
if field in image_fields and value:
file_info = get_file_info(value)
settings.update({field: json.loads(json.dumps(file_info))})
else:
website_settings.update({field: None})
settings.update({field: value})
return website_settings
return settings
@frappe.whitelist()
@@ -284,7 +293,7 @@ def get_evaluator_details(evaluator):
}
@frappe.whitelist(allow_guest=True)
@frappe.whitelist()
def get_certified_participants(filters=None, start=0, page_length=100):
query = get_certification_query(filters)
query = query.orderby("issue_date", order=frappe.qb.desc).offset(start).limit(page_length)
@@ -315,7 +324,7 @@ def get_certification_query(filters):
query = (
frappe.qb.from_(Certificate)
.select(Certificate.member)
.select(Certificate.member, Certificate.issue_date)
.distinct()
.join(User)
.on(Certificate.member == User.name)
@@ -338,14 +347,14 @@ def get_certification_query(filters):
return query
@frappe.whitelist(allow_guest=True)
@frappe.whitelist()
def get_count_of_certified_members(filters=None):
query = get_certification_query(filters)
result = query.run(as_dict=True)
return len(result) or 0
@frappe.whitelist(allow_guest=True)
@frappe.whitelist()
def get_certification_categories():
categories = []
seen = set()
@@ -367,20 +376,6 @@ def get_certification_categories():
return categories
@frappe.whitelist()
def get_assigned_badges(member):
assigned_badges = frappe.get_all(
"LMS Badge Assignment",
{"member": member},
["badge"],
as_dict=1,
)
for badge in assigned_badges:
badge.update(frappe.db.get_value("LMS Badge", badge.badge, ["name", "title", "image"]))
return assigned_badges
@frappe.whitelist()
def get_all_users():
frappe.only_for(["Moderator", "Course Creator", "Batch Evaluator"])
@@ -395,28 +390,13 @@ def get_all_users():
return {user.name: user for user in users}
@frappe.whitelist()
def mark_as_read(name):
doc = frappe.get_doc("Notification Log", name)
doc.read = 1
doc.save(ignore_permissions=True)
@frappe.whitelist()
def mark_all_as_read():
notifications = frappe.get_all(
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
)
for notification in notifications:
mark_as_read(notification)
@frappe.whitelist(allow_guest=True)
def get_sidebar_settings():
lms_settings = frappe.get_single("LMS Settings")
sidebar_items = frappe._dict()
if not lms_settings.allow_guest_access:
return []
sidebar_items = frappe._dict()
items = [
"courses",
"batches",
@@ -445,6 +425,7 @@ def get_sidebar_settings():
@frappe.whitelist()
def update_sidebar_item(webpage, icon):
frappe.only_for("Moderator")
filters = {
"web_page": webpage,
"parenttype": "LMS Settings",
@@ -463,6 +444,7 @@ def update_sidebar_item(webpage, icon):
@frappe.whitelist()
def delete_sidebar_item(webpage):
frappe.only_for("Moderator")
return frappe.db.delete(
"LMS Sidebar Item",
{
@@ -476,22 +458,31 @@ def delete_sidebar_item(webpage):
@frappe.whitelist()
def delete_lesson(lesson, chapter):
# Delete Reference
chapter = frappe.get_doc("Course Chapter", chapter)
chapter.lessons = [row for row in chapter.lessons if row.lesson != lesson]
chapter.save()
course = frappe.db.get_value("Course Chapter", chapter, "course")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to delete this lesson."), frappe.PermissionError)
lessons = frappe.get_all(
"Lesson Reference",
{"parent": chapter},
pluck="lesson",
order_by="idx",
)
lessons.remove(lesson)
frappe.db.delete("Lesson Reference", {"parent": chapter, "lesson": lesson})
update_index(lessons, chapter)
# Delete progress
frappe.db.delete("LMS Course Progress", {"lesson": lesson})
# Delete Lesson
frappe.db.delete("Course Lesson", lesson)
@frappe.whitelist()
def update_lesson_index(lesson, sourceChapter, targetChapter, idx):
hasMoved = sourceChapter == targetChapter
course = frappe.db.get_value("Course Chapter", sourceChapter, "course")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to modify this lesson."), frappe.PermissionError)
hasMoved = sourceChapter == targetChapter
update_source_chapter(lesson, sourceChapter, idx, hasMoved)
if not hasMoved:
update_target_chapter(lesson, targetChapter, idx)
@@ -550,6 +541,10 @@ def update_index(lessons, chapter):
@frappe.whitelist()
def update_chapter_index(chapter, course, idx):
"""Update the index of a chapter within a course"""
if not can_modify_course(course):
frappe.throw(_("You do not have permission to modify this chapter."), frappe.PermissionError)
chapters = frappe.get_all(
"Chapter Reference",
{"parent": course},
@@ -566,26 +561,9 @@ def update_chapter_index(chapter, course, idx):
frappe.db.set_value("Chapter Reference", {"chapter": chapter_name, "parent": course}, "idx", i + 1)
@frappe.whitelist(allow_guest=True)
def get_categories(doctype, filters):
categoryOptions = []
categories = frappe.get_all(
doctype,
filters,
pluck="category",
)
categories = list(set(categories))
for category in categories:
if category:
categoryOptions.append({"label": category, "value": category})
return categoryOptions
@frappe.whitelist()
def get_members(start=0, search=""):
frappe.only_for(["Moderator"])
filters = {"enabled": 1, "name": ["not in", ["Administrator", "Guest"]]}
or_filters = {}
@@ -652,6 +630,7 @@ def save_evaluation_details(
"""
Save evaluation details for a member against a course.
"""
frappe.only_for(["Batch Evaluator", "Moderator"])
evaluation = frappe.db.exists("LMS Certificate Evaluation", {"member": member, "course": course})
details = {
@@ -695,6 +674,7 @@ def save_certificate_details(
"""
Save certificate details for a member against a course.
"""
frappe.only_for(["Batch Evaluator", "Moderator"])
certificate = frappe.db.exists("LMS Certificate", {"member": member, "course": course})
details = {
@@ -729,16 +709,9 @@ def delete_documents(doctype, documents):
frappe.delete_doc(doctype, doc)
@frappe.whitelist(allow_guest=True)
def get_count(doctype, filters):
return frappe.db.count(
doctype,
filters=filters,
)
@frappe.whitelist()
def get_payment_gateway_details(payment_gateway):
frappe.only_for("Moderator")
gateway = frappe.get_doc("Payment Gateway", payment_gateway)
if gateway.gateway_controller is None:
@@ -794,6 +767,7 @@ def get_transformed_fields(meta, data=None):
@frappe.whitelist()
def get_new_gateway_fields(doctype):
frappe.only_for("Moderator")
try:
meta = frappe.get_meta(doctype).fields
except Exception:
@@ -824,6 +798,18 @@ def update_course_statistics():
@frappe.whitelist()
def get_announcements(batch):
roles = frappe.get_roles()
is_batch_student = frappe.db.exists(
"LMS Batch Enrollment", {"batch": batch, "member": frappe.session.user}
)
is_moderator = "Moderator" in roles
is_evaluator = "Batch Evaluator" in roles
if not (is_batch_student or is_moderator or is_evaluator):
frappe.throw(
_("You do not have permission to access announcements for this batch."), frappe.PermissionError
)
communications = frappe.get_all(
"Communication",
filters={
@@ -850,17 +836,21 @@ def get_announcements(batch):
@frappe.whitelist()
def delete_course(course):
chapters = frappe.get_all("Course Chapter", {"course": course}, pluck="name")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to delete this course."), frappe.PermissionError)
chapter_references = frappe.get_all("Chapter Reference", {"parent": course}, pluck="name")
frappe.db.delete("LMS Enrollment", {"course": course})
frappe.db.delete("LMS Course Progress", {"course": course})
frappe.db.set_value("LMS Quiz", {"course": course}, "course", None)
frappe.db.set_value("LMS Quiz Submission", {"course": course}, "course", None)
chapters = frappe.get_all("Course Chapter", {"course": course}, pluck="name")
frappe.db.delete("Chapter Reference", {"parent": course})
for chapter in chapters:
lessons = frappe.get_all("Course Lesson", {"chapter": chapter}, pluck="name")
lesson_references = frappe.get_all("Lesson Reference", {"parent": chapter}, pluck="name")
for lesson in lesson_references:
frappe.delete_doc("Lesson Reference", lesson)
frappe.db.delete("Lesson Reference", {"parent": chapter})
for lesson in lessons:
topics = frappe.get_all(
@@ -871,26 +861,21 @@ def delete_course(course):
for topic in topics:
frappe.db.delete("Discussion Reply", {"topic": topic})
frappe.db.delete("Discussion Topic", topic)
frappe.delete_doc("Course Lesson", lesson)
for chapter in chapter_references:
frappe.delete_doc("Chapter Reference", chapter)
for chapter in chapters:
frappe.delete_doc("Course Chapter", chapter)
frappe.db.delete("LMS Course Progress", {"course": course})
frappe.db.delete("LMS Quiz", {"course": course})
frappe.db.delete("LMS Quiz Submission", {"course": course})
frappe.db.delete("LMS Enrollment", {"course": course})
frappe.delete_doc("LMS Course", course)
@frappe.whitelist()
def delete_batch(batch):
if not can_modify_batch(batch):
frappe.throw(_("You do not have permission to delete this batch."), frappe.PermissionError)
frappe.db.delete("LMS Batch Enrollment", {"batch": batch})
frappe.db.delete("Batch Course", {"parent": batch, "parenttype": "LMS Batch"})
frappe.db.delete("LMS Assessment", {"parent": batch, "parenttype": "LMS Batch"})
@@ -929,11 +914,14 @@ def give_discussions_permission():
"delete": 1,
"if_owner": 0 if role == "Moderator" else 1,
}
).save(ignore_permissions=True)
).save()
@frappe.whitelist()
def upsert_chapter(title, course, is_scorm_package, scorm_package, name=None):
if not can_modify_course(course):
frappe.throw(_("You do not have permission to modify this chapter."), frappe.PermissionError)
values = frappe._dict({"title": title, "course": course, "is_scorm_package": is_scorm_package})
if is_scorm_package:
@@ -1056,6 +1044,10 @@ def add_lesson(title, chapter, course, idx):
@frappe.whitelist()
def delete_chapter(chapter):
course = frappe.db.get_value("Course Chapter", chapter, "course")
if not can_modify_course(course):
frappe.throw(_("You do not have permission to delete this chapter."), frappe.PermissionError)
chapterInfo = frappe.db.get_value(
"Course Chapter", chapter, ["is_scorm_package", "scorm_package_path"], as_dict=True
)
@@ -1098,9 +1090,9 @@ def mark_lesson_progress(course, chapter_number, lesson_number):
@frappe.whitelist()
def get_heatmap_data(member=None, base_days=200):
if not member:
member = frappe.session.user
def get_heatmap_data(member, base_days=200):
if not (has_course_instructor_role() or has_moderator_role() or has_evaluator_role()):
frappe.throw(_("You do not have permission to access heatmap data."), frappe.PermissionError)
base_date, start_date, number_of_days, days = calculate_date_ranges(base_days)
date_count = initialize_date_count(days)
@@ -1213,6 +1205,8 @@ def get_week_difference(start_date, current_date):
@frappe.whitelist()
def get_notifications(filters):
filters = frappe._dict(filters or {})
filters.for_user = frappe.session.user
notifications = frappe.get_all(
"Notification Log",
filters,
@@ -1312,9 +1306,8 @@ def get_lms_settings():
@frappe.whitelist()
def cancel_evaluation(evaluation):
evaluation = frappe._dict(evaluation)
if evaluation.member != frappe.session.user:
return
frappe.throw(_("You do not have permission to cancel this evaluation."), frappe.PermissionError)
frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Cancelled")
events = frappe.get_all(
@@ -1412,16 +1405,6 @@ def add_an_evaluator(email):
return evaluator
@frappe.whitelist()
def delete_evaluator(evaluator):
frappe.only_for("Moderator")
if not frappe.db.exists("Course Evaluator", evaluator):
frappe.throw(_("Evaluator does not exist."))
frappe.db.delete("Has Role", {"parent": evaluator, "role": "Batch Evaluator"})
frappe.db.delete("Course Evaluator", evaluator)
@frappe.whitelist()
def capture_user_persona(responses):
frappe.only_for("System Manager")
@@ -1454,6 +1437,7 @@ def get_meta_info(type, route):
@frappe.whitelist()
def update_meta_info(meta_type, route, meta_tags):
frappe.only_for(["Course Creator", "Batch Evaluator", "Moderator"])
validate_meta_data_permissions(meta_type)
validate_meta_tags(meta_tags)
@@ -1554,6 +1538,10 @@ def make_new_exercise_submission(exercise, code, test_cases):
def update_exercise_submission(submission, code, test_cases):
member = frappe.db.get_value("LMS Programming Exercise Submission", submission, "member")
if member != frappe.session.user:
frappe.throw(_("You do not have permission to update this submission."), frappe.PermissionError)
update_test_cases(test_cases, submission)
status = get_exercise_status(test_cases)
frappe.db.set_value("LMS Programming Exercise Submission", submission, {"status": status, "code": code})
@@ -1626,6 +1614,11 @@ def track_new_watch_time(lesson, video):
@frappe.whitelist()
def get_course_progress_distribution(course):
if not can_modify_course(course):
frappe.throw(
_("You do not have permission to access this course's progress data."), frappe.PermissionError
)
all_progress = frappe.get_all(
"LMS Enrollment",
{
@@ -1653,24 +1646,16 @@ def get_average_course_progress(progress_list):
def get_progress_distribution(progressList):
distribution = [
{
"category": "0-20%",
"count": len([p for p in progressList if 0 <= p < 20]),
"name": "Just Started (0-30%)",
"value": len([p for p in progressList if 0 <= p < 30]),
},
{
"category": "20-40%",
"count": len([p for p in progressList if 20 <= p < 40]),
"name": "In Progress (30-60%)",
"value": len([p for p in progressList if 30 <= p < 60]),
},
{
"category": "40-60%",
"count": len([p for p in progressList if 40 <= p < 60]),
},
{
"category": "60-80%",
"count": len([p for p in progressList if 60 <= p < 80]),
},
{
"category": "80-100%",
"count": len([p for p in progressList if 80 <= p <= 100]),
"name": "Advanced (60-100%)",
"value": len([p for p in progressList if 60 <= p <= 100]),
},
]
@@ -1686,7 +1671,7 @@ def get_pwa_manifest():
"name": title,
"short_name": title,
"description": "Easy to use, 100% open source Learning Management System",
"start_url": "/lms",
"start_url": get_lms_route(),
"icons": [
{
"src": banner_image or "/assets/lms/frontend/manifest/manifest-icon-192.maskable.png",
@@ -1730,9 +1715,6 @@ def get_profile_details(username):
@frappe.whitelist()
def get_streak_info():
if frappe.session.user == "Guest":
return {}
all_dates = fetch_activity_dates(frappe.session.user)
streak, longest_streak = calculate_streaks(all_dates)
current_streak = calculate_current_streak(all_dates, streak)
@@ -1801,8 +1783,6 @@ def calculate_current_streak(all_dates, streak):
@frappe.whitelist()
def get_my_live_classes():
my_live_classes = []
if frappe.session.user == "Guest":
return my_live_classes
batches = frappe.get_all(
"LMS Batch Enrollment",
@@ -1847,8 +1827,6 @@ def get_my_live_classes():
@frappe.whitelist()
def get_created_courses():
created_courses = []
if frappe.session.user == "Guest":
return created_courses
CourseInstructor = frappe.qb.DocType("Course Instructor")
Course = frappe.qb.DocType("LMS Course")
@@ -1876,8 +1854,6 @@ def get_created_courses():
@frappe.whitelist()
def get_created_batches():
created_batches = []
if frappe.session.user == "Guest":
return created_batches
CourseInstructor = frappe.qb.DocType("Course Instructor")
Batch = frappe.qb.DocType("LMS Batch")
@@ -1905,9 +1881,6 @@ def get_created_batches():
@frappe.whitelist()
def get_admin_live_classes():
if frappe.session.user == "Guest":
return []
CourseInstructor = frappe.qb.DocType("Course Instructor")
LMSLiveClass = frappe.qb.DocType("LMS Live Class")
@@ -1938,9 +1911,6 @@ def get_admin_live_classes():
@frappe.whitelist()
def get_admin_evals():
if frappe.session.user == "Guest":
return []
evals = frappe.get_all(
"LMS Certificate Request",
{
@@ -1970,9 +1940,6 @@ def get_admin_evals():
@frappe.whitelist()
def get_my_courses():
my_courses = []
if frappe.session.user == "Guest":
return my_courses
courses = get_my_latest_courses()
if not len(courses):
@@ -2024,9 +1991,6 @@ def get_popular_courses():
@frappe.whitelist()
def get_my_batches():
my_batches = []
if frappe.session.user == "Guest":
return my_batches
batches = get_my_latest_batches()
if not len(batches):
@@ -2067,5 +2031,42 @@ def get_upcoming_batches():
@frappe.whitelist()
def delete_programming_exercise(exercise):
frappe.only_for(["Moderator", "Course Creator"])
frappe.db.delete("LMS Programming Exercise Submission", {"exercise": exercise})
frappe.db.delete("LMS Programming Exercise", exercise)
@frappe.whitelist()
def get_lesson_completion_stats(course):
roles = frappe.get_roles()
if "Course Creator" not in roles and "Moderator" not in roles:
frappe.throw(_("You do not have permission to access lesson completion stats."))
CourseProgress = frappe.qb.DocType("LMS Course Progress")
LessonReference = frappe.qb.DocType("Lesson Reference")
ChapterReference = frappe.qb.DocType("Chapter Reference")
Lesson = frappe.qb.DocType("Course Lesson")
rows = (
frappe.qb.from_(CourseProgress)
.join(LessonReference)
.on(CourseProgress.lesson == LessonReference.lesson)
.join(ChapterReference)
.on(LessonReference.parent == ChapterReference.chapter)
.join(Lesson)
.on(CourseProgress.lesson == Lesson.name)
.select(
LessonReference.idx,
ChapterReference.idx.as_("chapter_idx"),
CourseProgress.lesson,
Lesson.title,
Lesson.name.as_("lesson_name"),
fn.Count(CourseProgress.name).as_("completion_count"),
)
.where((CourseProgress.course == course) & (CourseProgress.status == "Complete"))
.groupby(CourseProgress.lesson)
.orderby(ChapterReference.idx, LessonReference.idx)
.run(as_dict=True)
)
return rows

View File

@@ -1,23 +1,21 @@
# Copyright (c) 2022, Frappe and Contributors
# See license.txt
# import frappe
from frappe.tests import UnitTestCase
from frappe.utils import add_days, format_time, getdate
from lms.lms.doctype.course_evaluator.course_evaluator import get_schedule, get_schedule_range_end_date
from lms.lms.test_utils import TestUtils
from lms.lms.test_helpers import BaseTestUtils
class TestCourseEvaluator(UnitTestCase):
class TestCourseEvaluator(BaseTestUtils):
def setUp(self):
self.admin = TestUtils.create_user(
self, "frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
super().setUp()
self.admin = self._create_user(
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
)
self.course = TestUtils.create_a_course(self)
self.evaluator = TestUtils.create_evaluator(self)
self.batch = TestUtils.create_a_batch(self)
self.course = self._create_course()
self.evaluator = self._create_evaluator()
self.batch = self._create_batch(self.course.name)
def test_schedule_day_and_time(self):
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)

View File

@@ -182,8 +182,3 @@ def get_assignment_progress(lesson):
):
return False
return True
@frappe.whitelist()
def get_lesson_info(chapter):
return frappe.db.get_value("Course Chapter", chapter, "course")

View File

@@ -9,18 +9,3 @@ from lms.lms.utils import has_course_instructor_role, has_moderator_role
class LMSAssignment(Document):
pass
@frappe.whitelist()
def save_assignment(assignment, title, type, question):
if not has_moderator_role() or not has_course_instructor_role():
return
if assignment:
doc = frappe.get_doc("LMS Assignment", assignment)
else:
doc = frappe.get_doc({"doctype": "LMS Assignment"})
doc.update({"title": title, "type": type, "question": question})
doc.save(ignore_permissions=True)
return doc.name

View File

@@ -7,6 +7,8 @@ from frappe.desk.doctype.notification_log.notification_log import make_notificat
from frappe.model.document import Document
from frappe.utils import validate_url
from lms.lms.utils import get_lms_route
class LMSAssignmentSubmission(Document):
def validate(self):
@@ -72,83 +74,7 @@ class LMSAssignmentSubmission(Document):
"document_name": self.name,
"from_user": self.evaluator,
"type": "Alert",
"link": f"/lms/assignment-submission/{self.assignment}/{self.name}",
"link": get_lms_route(f"assignment-submission/{self.assignment}/{self.name}"),
}
)
make_notification_logs(notification, [self.member])
@frappe.whitelist()
def upload_assignment(
assignment_attachment=None,
answer=None,
assignment=None,
lesson=None,
status="Not Graded",
comments=None,
submission=None,
):
if frappe.session.user == "Guest":
return
assignment_details = frappe.db.get_value(
"LMS Assignment", assignment, ["type", "grade_assignment"], as_dict=1
)
assignment_type = assignment_details.type
if assignment_type in ["URL", "Text"] and not answer:
frappe.throw(_("Please enter the URL for assignment submission."))
if assignment_type == "File" and not assignment_attachment:
frappe.throw(_("Please upload the assignment file."))
if assignment_type == "URL" and not validate_url(answer):
frappe.throw(_("Please enter a valid URL."))
if submission:
doc = frappe.get_doc("LMS Assignment Submission", submission)
else:
doc = frappe.get_doc(
{
"doctype": "LMS Assignment Submission",
"assignment": assignment,
"lesson": lesson,
"member": frappe.session.user,
"type": assignment_type,
}
)
doc.update(
{
"assignment_attachment": assignment_attachment,
"status": "Not Applicable"
if assignment_type == "Text" and not assignment_details.grade_assignment
else status,
"comments": comments,
"answer": answer,
}
)
doc.save(ignore_permissions=True)
return doc.name
@frappe.whitelist()
def get_assignment(lesson):
assignment = frappe.db.get_value(
"LMS Assignment Submission",
{"lesson": lesson, "member": frappe.session.user},
["name", "lesson", "member", "assignment_attachment", "comments", "status"],
as_dict=True,
)
assignment.file_name = frappe.db.get_value(
"File", {"file_url": assignment.assignment_attachment}, "file_name"
)
return assignment
@frappe.whitelist()
def grade_assignment(name, result, comments):
doc = frappe.get_doc("LMS Assignment Submission", name)
doc.status = result
doc.comments = comments
doc.save(ignore_permissions=True)

View File

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

View File

@@ -18,6 +18,7 @@ from lms.lms.utils import (
get_instructors,
get_lesson_index,
get_lesson_url,
get_lms_route,
get_quiz_details,
update_payment_record,
)
@@ -164,7 +165,7 @@ def send_email_notification_for_published_batch(batch):
"medium": batch.medium,
"timezone": batch.timezone,
"instructors": instructors,
"batch_url": f"{frappe.utils.get_url()}/lms/batches/details/{batch.name}",
"batch_url": frappe.utils.get_url(get_lms_route(f"batches/details/{batch.name}")),
}
frappe.sendmail(
@@ -193,7 +194,7 @@ def send_system_notification_for_published_batch(batch):
"document_name": batch.name,
"from_user": instructors[0] if instructors else None,
"type": "Alert",
"link": f"/lms/batches/details/{batch.name}",
"link": get_lms_route(f"batches/details/{batch.name}"),
}
)
make_notification_logs(notification, students)

View File

@@ -231,32 +231,9 @@ def update_meeting_details(eval, event, calendar):
frappe.db.set_value("LMS Certificate Request", eval.name, "google_meet_link", event.google_meet_link)
@frappe.whitelist()
def create_certificate_request(course, date, day, start_time, end_time, batch_name=None):
is_member = frappe.db.exists(
{"doctype": "LMS Enrollment", "course": course, "member": frappe.session.user}
)
if not is_member:
return
eval = frappe.new_doc("LMS Certificate Request")
eval.update(
{
"course": course,
"evaluator": get_evaluator(course, batch_name),
"member": frappe.session.user,
"date": date,
"day": day,
"start_time": start_time,
"end_time": end_time,
"batch_name": batch_name,
}
)
eval.save(ignore_permissions=True)
@frappe.whitelist()
def create_lms_certificate_evaluation(source_name, target_doc=None):
frappe.only_for(["Moderator", "Batch Evaluator", "System Manager"])
doc = get_mapped_doc(
"LMS Certificate Request",
source_name,

View File

@@ -20,7 +20,11 @@ frappe.ui.form.on("LMS Course", {
});
},
refresh: (frm) => {
frm.add_web_link(`/lms/courses/${frm.doc.name}`, "See on Website");
const lmsPath = frappe.boot.lms_path || "lms";
frm.add_web_link(
`/${lmsPath}/courses/${frm.doc.name}`,
"See on Website"
);
if (!frm.doc.currency)
frappe.db

View File

@@ -9,7 +9,13 @@ from frappe.desk.doctype.notification_log.notification_log import make_notificat
from frappe.model.document import Document
from frappe.utils import cint, today
from ...utils import generate_slug, get_instructors, update_payment_record, validate_image
from ...utils import (
generate_slug,
get_instructors,
get_lms_route,
update_payment_record,
validate_image,
)
class LMSCourse(Document):
@@ -107,7 +113,7 @@ class LMSCourse(Document):
subject = self.title + " is available!"
args = {
"title": self.title,
"course_link": f"/lms/courses/{self.name}",
"course_link": get_lms_route(f"courses/{self.name}"),
"app_name": frappe.db.get_single_value("System Settings", "app_name"),
"site_url": frappe.utils.get_url(),
}
@@ -172,7 +178,7 @@ def send_email_notification_for_published_courses(courses):
"title": course.title,
"short_introduction": course.short_introduction,
"instructors": instructors,
"course_url": f"{frappe.utils.get_url()}/lms/courses/{course.name}",
"course_url": frappe.utils.get_url(get_lms_route(f"courses/{course.name}")),
}
frappe.sendmail(
@@ -202,7 +208,7 @@ def send_system_notification_for_published_courses(courses):
"document_name": course.name,
"from_user": instructors[0] if instructors else None,
"type": "Alert",
"link": f"/lms/courses/{course.name}",
"link": get_lms_route(f"courses/{course.name}"),
}
)
make_notification_logs(notification, students)

View File

@@ -1,84 +1,55 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import unittest
import frappe
from .lms_course import LMSCourse
from lms.lms.api import delete_course
from lms.lms.test_helpers import BaseTestUtils
class TestLMSCourse(unittest.TestCase):
class TestLMSCourse(BaseTestUtils):
def setUp(self):
super().setUp()
self.instructor = self._create_user(
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator"]
)
def test_new_course(self):
course = new_course("Test Course")
assert course.title == "Test Course"
course_name = f"Test Course {frappe.generate_hash()}"
def tearDown(self):
if frappe.db.exists("User", "tester@example.com"):
frappe.delete_doc("User", "tester@example.com")
course = self._create_course(course_name)
if frappe.db.exists("LMS Course", "test-course"):
frappe.db.delete("Batch Course", {"course": "test-course"})
frappe.db.delete("LMS Enrollment", {"course": "test-course"})
frappe.db.delete("Course Lesson", {"course": "test-course"})
frappe.db.delete("Course Chapter", {"course": "test-course"})
frappe.db.delete("LMS Course Mentor Mapping", {"course": "test-course"})
frappe.db.delete("Course Instructor", {"parent": "test-course"})
frappe.db.sql("delete from `tabCourse Instructor`")
frappe.delete_doc("LMS Course", "test-course")
self.assertEqual(course.title, course_name)
self.assertTrue(frappe.db.exists("LMS Course", course.name))
def test_delete_course(self):
course = self._create_course(f"Test Course {frappe.generate_hash()}")
chapter = self._create_chapter(f"Test Chapter {frappe.generate_hash()}", course.name)
lesson = self._create_lesson(f"Test Lesson {frappe.generate_hash()}", chapter.name, course.name)
def new_user(name, email):
user = frappe.db.exists("User", email)
if user:
return frappe.get_doc("User", user)
else:
filters = {
"email": email,
"first_name": name,
"send_welcome_email": False,
}
lesson_ref = self._create_lesson_reference(chapter.name, lesson.name)
chapter_ref = self._create_chapter_reference(course.name, chapter.name)
doc = frappe.new_doc("User")
doc.update(filters)
doc.save()
return doc
user_email = f"test_{frappe.generate_hash()}@example.com"
self._create_user(user_email, "Test", "Member", ["LMS Student"])
enrollment = self._create_enrollment(user_email, course.name)
progress = self._create_progress(user_email, course.name, lesson.name)
delete_course(course.name)
def new_course(title, additional_filters=None):
course = frappe.db.exists("LMS Course", {"title": title})
if course:
return frappe.get_doc("LMS Course", course)
else:
create_evaluator()
user = frappe.db.get_value(
"User",
{
"user_type": "System User",
},
)
filters = {
"title": title,
"short_introduction": title,
"description": title,
"video_link": "https://youtu.be/pEbIhUySqbk",
"image": "/assets/lms/images/course-home.png",
"instructors": [{"instructor": user}],
"published": 1,
}
self.assertFalse(frappe.db.exists("LMS Course", course.name))
self.assertFalse(frappe.db.exists("Course Chapter", chapter.name))
self.assertFalse(frappe.db.exists("Course Lesson", lesson.name))
self.assertFalse(frappe.db.exists("LMS Enrollment", enrollment.name))
self.assertFalse(frappe.db.exists("LMS Course Progress", {"course": course.name}))
self.assertFalse(frappe.db.exists("Chapter Reference", {"parent": course.name}))
self.assertFalse(frappe.db.exists("Lesson Reference", {"parent": chapter.name}))
if additional_filters:
filters.update(additional_filters)
doc = frappe.new_doc("LMS Course")
doc.update(filters)
doc.save()
return doc
def create_evaluator():
if not frappe.db.exists("Course Evaluator", "evaluator@example.com"):
new_user("Evaluator", "evaluator@example.com")
frappe.get_doc({"doctype": "Course Evaluator", "evaluator": "evaluator@example.com"}).save(
ignore_permissions=True
)
# remove from cleanup_items list since delete_course already deleted them
self.cleanup_items.remove(("LMS Course", course.name))
self.cleanup_items.remove(("LMS Enrollment", enrollment.name))
self.cleanup_items.remove(("LMS Course Progress", progress.name))
self.cleanup_items.remove(("Chapter Reference", chapter_ref.name))
self.cleanup_items.remove(("Lesson Reference", lesson_ref.name))
self.cleanup_items.remove(("Course Chapter", chapter.name))
self.cleanup_items.remove(("Course Lesson", lesson.name))

View File

@@ -7,15 +7,3 @@ from frappe.model.document import Document
class LMSCourseInterest(Document):
pass
@frappe.whitelist()
def capture_interest(course):
data = {
"doctype": "LMS Course Interest",
"course": course,
"user": frappe.session.user,
}
if not frappe.db.exists(data):
frappe.get_doc(data).save(ignore_permissions=True)
return "OK"

View File

@@ -101,8 +101,8 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-20 22:25:36.829929",
"modified_by": "Administrator",
"modified": "2026-01-28 18:10:51.370989",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Course Progress",
"owner": "Administrator",
@@ -123,13 +123,39 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],

View File

@@ -38,10 +38,11 @@
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-21 15:25:16.744558",
"modified_by": "Administrator",
"modified": "2026-01-29 16:10:47.787285",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Course Review",
"owner": "Administrator",
@@ -60,7 +61,6 @@
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
@@ -69,12 +69,49 @@
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Moderator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "course",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "course",
"track_changes": 1
}
}

View File

@@ -20,16 +20,3 @@ class LMSCourseReview(Document):
def validate_if_already_reviewed(self):
if frappe.db.exists("LMS Course Review", {"course": self.course, "owner": self.owner}):
frappe.throw(_("You have already reviewed this course"))
@frappe.whitelist()
def submit_review(rating, review, course):
out_of_ratings = frappe.db.get_all(
"DocField", {"parent": "LMS Course Review", "fieldtype": "Rating"}, ["options"]
)
out_of_ratings = (len(out_of_ratings) and out_of_ratings[0].options) or 5
rating = cint(rating) / out_of_ratings
frappe.get_doc(
{"doctype": "LMS Course Review", "rating": rating, "review": review, "course": course}
).save(ignore_permissions=True)
return "OK"

View File

@@ -141,7 +141,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-23 11:11:23.908797",
"modified": "2026-01-28 18:07:58.529033",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Enrollment",
@@ -163,6 +163,7 @@
"create": 1,
"email": 1,
"export": 1,
"if_owner": 1,
"print": 1,
"read": 1,
"report": 1,
@@ -183,6 +184,18 @@
"select": 1,
"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
}
],
"quick_entry": 1,

View File

@@ -9,11 +9,24 @@ from frappe.utils import ceil
class LMSEnrollment(Document):
def before_insert(self):
self.validate_duplicate_enrollment()
self.validate_course_enrollment_eligibility()
def on_update(self):
update_program_progress(self.member)
def validate_duplicate_enrollment(self):
existing_enrollment = frappe.db.exists(
"LMS Enrollment",
{
"course": self.course,
"member": self.member,
},
)
if existing_enrollment:
frappe.throw(_("Student is already enrolled in this course."))
def validate_course_enrollment_eligibility(self):
course_details = frappe.db.get_value(
"LMS Course",

View File

@@ -1,7 +0,0 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on("LMS Mentor Request", {
// refresh: function(frm) {
// }
});

View File

@@ -1,87 +0,0 @@
{
"actions": [],
"creation": "2021-04-18 11:48:02.635688",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"member",
"course",
"reviewed_by",
"column_break_3",
"member_name",
"status",
"comments"
],
"fields": [
{
"fieldname": "member",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Member",
"options": "User"
},
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fetch_from": "member.full_name",
"fieldname": "member_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Member Name"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "Pending\nApproved\nRejected\nWithdrawn"
},
{
"fieldname": "reviewed_by",
"fieldtype": "Link",
"label": "Reviewed By",
"options": "User"
},
{
"fieldname": "comments",
"fieldtype": "Small Text",
"label": "Comments"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-05-21 11:49:12.543502",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Mentor Request",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -1,136 +0,0 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
class LMSMentorRequest(Document):
def on_update(self):
if self.has_value_changed("status"):
if self.status == "Approved":
self.create_course_mentor_mapping()
if self.status != "Pending":
self.send_status_change_email()
def create_course_mentor_mapping(self):
mapping = frappe.get_doc(
{
"doctype": "LMS Course Mentor Mapping",
"mentor": self.member,
"course": self.course,
}
)
mapping.save()
def send_creation_email(self):
email_template = self.get_email_template("mentor_request_creation")
if not email_template:
return
course_details = frappe.db.get_value(
"LMS Course", self.course, ["owner", "slug", "title"], as_dict=True
)
message = frappe.render_template(
email_template.response,
{
"member_name": frappe.db.get_value("User", frappe.session.user, "full_name"),
"course_url": "/lms/courses/" + course_details.slug,
"course": course_details.title,
},
)
email_args = {
"recipients": [frappe.session.user, course_details.owner],
"subject": email_template.subject,
"header": email_template.subject,
"message": message,
}
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
def send_status_change_email(self):
email_template = self.get_email_template("mentor_request_status_update")
if not email_template:
return
course_details = frappe.db.get_value("LMS Course", self.course, ["owner", "title"], as_dict=True)
message = frappe.render_template(
email_template.response,
{
"member_name": self.member_name,
"status": self.status,
"course": course_details.title,
},
)
if self.status == "Approved" or self.status == "Rejected":
email_args = {
"recipients": self.member,
"cc": [course_details.owner, self.reviewed_by],
"subject": email_template.subject,
"header": email_template.subject,
"message": message,
}
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
elif self.status == "Withdrawn":
email_args = {
"recipients": [self.member, course_details.owner],
"subject": email_template.subject,
"header": email_template.subject,
"message": message,
}
frappe.enqueue(method=frappe.sendmail, queue="short", timeout=300, is_async=True, **email_args)
def get_email_template(self, template_name):
template = frappe.db.get_single_value("LMS Settings", template_name)
if template:
return frappe.get_doc("Email Template", template)
@frappe.whitelist()
def has_requested(course):
return frappe.db.count(
"LMS Mentor Request",
filters={
"member": frappe.session.user,
"course": course,
"status": ["in", ("Pending", "Approved")],
},
)
@frappe.whitelist()
def create_request(course):
if not has_requested(course):
request = frappe.get_doc(
{
"doctype": "LMS Mentor Request",
"member": frappe.session.user,
"course": course,
"status": "Pending",
}
)
request.save(ignore_permissions=True)
request.send_creation_email()
return "OK"
else:
return "Already Applied"
@frappe.whitelist()
def cancel_request(course):
request = frappe.get_doc(
"LMS Mentor Request",
{
"member": frappe.session.user,
"course": course,
"status": ["in", ("Pending", "Approved")],
},
)
request.status = "Withdrawn"
request.save(ignore_permissions=True)
return "OK"

View File

@@ -1,9 +0,0 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestLMSMentorRequest(unittest.TestCase):
pass

View File

@@ -7,6 +7,8 @@ from frappe.email.doctype.email_template.email_template import get_email_templat
from frappe.model.document import Document
from frappe.utils import add_days, flt, nowdate
from lms.lms.utils import get_lms_route
class LMSPayment(Document):
pass
@@ -76,7 +78,9 @@ def send_mail(payment):
"title": frappe.db.get_value(
payment.payment_for_document_type, payment.payment_for_document, "title"
),
"link": f"/lms/billing/{ payment.payment_for_document_type.split(' ')[-1].lower() }/{ payment.payment_for_document }",
"link": get_lms_route(
f"billing/{payment.payment_for_document_type.split(' ')[-1].lower()}/{payment.payment_for_document}"
),
}
if custom_template:

View File

@@ -39,6 +39,8 @@ def validate_correct_options(question):
if len(correct_options) > 1:
question.multiple = 1
else:
question.multiple = 0
if not len(correct_options):
frappe.throw(_("At least one option must be correct for this question."))
@@ -91,18 +93,3 @@ def get_correct_options(question):
correct_options.append(field)
return correct_options
@frappe.whitelist()
def get_question_details(question):
if not has_course_instructor_role() or not has_moderator_role():
return
fields = ["question", "type", "name"]
for i in range(1, 5):
fields.append(f"option_{i}")
fields.append(f"is_correct_{i}")
fields.append(f"explanation_{i}")
fields.append(f"possibility_{i}")
return frappe.db.get_value("LMS Question", question, fields, as_dict=1)

View File

@@ -257,20 +257,6 @@ def save_progress_after_quiz(quiz_details, percentage):
save_progress(quiz_details.lesson, quiz_details.course)
@frappe.whitelist()
def get_question_details(question):
if frappe.db.exists("LMS Quiz Question", question):
fields = ["name", "question", "type"]
for num in range(1, 5):
fields.append(f"option_{cstr(num)}")
fields.append(f"is_correct_{cstr(num)}")
fields.append(f"explanation_{cstr(num)}")
fields.append(f"possibility_{cstr(num)}")
return frappe.db.get_value("LMS Quiz Question", question, fields, as_dict=1)
return
@frappe.whitelist()
def check_answer(question, type, answers):
answers = json.loads(answers)

View File

@@ -10,9 +10,7 @@ import frappe
class TestLMSQuiz(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
frappe.get_doc({"doctype": "LMS Quiz", "title": "Test Quiz", "passing_percentage": 90}).save(
ignore_permissions=True
)
frappe.get_doc({"doctype": "LMS Quiz", "title": "Test Quiz", "passing_percentage": 90}).save()
def test_with_multiple_options(self):
question = frappe.new_doc("LMS Question")

View File

@@ -1,3 +1,4 @@
frappe.pages["lms-home"].on_page_load = function (wrapper) {
window.location.href = "/lms/courses";
const lmsPath = frappe.boot.lms_path || "lms";
window.location.href = `/${lmsPath}/courses`;
};

250
lms/lms/test_helpers.py Normal file
View File

@@ -0,0 +1,250 @@
import frappe
from frappe.tests import UnitTestCase
from frappe.utils import add_days, nowdate
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template
class BaseTestUtils(UnitTestCase):
"""
Base class with helper methods for creating test data.
Subclasses should call super().setUp() and super().tearDown().
"""
def setUp(self):
self.cleanup_items = []
def tearDown(self):
for item_type, item_name in reversed(self.cleanup_items):
if frappe.db.exists(item_type, item_name):
try:
frappe.delete_doc(item_type, item_name, force=True)
except Exception:
pass
def _create_user(self, email, first_name, last_name, roles, user_type="Website User"):
if frappe.db.exists("User", email):
return frappe.get_doc("User", email)
user = frappe.new_doc("User")
user.update(
{
"email": email,
"first_name": first_name,
"last_name": last_name,
"user_type": user_type,
"send_welcome_email": False,
}
)
for role in roles:
user.append("roles", {"role": role})
user.save()
self.cleanup_items.append(("User", user.name))
return user
def _create_course(self, title="Utility Course", instructor="frappe@example.com"):
existing = frappe.db.exists("LMS Course", {"title": title})
if existing:
return frappe.get_doc("LMS Course", existing)
course = frappe.new_doc("LMS Course")
course.update(
{
"title": title,
"short_introduction": "A course to test utilities of Frappe Learning",
"description": "This is a detailed description of the Utility Course.",
"tags": "Frappe,Learning,Utility",
"category": "Business",
"published": 1,
"instructors": [{"instructor": instructor}],
}
)
course.save()
self.cleanup_items.append(("LMS Course", course.name))
return course
def _create_chapter(self, title, course):
if not title:
title = f"Course Chapter {frappe.generate_hash()}"
existing = frappe.db.exists("Course Chapter", {"course": course, "title": title})
if existing:
return frappe.get_doc("Course Chapter", existing)
chapter = frappe.new_doc("Course Chapter")
chapter.update(
{
"course": course,
"title": title,
}
)
chapter.save()
self.cleanup_items.append(("Course Chapter", chapter.name))
return chapter
def _create_lesson(self, title, chapter, course):
existing = frappe.db.exists("Course Lesson", {"course": course, "title": title})
if existing:
return frappe.get_doc("Course Lesson", existing)
lesson = frappe.new_doc("Course Lesson")
lesson.update(
{
"course": course,
"chapter": chapter,
"title": title,
"content": '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}',
}
)
lesson.save()
self.cleanup_items.append(("Course Lesson", lesson.name))
return lesson
def _create_lesson_reference(self, chapter, lesson):
lesson_ref = frappe.get_doc(
{
"doctype": "Lesson Reference",
"lesson": lesson,
"parent": chapter,
"parenttype": "Course Chapter",
"parentfield": "lessons",
"idx": 1,
}
)
lesson_ref.insert()
self.cleanup_items.append(("Lesson Reference", lesson_ref.name))
return lesson_ref
def _create_chapter_reference(self, course, chapter, idx=1):
chapter_ref = frappe.get_doc(
{
"doctype": "Chapter Reference",
"chapter": chapter,
"parent": course,
"parenttype": "LMS Course",
"parentfield": "chapters",
"idx": idx,
}
)
chapter_ref.insert()
self.cleanup_items.append(("Chapter Reference", chapter_ref.name))
return chapter_ref
def _create_enrollment(self, member, course):
existing = frappe.db.exists("LMS Enrollment", {"course": course, "member": member})
if existing:
return frappe.get_doc("LMS Enrollment", existing)
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.update({"member": member, "course": course})
enrollment.insert()
self.cleanup_items.append(("LMS Enrollment", enrollment.name))
return enrollment
def _create_progress(self, member, course, lesson):
progress = frappe.new_doc("LMS Course Progress")
progress.update({"member": member, "course": course, "lesson": lesson})
progress.insert()
self.cleanup_items.append(("LMS Course Progress", progress.name))
return progress
def _create_evaluator(self, evaluator_email="frappe@example.com"):
if frappe.db.exists("Course Evaluator", evaluator_email):
return frappe.get_doc("Course Evaluator", evaluator_email)
evaluator = frappe.new_doc("Course Evaluator")
evaluator.update(
{
"evaluator": evaluator_email,
"schedule": [
{"day": "Monday", "start_time": "10:00", "end_time": "12:00"},
{"day": "Wednesday", "start_time": "14:00", "end_time": "16:00"},
],
"unavailable_from": add_days(nowdate(), 5),
"unavailable_to": add_days(nowdate(), 12),
}
)
evaluator.save()
self.cleanup_items.append(("Course Evaluator", evaluator.name))
return evaluator
def _create_batch(
self,
course,
instructor="frappe@example.com",
title="Utility Training",
evaluator="frappe@example.com",
):
existing = frappe.db.exists("LMS Batch", {"title": title})
if existing:
return frappe.get_doc("LMS Batch", existing)
batch = frappe.new_doc("LMS Batch")
batch.update(
{
"title": title,
"start_date": nowdate(),
"end_date": add_days(nowdate(), 10),
"start_time": "09:00:00",
"end_time": "11:00:00",
"timezone": "Asia/Kolkata",
"published": 1,
"description": "Batch for Utility Course Training",
"batch_details": "This batch is created to test utility functions.",
"evaluation_end_date": add_days(nowdate(), 120),
"instructors": [{"instructor": instructor}],
"courses": [{"course": course, "evaluator": evaluator}],
}
)
batch.save()
self.cleanup_items.append(("LMS Batch", batch.name))
return batch
def _create_batch_enrollment(self, member, batch):
existing = frappe.db.exists("LMS Batch Enrollment", {"batch": batch, "member": member})
if existing:
return frappe.get_doc("LMS Batch Enrollment", existing)
batch_enrollment = frappe.new_doc("LMS Batch Enrollment")
batch_enrollment.update({"member": member, "batch": batch})
batch_enrollment.insert()
self.cleanup_items.append(("LMS Batch Enrollment", batch_enrollment.name))
return batch_enrollment
def _add_rating(self, course, member, rating, review_text):
existing = frappe.db.exists("LMS Course Review", {"course": course, "owner": member})
if existing:
return frappe.get_doc("LMS Course Review", existing)
frappe.session.user = member
review_doc = frappe.new_doc("LMS Course Review")
review_doc.update(
{
"course": course,
"rating": rating,
"review": review_text,
}
)
review_doc.save()
self.cleanup_items.append(("LMS Course Review", review_doc.name))
frappe.session.user = "Administrator"
return review_doc
def _create_certificate(self, course, member):
existing = frappe.db.exists("LMS Certificate", {"course": course, "member": member})
if existing:
return frappe.get_doc("LMS Certificate", existing)
certificate = frappe.new_doc("LMS Certificate")
certificate.update(
{
"course": course,
"member": member,
"issue_date": frappe.utils.nowdate(),
"template": get_default_certificate_template(),
"published": 1,
}
)
certificate.save()
self.cleanup_items.append(("LMS Certificate", certificate.name))
return certificate

View File

@@ -1,21 +1,25 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
import frappe
from frappe.tests import UnitTestCase
from frappe.utils import add_days, nowdate
from frappe.utils import get_time, getdate, to_timedelta
from lms.lms.api import get_certified_participants
from lms.lms.doctype.lms_certificate.lms_certificate import get_default_certificate_template, is_certified
from .utils import (
from lms.lms.doctype.lms_certificate.lms_certificate import is_certified
from lms.lms.test_helpers import BaseTestUtils
from lms.lms.utils import (
get_average_rating,
get_batch_details,
get_chapters,
get_course_details,
get_evaluator,
get_instructors,
get_lesson_index,
get_lesson_url,
get_lessons,
get_lms_route,
get_membership,
get_reviews,
get_tags,
has_course_instructor_role,
has_evaluator_role,
has_moderator_role,
@@ -25,132 +29,52 @@ from .utils import (
)
class TestUtils(UnitTestCase):
class TestLMSUtils(BaseTestUtils):
def setUp(self):
self.student1 = self.create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"])
self.student2 = self.create_user("student2@example.com", "John", "Doe", ["LMS Student"])
self.admin = self.create_user(
super().setUp()
self.student1 = self._create_user("student1@example.com", "Ashley", "Smith", ["LMS Student"])
self.student2 = self._create_user("student2@example.com", "John", "Doe", ["LMS Student"])
self.admin = self._create_user(
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
)
self.course = self._create_course()
self._setup_chapters_and_lessons()
self.course = self.create_a_course()
self.add_chapters()
self.add_lessons()
self._create_enrollment(self.student1.email, self.course.name)
self._create_enrollment(self.student2.email, self.course.name)
self.add_enrollment(self.course.name, self.student1.email)
self.add_enrollment(self.course.name, self.student2.email)
self._add_rating(self.course.name, self.student1.email, 0.8, "Good course")
self._add_rating(self.course.name, self.student2.email, 1, "Excellent course")
self.add_rating(self.course.name, self.student1.email, 0.8, "Good course")
self.add_rating(self.course.name, self.student2.email, 1, "Excellent course")
self._create_certificate(self.course.name, self.student1.email)
self.create_certificate(self.course.name, self.student1.email)
self.evaluator = self._create_evaluator()
self.batch = self._create_batch(self.course.name)
self._create_batch_enrollment(self.student1.email, self.batch.name)
self._create_batch_enrollment(self.student2.email, self.batch.name)
self.evaluator = self.create_evaluator()
self.batch = self.create_a_batch()
def create_a_course(self):
existing_course = frappe.db.exists("LMS Course", {"title": "Utility Course"})
if existing_course:
return frappe.get_doc("LMS Course", existing_course)
course = frappe.new_doc("LMS Course")
course.title = "Utility Course"
course.short_introduction = "A course to test utilities of Frappe Learning"
course.description = "This is a detailed description of the Utility Course."
course.tags = "Frappe,Learning,Utility"
course.published = 1
course.append("instructors", {"instructor": "frappe@example.com"})
course.save()
return course
def add_chapters(self):
def _setup_chapters_and_lessons(self):
chapters = []
for i in range(1, 4):
chapter = frappe.new_doc("Course Chapter")
chapter.course = self.course.name
chapter.title = f"Chapter {i}"
chapter.save()
chapter = self._create_chapter(f"Chapter {i}", self.course.name)
chapters.append(chapter)
self.course.reload()
for chapter in chapters:
self.course.append("chapters", {"chapter": chapter.name})
if not any(c.chapter == chapter.name for c in self.course.chapters):
self.course.append("chapters", {"chapter": chapter.name})
self.course.save()
def add_lessons(self):
for chapter in self.course.chapters:
chapterDoc = frappe.get_doc("Course Chapter", chapter.chapter)
lessons = []
for chapter_ref in self.course.chapters:
chapter_doc = frappe.get_doc("Course Chapter", chapter_ref.chapter)
for j in range(1, 3):
lesson = frappe.new_doc("Course Lesson")
lesson.course = self.course.name
lesson.chapter = chapter.chapter
lesson.title = f"Lesson {j} of {chapter.chapter}"
content = '{"time":1765194986690,"blocks":[{"id":"dkLzbW14ds","type":"markdown","data":{"text":"This is a simple content for the current lesson."}},{"id":"KBwuWPc8rV","type":"markdown","data":{"text":""}}],"version":"2.29.0"}'
lesson.content = content
lesson.save()
lessons.append(lesson)
lesson_title = f"Lesson {j} of {chapter_ref.chapter}"
lesson = self._create_lesson(lesson_title, chapter_ref.chapter, self.course.name)
for lesson in lessons:
chapterDoc.append("lessons", {"lesson": lesson.name})
chapterDoc.save()
def create_evaluator(self):
if frappe.db.exists("Course Evaluator", "frappe@example.com"):
return frappe.get_doc("Course Evaluator", "frappe@example.com")
evaluator = frappe.new_doc("Course Evaluator")
evaluator.evaluator = "frappe@example.com"
evaluator.append("schedule", {"day": "Monday", "start_time": "10:00", "end_time": "12:00"})
evaluator.append("schedule", {"day": "Wednesday", "start_time": "14:00", "end_time": "16:00"})
evaluator.unavailable_from = add_days(nowdate(), 5)
evaluator.unavailable_to = add_days(nowdate(), 12)
evaluator.save()
return evaluator
def create_a_batch(self):
existing_batch = frappe.db.exists("LMS Batch", {"title": "Utility Training"})
if existing_batch:
return frappe.get_doc("LMS Batch", existing_batch)
batch = frappe.new_doc("LMS Batch")
batch.title = "Utility Training"
batch.start_date = nowdate()
batch.end_date = add_days(batch.start_date, 10)
batch.start_time = "09:00:00"
batch.end_time = "11:00:00"
batch.timezone = "Asia/Kolkata"
batch.description = "Batch for Utility Course Training"
batch.batch_details = "This batch is created to test utility functions."
batch.evaluation_end_date = add_days(nowdate(), 120)
batch.append("instructors", {"instructor": "frappe@example.com"})
batch.append("courses", {"course": self.course.name, "evaluator": "frappe@example.com"})
batch.save()
return batch
def create_user(self, email, first_name, last_name, roles):
if frappe.db.exists("User", email):
return frappe.get_doc("User", email)
else:
user = frappe.new_doc("User")
user.email = email
user.first_name = first_name
user.last_name = last_name
user.user_type = "Website User"
for role in roles:
user.append("roles", {"role": role})
user.save()
return user
def create_certificate(self, course_name, member):
certificate = frappe.new_doc("LMS Certificate")
certificate.course = course_name
certificate.member = member
certificate.issue_date = frappe.utils.nowdate()
certificate.template = get_default_certificate_template()
certificate.published = 1
certificate.save()
return certificate
if not any(l.lesson == lesson.name for l in chapter_doc.lessons):
chapter_doc.append("lessons", {"lesson": lesson.name})
chapter_doc.save()
def test_simple_slugs(self):
self.assertEqual(slugify("hello-world"), "hello-world")
@@ -161,12 +85,6 @@ class TestUtils(UnitTestCase):
self.assertEqual(slugify("Hello World", ["hello-world"]), "hello-world-2")
self.assertEqual(slugify("Hello World", ["hello-world", "hello-world-2"]), "hello-world-3")
def add_enrollment(self, course, member):
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.course = course
enrollment.member = member
enrollment.save()
def test_get_membership(self):
membership = get_membership(self.course.name, self.student1.email)
self.assertIsNotNone(membership)
@@ -185,18 +103,6 @@ class TestUtils(UnitTestCase):
all_lessons = frappe.db.count("Course Lesson", {"course": self.course.name})
self.assertEqual(len(lessons), all_lessons)
for chapter in self.course.chapters:
chapter_lessons = [lesson for lesson in lessons if lesson.chapter == chapter.chapter]
self.assertEqual(len(chapter_lessons), 2)
for j, lesson in enumerate(chapter_lessons, start=1):
self.assertEqual(lesson.title, f"Lesson {j} of {chapter.chapter}")
self.assertEqual(lesson.number, f"{chapter.idx}-{j}")
def test_get_tags(self):
tags = get_tags(self.course.name)
expected_tags = ["Frappe", "Learning", "Utility"]
self.assertEqual(set(tags), set(expected_tags))
def test_get_instructors(self):
instructors = get_instructors("LMS Course", self.course.name)
self.assertEqual(len(instructors), len(self.course.instructors))
@@ -206,27 +112,10 @@ class TestUtils(UnitTestCase):
average_rating = get_average_rating(self.course.name)
self.assertEqual(average_rating, 4.5)
def add_rating(self, course_name, member, rating, review):
frappe.session.user = member
review = frappe.new_doc("LMS Course Review")
review.course = course_name
review.rating = rating
review.review = review
review.save()
frappe.session.user = "Administrator"
def test_get_reviews(self):
reviews = get_reviews(self.course.name)
self.assertEqual(len(reviews), 2)
for review in reviews:
if review.rating == 0.8:
self.assertEqual(review.member, self.student1.email)
self.assertEqual(review.review, "Good course")
elif review.rating == 1:
self.assertEqual(review.member, self.student2.email)
self.assertEqual(review.review, "Excellent course")
def test_get_lesson_index(self):
lessons = get_lessons(self.course.name)
for lesson in lessons:
@@ -235,7 +124,7 @@ class TestUtils(UnitTestCase):
def test_get_lesson_url(self):
lessons = get_lessons(self.course.name)
for lesson in lessons:
expected_url = f"/lms/courses/{self.course.name}/learn/{lesson.number}"
expected_url = get_lms_route(f"courses/{self.course.name}/learn/{lesson.number}")
self.assertEqual(get_lesson_url(self.course.name, lesson.number), expected_url)
def test_is_instructor(self):
@@ -298,30 +187,44 @@ class TestUtils(UnitTestCase):
frappe.db.set_value("User", self.student1.email, "open_to", "")
def test_rating_validation(self):
student3 = self.create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
student3 = self._create_user("student3@example.com", "Emily", "Cooper", ["LMS Student"])
with self.assertRaises(frappe.exceptions.ValidationError):
self.add_rating(self.course.name, student3.email, -0.5, "Bad course")
frappe.session.user = student3.email
review = frappe.new_doc("LMS Course Review")
review.course = self.course.name
review.rating = -0.5
review.review = "Bad course"
review.save()
frappe.session.user = "Administrator"
frappe.delete_doc("User", student3.email)
def test_get_evaluator(self):
evaluator_email = get_evaluator(self.course.name, self.batch.name)
self.assertEqual(evaluator_email, self.evaluator.evaluator)
def tearDown(self):
if frappe.db.exists("LMS Batch", self.batch.name):
frappe.delete_doc("LMS Batch", self.batch.name)
def test_get_course_details(self):
course_details = get_course_details(self.course.name)
self.assertEqual(course_details.name, self.course.name)
self.assertEqual(course_details.title, self.course.title)
self.assertEqual(course_details.category, self.course.category)
self.assertEqual(course_details.description, self.course.description)
self.assertEqual(course_details.short_introduction, self.course.short_introduction)
self.assertEqual(course_details.tags, self.course.tags)
self.assertEqual(course_details.published, 1)
self.assertEqual(len(course_details.instructors), len(self.course.instructors))
if frappe.db.exists("LMS Course", self.course.name):
frappe.db.delete("LMS Certificate", {"course": self.course.name})
frappe.db.delete("LMS Enrollment", {"course": self.course.name})
frappe.db.delete("LMS Course Review", {"course": self.course.name})
frappe.db.delete("Course Lesson", {"course": self.course.name})
frappe.db.delete("Course Chapter", {"course": self.course.name})
frappe.db.delete("Course Instructor", {"parent": self.course.name})
frappe.delete_doc("LMS Course", self.course.name)
frappe.delete_doc("Course Evaluator", self.evaluator.name)
frappe.delete_doc("User", "student1@example.com")
frappe.delete_doc("User", "student2@example.com")
frappe.delete_doc("User", "frappe@example.com")
def test_get_batch_details(self):
batch_details = get_batch_details(self.batch.name)
self.assertEqual(batch_details.name, self.batch.name)
self.assertEqual(batch_details.title, self.batch.title)
self.assertEqual(batch_details.start_date, getdate(self.batch.start_date))
self.assertEqual(batch_details.end_date, getdate(self.batch.end_date))
self.assertEqual(batch_details.start_time, to_timedelta(self.batch.start_time))
self.assertEqual(batch_details.end_time, to_timedelta(self.batch.end_time))
self.assertEqual(batch_details.timezone, self.batch.timezone)
self.assertEqual(batch_details.published, 1)
self.assertEqual(batch_details.description, self.batch.description)
self.assertEqual(batch_details.batch_details, self.batch.batch_details)
self.assertEqual(len(batch_details.courses), len(self.batch.courses))
self.assertEqual(batch_details.evaluation_end_date, getdate(self.batch.evaluation_end_date))
self.assertEqual(len(batch_details.instructors), len(self.batch.instructors))
self.assertEqual(len(batch_details.students), 2)

View File

@@ -4,7 +4,7 @@ from frappe.model.naming import append_number_if_name_exists
from frappe.utils import escape_html, random_string
from frappe.website.utils import cleanup_page_name, is_signup_disabled
from lms.lms.utils import get_country_code
from lms.lms.utils import get_country_code, get_lms_route
def validate_username_duplicates(doc, method):
@@ -88,4 +88,4 @@ def set_country_from_ip(login_manager=None, user=None):
def on_login(login_manager):
default_app = frappe.db.get_single_value("System Settings", "default_app")
if default_app == "lms":
frappe.local.response["home_page"] = "/lms"
frappe.local.response["home_page"] = get_lms_route()

View File

@@ -32,6 +32,22 @@ from lms.lms.md import find_macros
RE_SLUG_NOTALLOWED = re.compile("[^a-z0-9]+")
def get_lms_path():
path = frappe.conf.get("lms_path") or "lms"
return path.strip("/")
def get_lms_route(path=""):
base = f"/{get_lms_path()}"
if not path:
return base
return f"{base}/{path.lstrip('/')}"
def extend_bootinfo(bootinfo):
bootinfo["lms_path"] = get_lms_path()
def slugify(title, used_slugs=None):
"""Converts title to a slug.
@@ -202,13 +218,6 @@ def get_lesson_icon(body, content):
return "icon-list"
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_tags(course):
tags = frappe.db.get_value("LMS Course", course, "tags")
return tags.split(",") if tags else []
def get_instructors(doctype, docname):
instructor_details = []
instructors = frappe.get_all(
@@ -254,7 +263,7 @@ def get_reviews(course):
for review in reviews:
review.rating = review.rating * out_of_ratings
review.owner_details = frappe.db.get_value(
"User", review.owner, ["name", "username", "full_name", "user_image"], as_dict=True
"User", review.owner, ["username", "full_name", "user_image"], as_dict=True
)
review.creation = pretty_date(review.creation)
@@ -277,7 +286,7 @@ def get_lesson_index(lesson_name):
def get_lesson_url(course, lesson_number):
if not lesson_number:
return
return f"/lms/courses/{course}/learn/{lesson_number}"
return get_lms_route(f"courses/{course}/learn/{lesson_number}")
def get_progress(course, lesson, member=None):
@@ -320,17 +329,6 @@ def has_course_instructor_role(member=None):
)
def can_create_batches(member=None):
if not member:
member = frappe.session.user
if has_moderator_role(member):
return True
if has_evaluator_role(member):
return True
return False
def has_moderator_role(member=None):
return frappe.db.get_value(
"Has Role",
@@ -421,7 +419,7 @@ def get_batch_details_for_notification(topic):
users = []
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
subject = _("New comment in batch {0}").format(batch_title)
link = f"/lms/batches/{topic.reference_docname}#discussions"
link = get_lms_route(f"batches/{topic.reference_docname}#discussions")
instructors = frappe.db.get_all(
"Course Instructor",
{"parenttype": "LMS Batch", "parent": topic.reference_docname},
@@ -475,7 +473,7 @@ def notify_mentions_on_portal(doc, topic):
subject = _("{0} mentioned you in a comment in {1}").format(
frappe.bold(from_user_name), frappe.bold(batch_title)
)
link = f"/lms/batches/{topic.reference_docname}#discussions"
link = get_lms_route(f"batches/{topic.reference_docname}#discussions")
for user in mentions:
notification = frappe._dict(
@@ -652,48 +650,6 @@ def get_evaluator(course, batch=None):
return evaluator
@frappe.whitelist()
def get_upcoming_evals(courses=None, batch=None):
if frappe.session.user == "Guest":
return []
if not courses:
courses = []
filters = {
"member": frappe.session.user,
"date": [">=", frappe.utils.nowdate()],
"status": "Upcoming",
}
if len(courses) > 0:
filters["course"] = ["in", courses]
if batch:
filters["batch_name"] = batch
upcoming_evals = frappe.get_all(
"LMS Certificate Request",
filters,
[
"name",
"date",
"start_time",
"course",
"evaluator",
"google_meet_link",
"member",
"member_name",
],
order_by="date",
)
for evals in upcoming_evals:
evals.course_title = frappe.db.get_value("LMS Course", evals.course, "title")
evals.evaluator_name = frappe.db.get_value("User", evals.evaluator, "full_name")
return upcoming_evals
def check_multicurrency(amount, currency, country=None, amount_usd=None):
settings = frappe.get_single("LMS Settings")
show_usd_equivalent = settings.show_usd_equivalent
@@ -759,11 +715,21 @@ def get_current_exchange_rate(source, target="USD"):
return details["rates"][target]
def guest_access_allowed():
allow_guest_access = frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
if frappe.session.user == "Guest" and not allow_guest_access:
return False
return True
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_courses(filters=None, start=0):
"""Returns the list of courses."""
if not guest_access_allowed():
return []
if not filters:
filters = {}
@@ -905,6 +871,13 @@ def get_course_fields():
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_course_details(course):
if not guest_access_allowed():
return {}
is_course_published = frappe.db.get_value("LMS Course", course, "published")
if not is_course_published and not can_modify_course(course):
return {}
fields = get_course_fields()
course_details = frappe.db.get_value(
"LMS Course",
@@ -976,6 +949,10 @@ def get_categorized_courses(courses):
@frappe.whitelist(allow_guest=True)
def get_course_outline(course, progress=False):
"""Returns the course outline."""
if not guest_access_allowed():
return []
outline = []
chapters = frappe.get_all("Chapter Reference", {"parent": course}, ["chapter", "idx"], order_by="idx")
for chapter in chapters:
@@ -1003,6 +980,9 @@ def get_course_outline(course, progress=False):
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_lesson(course, chapter, lesson):
if not guest_access_allowed():
return {}
chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter")
lesson_name = frappe.db.get_value("Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson")
lesson_details = frappe.db.get_value(
@@ -1114,12 +1094,15 @@ def get_neighbour_lesson(course, chapter, lesson):
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_batch_details(batch):
if not guest_access_allowed():
return {}
batch_students = frappe.get_all("LMS Batch Enrollment", {"batch": batch}, pluck="member")
if (
not frappe.db.get_value("LMS Batch", batch, "published")
and not can_create_batches()
and frappe.session.user not in batch_students
):
is_batch_admin = can_modify_batch(batch)
is_batch_published = frappe.db.get_value("LMS Batch", batch, "published")
is_student_enrolled = frappe.session.user in batch_students
if not (is_batch_published or is_batch_admin or is_student_enrolled):
return
batch_details = frappe.db.get_value(
@@ -1164,7 +1147,10 @@ def get_batch_details(batch):
batch_details.courses = frappe.get_all(
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
)
batch_details.students = batch_students
if can_modify_batch(batch):
batch_details.students = batch_students
elif is_student_enrolled:
batch_details.students = [frappe.session.user]
if batch_details.paid_batch and batch_details.start_date >= getdate():
batch_details.amount, batch_details.currency = check_multicurrency(
@@ -1173,7 +1159,7 @@ def get_batch_details(batch):
batch_details.price = fmt_money(batch_details.amount, 0, batch_details.currency)
if batch_details.seat_count:
batch_details.seats_left = batch_details.seat_count - len(batch_details.students)
batch_details.seats_left = batch_details.seat_count - len(batch_students)
return batch_details
@@ -1235,6 +1221,9 @@ def get_question_details(question):
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_batch_courses(batch):
if not guest_access_allowed():
return []
courses = []
course_list = frappe.get_all("Batch Course", {"parent": batch}, ["name", "course"])
@@ -1247,10 +1236,8 @@ def get_batch_courses(batch):
@frappe.whitelist()
def get_assessments(batch, member=None):
if not member:
member = frappe.session.user
def get_assessments(batch):
member = frappe.session.user
assessments = frappe.get_all(
"LMS Assessment",
{"parent": batch},
@@ -1296,7 +1283,7 @@ def get_assignment_details(assessment, member):
assessment.edit_url = f"/assignments/{assessment.assessment_name}"
submission_name = existing_submission if existing_submission else "new-submission"
assessment.url = f"/lms/assignment-submission/{assessment.assessment_name}/{submission_name}"
assessment.url = get_lms_route(f"assignment-submission/{assessment.assessment_name}/{submission_name}")
return assessment
@@ -1358,6 +1345,7 @@ def get_exercise_details(assessment, member):
@frappe.whitelist()
def get_batch_assessment_count(batch):
frappe.only_for(["Moderator", "Batch Evaluator"])
if not frappe.db.exists("LMS Batch", batch):
frappe.throw(_("The specified batch does not exist."))
return frappe.db.count("LMS Assessment", {"parent": batch})
@@ -1368,11 +1356,13 @@ def get_batch_students(filters, offset=0, limit_start=0, limit_page_length=None,
# limit_start and limit_page_length are used for backward compatibility
start = limit_start or offset
page_length = limit_page_length or limit
batch = filters.get("batch")
if not batch:
return []
if not can_modify_batch(batch):
frappe.throw(_("You are not authorized to view the students of this batch."))
students = []
students_list = frappe.get_all(
"LMS Batch Enrollment",
@@ -1474,6 +1464,8 @@ def get_quiz_pass_stats(batch):
@frappe.whitelist()
def get_batch_chart_data(batch):
"""Get completion counts per course and assessment"""
if not can_modify_batch(batch):
frappe.throw(_("You are not authorized to view the chart data of this batch."))
if not frappe.db.exists("LMS Batch", batch):
frappe.throw(_("The specified batch does not exist."))
@@ -1610,8 +1602,27 @@ def has_submitted_assessment(assessment, assessment_type, member=None):
)
def can_access_topic(doctype, docname):
is_student = False
if doctype == "Course Lesson":
course = frappe.db.get_value("Course Lesson", docname, "course")
is_student = frappe.db.exists("LMS Enrollment", {"course": course, "member": frappe.session.user})
if not is_student and not can_modify_course(course):
return False
elif doctype == "LMS Batch":
is_student = frappe.db.exists(
"LMS Batch Enrollment", {"batch": docname, "member": frappe.session.user}
)
if not is_student and not can_modify_batch(docname):
return False
return True
@frappe.whitelist()
def get_discussion_topics(doctype, docname, single_thread):
if not can_access_topic(doctype, docname):
frappe.throw(_("You are not authorized to view the discussion topics for this item."))
if single_thread:
filters = {
"reference_doctype": doctype,
@@ -1653,6 +1664,10 @@ def create_discussion_topic(doctype, docname):
@frappe.whitelist()
def get_discussion_replies(topic):
doctype = frappe.db.get_value("Discussion Topic", topic, "reference_doctype")
if not can_access_topic(doctype, topic):
frappe.throw(_("You are not authorized to view the discussion replies for this topic."))
replies = frappe.get_all(
"Discussion Reply",
{
@@ -1805,6 +1820,7 @@ def calculate_discount_amount(base_amount, coupon):
@frappe.whitelist()
def get_lesson_creation_details(course, chapter, lesson):
frappe.only_for(["Moderator", "Course Creator"])
chapter_name = frappe.db.get_value("Chapter Reference", {"parent": course, "idx": chapter}, "chapter")
lesson_name = frappe.db.get_value("Lesson Reference", {"parent": chapter_name, "idx": lesson}, "lesson")
@@ -1835,7 +1851,7 @@ def get_lesson_creation_details(course, chapter, lesson):
@frappe.whitelist()
def get_roles(name):
frappe.only_for("Moderator")
frappe.only_for(["Moderator", "Batch Evaluator"])
return {
"moderator": has_moderator_role(name),
"course_creator": has_course_instructor_role(name),
@@ -1989,6 +2005,9 @@ def update_certificate_purchase(course, payment_name):
@frappe.whitelist()
def get_programs():
if not guest_access_allowed():
frappe.throw(_("Please login to view programs."))
enrolled_programs = frappe.get_all(
"LMS Program Member", {"member": frappe.session.user}, ["parent as name", "progress"]
)
@@ -2021,6 +2040,9 @@ def get_programs():
@frappe.whitelist()
def get_program_details(program_name):
if not guest_access_allowed():
frappe.throw(_("Please login to view program details."))
program = frappe.db.get_value(
"LMS Program",
program_name,
@@ -2078,9 +2100,6 @@ def enroll_in_program(program):
def validate_program_enrollment(program):
if frappe.session.user == "Guest":
frappe.throw(_("Please login to enroll in the program."))
published = frappe.db.get_value("LMS Program", program, "published")
if not published:
frappe.throw(_("You cannot enroll in an unpublished program."))
@@ -2089,6 +2108,9 @@ def validate_program_enrollment(program):
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_batches(filters=None, start=0, order_by="start_date"):
if not guest_access_allowed():
return []
if not filters:
filters = {}
@@ -2203,6 +2225,9 @@ def get_palette(full_name):
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60 * 60)
def get_related_courses(course):
if not guest_access_allowed():
return []
related_course_details = []
related_courses = frappe.get_all("Related Courses", {"parent": course}, order_by="idx", pluck="course")
@@ -2258,3 +2283,27 @@ def validate_batch_access(batch):
)
if not enrollment_exists:
frappe.throw(_("You do not have access to this batch."))
def can_modify_course(course):
is_instructor = frappe.db.exists(
"Course Instructor",
{"instructor": frappe.session.user, "parent": course, "parenttype": "LMS Course"},
)
if not (has_moderator_role() or is_instructor):
return False
return True
def can_modify_batch(batch):
is_instructor = frappe.db.exists(
"Course Instructor",
{
"instructor": frappe.session.user,
"parent": batch,
"parenttype": "LMS Batch",
},
)
if not (has_moderator_role() or is_instructor):
return False
return True

View File

@@ -11,7 +11,7 @@
{{ widgets.CourseCard(course=course, read_only=False) }}
{% endfor %}
</div>
<a class="d-flex justify-content-center align-items-center mt-12" href="/lms/courses">
<a class="d-flex justify-content-center align-items-center mt-12" href="{{ get_lms_route('courses') }}">
<span>{{ _("Explore More") }}</span>
</a>
</div>

View File

@@ -12,7 +12,7 @@
{% endfor %}
</div>
<a class="d-flex justify-content-center align-items-center mt-12" href="/lms/courses">
<a class="d-flex justify-content-center align-items-center mt-12" href="{{ get_lms_route('courses') }}">
<span>{{ _("Explore More") }}</span>
</a>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% set color = get_palette(member.full_name) %}
<span class="avatar {{ avatar_class }}" title="{{ member.full_name }}">
<a class="button-links" href="/lms/users/{{ member.username }}">
<a class="button-links" href="{{ get_lms_route('users/' ~ member.username) }}">
{% if member.user_image %}
<img class="avatar-frame standard-image" style="object-fit: cover;" src="{{ member.user_image }}"
title="{{ member.full_name }}">

View File

@@ -14,7 +14,7 @@
<div class="course-image {% if not course.image %} default-image {% endif %}"
{% if course.image %} style="background-image: url( {{ course.image | urlencode }} );" {% endif %}>
<div class="course-tags">
{% for tag in get_tags(course.name) %}
{% for tag in frappe.db.get_value("LMS Course", course.name, "tags").split(",") %}
<div class="course-card-pills">{{ tag }}</div>
{% endfor %}
</div>
@@ -93,7 +93,7 @@
</div>
{% endif %}
{% endfor %}
<a class="button-links" href="/lms/users/{{ instructors[0].username }}">
<a class="button-links" href="{{ get_lms_route('users/' ~ instructors[0].username) }}">
<span class="course-instructor">
{% if ins_len == 1 %}
{{ instructors[0].full_name }}
@@ -128,7 +128,7 @@
<a class="stretched-link" href="{{ get_lesson_url(course.name, lesson_index) }}{{ query_parameter }}"></a>
{% else %}
<a class="stretched-link" href="/lms/courses/{{ course.name }}"></a>
<a class="stretched-link" href="{{ get_lms_route('courses/' ~ course.name) }}"></a>
{% endif %}
{% endif %}
</div>

View File

@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Frappe LMS VERSION\n"
"Report-Msgid-Bugs-To: jannat@frappe.io\n"
"POT-Creation-Date: 2026-01-16 16:04+0000\n"
"PO-Revision-Date: 2026-01-16 16:04+0000\n"
"POT-Creation-Date: 2026-01-23 16:05+0000\n"
"PO-Revision-Date: 2026-01-23 16:05+0000\n"
"Last-Translator: jannat@frappe.io\n"
"Language-Team: jannat@frappe.io\n"
"MIME-Version: 1.0\n"
@@ -317,7 +317,7 @@ msgid "Administrator"
msgstr ""
#. Name of a role
#: frontend/src/pages/Batches.vue:319 lms/lms/doctype/lms_badge/lms_badge.json
#: frontend/src/pages/Batches.vue:321 lms/lms/doctype/lms_badge/lms_badge.json
msgid "All"
msgstr ""
@@ -484,7 +484,7 @@ msgstr ""
msgid "Apps"
msgstr ""
#: frontend/src/pages/Batches.vue:329
#: frontend/src/pages/Batches.vue:331
msgid "Archived"
msgstr ""
@@ -751,7 +751,7 @@ msgstr ""
#. Label of the batch_name (Link) field in DocType 'LMS Certificate'
#. Label of the batch_name (Link) field in DocType 'LMS Certificate Request'
#. Label of the batch_name (Link) field in DocType 'LMS Live Class'
#: frontend/src/components/Modals/Event.vue:32
#: frontend/src/components/Modals/Event.vue:35
#: frontend/src/components/Settings/BadgeForm.vue:195
#: frontend/src/components/Settings/Badges.vue:200
#: frontend/src/components/Settings/Transactions/TransactionDetails.vue:125
@@ -876,7 +876,7 @@ msgid "Batch:"
msgstr ""
#. Label of the batches (Check) field in DocType 'LMS Settings'
#: frontend/src/pages/Batches.vue:350 frontend/src/pages/Batches.vue:357
#: frontend/src/pages/Batches.vue:352 frontend/src/pages/Batches.vue:359
#: lms/lms/doctype/lms_settings/lms_settings.json lms/www/lms.py:121
msgid "Batches"
msgstr ""
@@ -1021,7 +1021,7 @@ msgstr ""
msgid "Certificate of Completion"
msgstr ""
#: frontend/src/components/Modals/Event.vue:347
#: frontend/src/components/Modals/Event.vue:353
msgid "Certificate saved successfully"
msgstr ""
@@ -1041,7 +1041,7 @@ msgstr ""
#. Label of a chart in the LMS Workspace
#. Label of a Card Break in the LMS Workspace
#. Label of a Link in the LMS Workspace
#: frontend/src/components/Modals/Event.vue:411
#: frontend/src/components/Modals/Event.vue:427
#: frontend/src/components/Sidebar/AppSidebar.vue:550
#: frontend/src/pages/BatchForm.vue:69 frontend/src/pages/Batches.vue:100
#: frontend/src/pages/CourseCertification.vue:10
@@ -1769,7 +1769,7 @@ msgstr ""
#: frontend/src/components/Modals/BatchStudentProgress.vue:95
#: frontend/src/pages/BatchDetail.vue:44
#: frontend/src/pages/CourseCertification.vue:127
#: frontend/src/pages/Courses.vue:365 frontend/src/pages/Courses.vue:372
#: frontend/src/pages/Courses.vue:364 frontend/src/pages/Courses.vue:371
#: frontend/src/pages/Programs/ProgramForm.vue:49
#: frontend/src/pages/Programs/Programs.vue:35
#: lms/lms/doctype/lms_batch/lms_batch.json
@@ -1876,7 +1876,7 @@ msgstr ""
msgid "Create your first quiz"
msgstr ""
#: frontend/src/pages/Assignments.vue:178 frontend/src/pages/Courses.vue:355
#: frontend/src/pages/Assignments.vue:178 frontend/src/pages/Courses.vue:354
msgid "Created"
msgstr ""
@@ -1952,7 +1952,7 @@ msgstr ""
#. Label of the date (Date) field in DocType 'LMS Certificate Request'
#. Label of the date (Date) field in DocType 'LMS Live Class'
#. Label of the date (Date) field in DocType 'Scheduled Flow'
#: frontend/src/components/Modals/Event.vue:40
#: frontend/src/components/Modals/Event.vue:46
#: frontend/src/components/Modals/LiveClassModal.vue:29
#: lms/lms/doctype/lms_batch_timetable/lms_batch_timetable.json
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
@@ -2239,7 +2239,7 @@ msgstr ""
msgid "Edit Email Template"
msgstr ""
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:8
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:13
msgid "Edit Payment Gateway"
msgstr ""
@@ -2422,7 +2422,7 @@ msgstr ""
msgid "Enroll Now"
msgstr ""
#: frontend/src/pages/Batches.vue:332 frontend/src/pages/Courses.vue:358
#: frontend/src/pages/Batches.vue:334 frontend/src/pages/Courses.vue:357
#: frontend/src/pages/Programs/StudentPrograms.vue:96
msgid "Enrolled"
msgstr ""
@@ -2524,7 +2524,7 @@ msgstr ""
#. Label of a Link in the LMS Workspace
#. Label of a shortcut in the LMS Workspace
#: frontend/src/components/Modals/Event.vue:404 lms/lms/workspace/lms/lms.json
#: frontend/src/components/Modals/Event.vue:420 lms/lms/workspace/lms/lms.json
msgid "Evaluation"
msgstr ""
@@ -2549,7 +2549,7 @@ msgstr ""
msgid "Evaluation end date cannot be less than the batch end date."
msgstr ""
#: frontend/src/components/Modals/Event.vue:286
#: frontend/src/components/Modals/Event.vue:292
msgid "Evaluation saved successfully"
msgstr ""
@@ -2662,7 +2662,7 @@ msgstr ""
#. Label of the expiry_date (Date) field in DocType 'LMS Certificate'
#: frontend/src/components/Modals/BulkCertificates.vue:33
#: frontend/src/components/Modals/Event.vue:144
#: frontend/src/components/Modals/Event.vue:150
#: lms/lms/doctype/lms_certificate/lms_certificate.json
msgid "Expiry Date"
msgstr ""
@@ -2693,7 +2693,7 @@ msgstr ""
#. Submission'
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate
#. Evaluation'
#: frontend/src/components/Modals/Event.vue:396
#: frontend/src/components/Modals/Event.vue:412
#: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
msgid "Fail"
@@ -3207,7 +3207,7 @@ msgstr ""
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate
#. Evaluation'
#. Option for the 'Status' (Select) field in DocType 'LMS Course'
#: frontend/src/components/Modals/Event.vue:388
#: frontend/src/components/Modals/Event.vue:404
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
#: lms/lms/doctype/lms_course/lms_course.json
msgid "In Progress"
@@ -3351,7 +3351,7 @@ msgstr ""
#. Label of the issue_date (Date) field in DocType 'Certification'
#. Label of the issue_date (Date) field in DocType 'LMS Certificate'
#: frontend/src/components/Modals/BulkCertificates.vue:28
#: frontend/src/components/Modals/Event.vue:138
#: frontend/src/components/Modals/Event.vue:144
#: lms/lms/doctype/certification/certification.json
#: lms/lms/doctype/lms_certificate/lms_certificate.json
msgid "Issue Date"
@@ -3454,7 +3454,7 @@ msgstr ""
msgid "Join Call"
msgstr ""
#: frontend/src/components/Modals/Event.vue:78
#: frontend/src/components/Modals/Event.vue:84
msgid "Join Meeting"
msgstr ""
@@ -3894,7 +3894,7 @@ msgstr ""
msgid "LinkedIn ID"
msgstr ""
#: frontend/src/pages/Courses.vue:341
#: frontend/src/pages/Courses.vue:340
msgid "Live"
msgstr ""
@@ -4427,7 +4427,7 @@ msgstr ""
#: frontend/src/components/Settings/Members.vue:17
#: frontend/src/components/Settings/PaymentGateways.vue:16
#: frontend/src/components/Settings/ZoomSettings.vue:17
#: frontend/src/pages/Courses.vue:344
#: frontend/src/pages/Courses.vue:343
#: frontend/src/pages/Programs/Programs.vue:10
#: lms/lms/doctype/lms_badge/lms_badge.json
msgid "New"
@@ -4459,7 +4459,7 @@ msgstr ""
msgid "New Job Applicant"
msgstr ""
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:7
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:12
msgid "New Payment Gateway"
msgstr ""
@@ -4776,12 +4776,7 @@ msgstr ""
#: frontend/src/components/UserAvatar.vue:11
#: frontend/src/pages/CertifiedParticipants.vue:46
#: frontend/src/pages/Profile.vue:69
msgid "Open to Opportunities"
msgstr ""
#. Option for the 'Open to' (Select) field in DocType 'User'
#: lms/fixtures/custom_field.json
msgid "Opportunities"
msgid "Open to Work"
msgstr ""
#. Label of the option (Data) field in DocType 'LMS Option'
@@ -4922,7 +4917,7 @@ msgstr ""
#. Submission'
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate
#. Evaluation'
#: frontend/src/components/Modals/Event.vue:392
#: frontend/src/components/Modals/Event.vue:408
#: lms/lms/doctype/lms_assignment_submission/lms_assignment_submission.json
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
msgid "Pass"
@@ -5052,7 +5047,7 @@ msgstr ""
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate
#. Evaluation'
#. Option for the 'Status' (Select) field in DocType 'LMS Mentor Request'
#: frontend/src/components/Modals/Event.vue:384
#: frontend/src/components/Modals/Event.vue:400
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
#: lms/lms/doctype/lms_mentor_request/lms_mentor_request.json
msgid "Pending"
@@ -5192,7 +5187,7 @@ msgstr ""
msgid "Please login to continue with payment."
msgstr ""
#: lms/lms/utils.py:2081
#: lms/lms/utils.py:2082
msgid "Please login to enroll in the program."
msgstr ""
@@ -5523,7 +5518,7 @@ msgstr ""
#. Label of the published (Check) field in DocType 'LMS Course'
#. Label of the published (Check) field in DocType 'LMS Program'
#: frontend/src/components/Modals/BulkCertificates.vue:51
#: frontend/src/components/Modals/Event.vue:122
#: frontend/src/components/Modals/Event.vue:128
#: frontend/src/pages/BatchForm.vue:59 frontend/src/pages/CourseForm.vue:105
#: frontend/src/pages/Programs/ProgramForm.vue:33
#: frontend/src/pages/Programs/StudentPrograms.vue:100
@@ -5691,7 +5686,7 @@ msgstr ""
#. Label of the rating (Data) field in DocType 'LMS Course'
#. Label of the rating (Rating) field in DocType 'LMS Course Review'
#: frontend/src/components/CourseCardOverlay.vue:147
#: frontend/src/components/Modals/Event.vue:92
#: frontend/src/components/Modals/Event.vue:98
#: frontend/src/components/Modals/ReviewModal.vue:18
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
#: lms/lms/doctype/lms_course/lms_course.json
@@ -5927,14 +5922,14 @@ msgstr ""
#: frontend/src/components/Modals/AssignmentForm.vue:65
#: frontend/src/components/Modals/EditProfile.vue:121
#: frontend/src/components/Modals/EmailTemplateModal.vue:12
#: frontend/src/components/Modals/Event.vue:115
#: frontend/src/components/Modals/Event.vue:151
#: frontend/src/components/Modals/Event.vue:121
#: frontend/src/components/Modals/Event.vue:157
#: frontend/src/components/Modals/Question.vue:112
#: frontend/src/components/Modals/ZoomAccountModal.vue:10
#: frontend/src/components/Settings/BadgeAssignmentForm.vue:12
#: frontend/src/components/Settings/BadgeForm.vue:78
#: frontend/src/components/Settings/Coupons/CouponDetails.vue:78
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:38
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:41
#: frontend/src/components/Settings/Transactions/TransactionDetails.vue:129
#: frontend/src/pages/BatchForm.vue:14 frontend/src/pages/CourseForm.vue:17
#: frontend/src/pages/JobForm.vue:8 frontend/src/pages/LessonForm.vue:14
@@ -6046,7 +6041,7 @@ msgstr ""
msgid "Select Date"
msgstr ""
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:22
#: frontend/src/components/Settings/PaymentGatewayDetails.vue:26
msgid "Select Payment Gateway"
msgstr ""
@@ -6362,7 +6357,7 @@ msgstr ""
#. Label of the status (Select) field in DocType 'LMS Programming Exercise
#. Submission'
#. Label of the status (Select) field in DocType 'LMS Test Case Submission'
#: frontend/src/components/Modals/Event.vue:99
#: frontend/src/components/Modals/Event.vue:105
#: frontend/src/components/Settings/Badges.vue:228
#: frontend/src/components/Settings/ZoomSettings.vue:197
#: frontend/src/pages/AssignmentSubmissionList.vue:19
@@ -6473,7 +6468,7 @@ msgstr ""
#. Label of the summary (Small Text) field in DocType 'LMS Certificate
#. Evaluation'
#: frontend/src/components/Modals/Event.vue:106
#: frontend/src/components/Modals/Event.vue:112
#: lms/lms/doctype/lms_certificate_evaluation/lms_certificate_evaluation.json
msgid "Summary"
msgstr ""
@@ -6569,7 +6564,7 @@ msgstr ""
#. Label of the template (Link) field in DocType 'LMS Certificate'
#: frontend/src/components/Modals/BulkCertificates.vue:43
#: frontend/src/components/Modals/Event.vue:127
#: frontend/src/components/Modals/Event.vue:133
#: lms/lms/doctype/lms_certificate/lms_certificate.json
msgid "Template"
msgstr ""
@@ -6618,7 +6613,7 @@ msgstr ""
msgid "Thanks and Regards"
msgstr ""
#: lms/lms/utils.py:2247
#: lms/lms/utils.py:2248
msgid "The batch does not exist."
msgstr ""
@@ -6626,7 +6621,7 @@ msgstr ""
msgid "The batch you have enrolled for is starting tomorrow. Please be prepared and be on time for the session."
msgstr ""
#: lms/lms/utils.py:1747
#: lms/lms/utils.py:1748
msgid "The coupon code '{0}' is invalid."
msgstr ""
@@ -6650,7 +6645,7 @@ msgstr ""
msgid "The last day to schedule your evaluations is "
msgstr ""
#: lms/lms/utils.py:2231
#: lms/lms/utils.py:2232
msgid "The lesson does not exist."
msgstr ""
@@ -6658,7 +6653,7 @@ msgstr ""
msgid "The slot is already booked by another participant."
msgstr ""
#: lms/lms/utils.py:1361 lms/lms/utils.py:1477 lms/lms/utils.py:1944
#: lms/lms/utils.py:1362 lms/lms/utils.py:1478 lms/lms/utils.py:1945
msgid "The specified batch does not exist."
msgstr ""
@@ -6724,15 +6719,15 @@ msgstr ""
msgid "This class has ended"
msgstr ""
#: lms/lms/utils.py:1776
#: lms/lms/utils.py:1777
msgid "This coupon has expired."
msgstr ""
#: lms/lms/utils.py:1779
#: lms/lms/utils.py:1780
msgid "This coupon has reached its maximum usage limit."
msgstr ""
#: lms/lms/utils.py:1788
#: lms/lms/utils.py:1789
msgid "This coupon is not applicable to this {0}."
msgstr ""
@@ -6740,7 +6735,7 @@ msgstr ""
msgid "This course has:"
msgstr ""
#: lms/lms/utils.py:1707
#: lms/lms/utils.py:1708
msgid "This course is free."
msgstr ""
@@ -6797,7 +6792,7 @@ msgid "Thursday"
msgstr ""
#. Label of the time (Time) field in DocType 'LMS Live Class'
#: frontend/src/components/Modals/Event.vue:48
#: frontend/src/components/Modals/Event.vue:54
#: frontend/src/components/Modals/LiveClassModal.vue:52
#: frontend/src/components/Quiz.vue:58
#: lms/lms/doctype/lms_live_class/lms_live_class.json
@@ -6932,7 +6927,7 @@ msgstr ""
msgid "To Date"
msgstr ""
#: lms/lms/utils.py:1721
#: lms/lms/utils.py:1722
msgid "To join this batch, please contact the Administrator."
msgstr ""
@@ -7054,7 +7049,7 @@ msgstr ""
msgid "Under Review"
msgstr ""
#: frontend/src/pages/Batches.vue:330 frontend/src/pages/Courses.vue:356
#: frontend/src/pages/Batches.vue:332 frontend/src/pages/Courses.vue:355
msgid "Unpublished"
msgstr ""
@@ -7075,8 +7070,8 @@ msgstr ""
#. Option for the 'Status' (Select) field in DocType 'LMS Certificate Request'
#. Label of the upcoming (Check) field in DocType 'LMS Course'
#: frontend/src/pages/Batches.vue:328 frontend/src/pages/CourseForm.vue:117
#: frontend/src/pages/Courses.vue:347
#: frontend/src/pages/Batches.vue:330 frontend/src/pages/CourseForm.vue:117
#: frontend/src/pages/Courses.vue:346
#: lms/lms/doctype/lms_certificate_request/lms_certificate_request.json
#: lms/lms/doctype/lms_course/lms_course.json
msgid "Upcoming"
@@ -7218,7 +7213,7 @@ msgid "View Applications"
msgstr ""
#: frontend/src/components/CertificationLinks.vue:10
#: frontend/src/components/Modals/Event.vue:67
#: frontend/src/components/Modals/Event.vue:73
msgid "View Certificate"
msgstr ""
@@ -7325,6 +7320,11 @@ msgstr ""
msgid "Withdrawn"
msgstr ""
#. Option for the 'Open to' (Select) field in DocType 'User'
#: lms/fixtures/custom_field.json
msgid "Work"
msgstr ""
#. Label of the work_environment (Section Break) field in DocType 'User'
#: lms/fixtures/custom_field.json
msgid "Work Environment"
@@ -7425,7 +7425,7 @@ msgstr ""
msgid "You cannot enroll in an unpublished course."
msgstr ""
#: lms/lms/utils.py:2085
#: lms/lms/utils.py:2086
msgid "You cannot enroll in an unpublished program."
msgstr ""
@@ -7441,11 +7441,11 @@ msgstr ""
msgid "You cannot schedule evaluations for past slots."
msgstr ""
#: lms/lms/utils.py:2259
#: lms/lms/utils.py:2260
msgid "You do not have access to this batch."
msgstr ""
#: lms/lms/utils.py:2242
#: lms/lms/utils.py:2243
msgid "You do not have access to this course."
msgstr ""

View File

@@ -4,7 +4,6 @@
<br>
<p> {{ _(" Please evaluate and grade it.") }} </p>
<br>`
<a href="/lms/assignment-submission/{{ assignment_name }}/{{ submission_name }}">
<a href="{{ get_lms_route('assignment-submission/' ~ assignment_name ~ '/' ~ submission_name) }}">
{{ _("Open Assignment") }}
</a>

View File

@@ -23,7 +23,7 @@
<br>
<p>
{{ _("Visit the following link to view your ") }}
<a href="/lms/batches/{{ name }}">{{ _("Batch Details") }}</a>
<a href="{{ get_lms_route('batches/' ~ name) }}">{{ _("Batch Details") }}</a>
</p>
<p>
{{ _("If you have any questions or require assistance, feel free to contact us.") }}
@@ -32,4 +32,3 @@
<p>
{{ _("Best Regards") }}
</p>

View File

@@ -20,7 +20,7 @@
</p>
<br>
<p>
<a href="/lms/batches/{{ name }}">👉 {{ _("Visit your batch") }}</a>
<a href="{{ get_lms_route('batches/' ~ name) }}">👉 {{ _("Visit your batch") }}</a>
</p>
<br>
<p>

View File

@@ -17,7 +17,7 @@
</p>
<br>
<p>
<a href="/lms/batches/{{ batch_name }}">👉 {{ _("Visit your batch") }}</a>
<a href="{{ get_lms_route('batches/' ~ batch_name) }}">👉 {{ _("Visit your batch") }}</a>
</p>
<br>
<p>
@@ -26,4 +26,4 @@
<br>
<p>
{{ _("Best Regards") }}
</p>
</p>

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