Merge pull request #1942 from frappe/develop

chore: merge 'develop' into 'main'
This commit is contained in:
Jannat Patel
2025-12-24 15:57:13 +05:30
committed by GitHub
67 changed files with 7359 additions and 19638 deletions
+2 -1
View File
@@ -55,8 +55,9 @@
"@vitejs/plugin-vue": "5.0.3",
"autoprefixer": "10.4.2",
"postcss": "8.4.5",
"vite": "5.0.11",
"tailwindcss": "^3.4.15",
"unplugin-auto-import": "^20.3.0",
"vite": "5.0.11",
"vite-plugin-pwa": "0.15.0"
},
"resolutions": {
+33 -10
View File
@@ -26,28 +26,51 @@
v-model="quiz"
doctype="LMS Quiz"
:label="__('Select a quiz')"
placeholder=" "
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
:label="__('Select an assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
<div v-else class="space-y-4">
<Link
v-if="filterAssignmentsByCourse"
v-model="assignment"
doctype="LMS Assignment"
:filters="{
course: route.params.courseName,
}"
placeholder=" "
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
<Link
v-else
v-model="assignment"
doctype="LMS Assignment"
placeholder=" "
:label="__('Select an Assignment')"
:onCreate="(value, close) => redirectToForm()"
/>
<FormControl
type="checkbox"
:label="__('Filter assignments by course')"
v-model="filterAssignmentsByCourse"
/>
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { Dialog } from 'frappe-ui'
import { onMounted, ref, nextTick } from 'vue'
import Link from '@/components/Controls/Link.vue'
import { Dialog, FormControl } from 'frappe-ui'
import { nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Link } from 'frappe-ui/frappe'
const show = ref(false)
const quiz = ref(null)
const assignment = ref(null)
const filterAssignmentsByCourse = ref(false)
const route = useRoute()
const props = defineProps({
type: {
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div v-if="user.data?.is_student">
<div>
<div class="leading-5 mb-4">
<div class="leading-5 mb-4 text-ink-gray-7">
<div v-if="readOnly">
{{ __('Thank you for providing your feedback.') }}
<span
@@ -68,11 +68,12 @@ const props = defineProps({
const certification = createResource({
url: 'lms.lms.api.get_certification_details',
params: {
course: props.courseName,
makeParams(values) {
return {
course: props.courseName,
}
},
auto: user.data ? true : false,
cache: ['certificationData', user.data?.name],
})
const downloadCertificate = () => {
@@ -220,8 +220,12 @@ function enrollStudent() {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 500)
} else {
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
course: props.course.data.name,
call('frappe.client.insert', {
doc: {
doctype: 'LMS Enrollment',
course: props.course.data.name,
member: user.data.name,
},
})
.then(() => {
capture('enrolled_in_course', {
@@ -27,6 +27,12 @@
:label="__('Submission Type')"
:required="true"
/>
<Link
v-model="assignment.course"
:label="__('Course')"
doctype="LMS Course"
placeholder=" "
/>
<div>
<div class="text-xs text-ink-gray-5 mb-2">
{{ __('Question') }}
@@ -67,6 +73,7 @@
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
import { escapeHTML, sanitizeHTML } from '@/utils'
import { Link } from 'frappe-ui/frappe'
const show = defineModel()
const assignments = defineModel<Assignments>('assignments')
@@ -75,6 +82,7 @@ interface Assignment {
title: string
type: string
question: string
course?: string
}
interface Assignments {
@@ -89,6 +97,7 @@ const assignment = reactive({
title: '',
type: '',
question: '',
course: '',
})
const props = defineProps({
@@ -107,6 +116,7 @@ watch(
assignment.title = row.title
assignment.type = row.type
assignment.question = row.question
assignment.course = row.course || ''
}
})
}
+72 -36
View File
@@ -3,23 +3,11 @@
:options="{
title: 'Edit your profile',
size: '3xl',
actions: [
{
label: 'Save',
variant: 'solid',
onClick: (close) => saveProfile(close),
},
],
}"
>
<template #body-content>
<div class="grid grid-cols-2 gap-5">
<div class="space-y-4">
<!-- <Uploader
v-model="profile.image.file_url"
label="Profile Image"
description="Your profile image to help others recognize you."
/> -->
<div>
<div class="grid grid-cols-2 gap-10">
<div>
<div class="text-xs text-ink-gray-5 mb-1">
{{ __('Profile Image') }}
@@ -47,16 +35,16 @@
<div v-else class="mb-4">
<div class="flex items-center">
<img
:src="profile.image.file_url"
:src="profile.image?.file_url"
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
/>
<div class="text-base flex flex-col ml-2">
<span>
{{ profile.image.file_name }}
{{ profile.image?.file_name }}
</span>
<span class="text-sm text-ink-gray-4 mt-1">
{{ getFileSize(profile.image.file_size) }}
{{ getFileSize(profile.image?.file_size) }}
</span>
</div>
<X
@@ -66,39 +54,79 @@
</div>
</div>
</div>
<FormControl v-model="profile.first_name" :label="__('First Name')" />
<FormControl v-model="profile.last_name" :label="__('Last Name')" />
<FormControl v-model="profile.headline" :label="__('Headline')" />
<Link
:label="__('Language')"
v-model="profile.language"
doctype="Language"
<Switch
v-model="profile.looking_for_job"
:label="__('Open to Opportunities')"
:description="
__('Show recruiters and others that you are open to work.')
"
class="!px-0"
/>
</div>
<div>
<div class="mb-4">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Bio') }}
<div class="grid grid-cols-2 gap-10">
<div class="space-y-4">
<div class="space-y-4">
<FormControl
v-model="profile.first_name"
:label="__('First Name')"
/>
<FormControl
v-model="profile.last_name"
:label="__('Last Name')"
/>
<FormControl v-model="profile.headline" :label="__('Headline')" />
<FormControl
v-model="profile.linkedin"
:label="__('LinkedIn ID')"
/>
<FormControl v-model="profile.github" :label="__('GitHub ID')" />
<FormControl
v-model="profile.twitter"
:label="__('Twitter ID')"
/>
</div>
<TextEditor
:fixedMenu="true"
@change="(val) => (profile.bio = val)"
:content="profile.bio"
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
</div>
<div class="space-y-4">
<Link
:label="__('Language')"
v-model="profile.language"
doctype="Language"
/>
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Bio') }}
</div>
<TextEditor
:fixedMenu="true"
@change="(val) => (profile.bio = val)"
:content="profile.bio"
:rows="15"
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
/>
</div>
</div>
</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>
import {
Button,
createResource,
Dialog,
FormControl,
FileUploader,
Button,
createResource,
Switch,
TextEditor,
toast,
} from 'frappe-ui'
@@ -123,6 +151,10 @@ const profile = reactive({
headline: '',
bio: '',
image: '',
looking_for_job: false,
linkedin: '',
github: '',
twitter: '',
})
const imageResource = createResource({
@@ -145,7 +177,7 @@ const updateProfile = createResource({
doctype: 'User',
name: props.profile.data.name,
fieldname: {
user_image: profile.image.file_url,
user_image: profile.image?.file_url || null,
...profile,
},
}
@@ -199,6 +231,10 @@ watch(
profile.headline = newVal.headline
profile.language = newVal.language
profile.bio = newVal.bio
profile.looking_for_job = newVal.looking_for_job
profile.linkedin = newVal.linkedin
profile.github = newVal.github
profile.twitter = newVal.twitter
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
}
}
@@ -2,7 +2,7 @@
<Dialog
v-model="show"
:options="{
title: __('Schedule Evaluation'),
title: __('Schedule your evaluation'),
size: 'xl',
actions: [
{
@@ -14,52 +14,49 @@
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Course') }}
<div class="flex flex-col gap-4 text-base max-h-[60vh]">
<FormControl
v-model="evaluation.course"
type="select"
:label="__('Course')"
:options="getCourses()"
/>
<div v-if="slots.data?.length" class="space-y-4 overflow-y-auto mt-4">
<div class="text-ink-gray-9 font-medium">
{{ __('Available Slots') }}
</div>
<Select v-model="evaluation.course" :options="getCourses()" />
</div>
<div>
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Date') }}
</div>
<FormControl
type="date"
v-model="evaluation.date"
:min="
dayjs()
.add(dayjs.duration({ days: 1 }))
.format('YYYY-MM-DD')
"
/>
</div>
<div v-if="slots.data?.length">
<div class="mb-1.5 text-sm text-ink-gray-5">
{{ __('Select a slot') }}
</div>
<div class="grid grid-cols-2 gap-2">
<div v-for="slot in slots.data">
<div
class="text-base text-center border rounded-md text-ink-gray-8 bg-surface-gray-3 p-2 cursor-pointer"
@click="saveSlot(slot)"
:class="{
'border-outline-gray-4':
evaluation.start_time == slot.start_time,
}"
>
{{ formatTime(slot.start_time) }} -
{{ formatTime(slot.end_time) }}
<div class="space-y-5">
<div v-for="row in slots.data" class="space-y-2">
<div class="flex items-center text-ink-gray-7 space-x-2">
<Calendar class="size-3" />
<div class="text-ink-gray-9">
{{ dayjs(row.date).format('DD MMMM YYYY') }}
</div>
<div>&middot;</div>
<div class="text-ink-gray-5">
{{ row.day }}
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div
v-for="slot in row.slots"
class="text-base text-center border rounded-md text-ink-gray-8 p-2 cursor-pointer text-ink-gray-7 hover:bg-surface-gray-2 hover:border-outline-gray-3"
@click="saveSlot(slot, row)"
:class="{
'border-outline-gray-4 text-ink-gray-9':
evaluation.date == row.date &&
evaluation.start_time == slot.start_time,
}"
>
{{ formatTime(slot.start_time) }} -
{{ formatTime(slot.end_time) }}
</div>
</div>
</div>
</div>
</div>
<div
v-else-if="evaluation.course && evaluation.date"
class="text-sm italic text-ink-red-4"
>
{{ __('No slots available for this date.') }}
<div v-else class="text-ink-red-3">
{{ __('No slots available for the selected course.') }}
</div>
</div>
</template>
@@ -67,14 +64,15 @@
</template>
<script setup>
import {
call,
createResource,
dayjs,
Dialog,
createResource,
Select,
FormControl,
toast,
} from 'frappe-ui'
import { reactive, watch, inject } from 'vue'
import { ref, watch, inject } from 'vue'
import { Calendar } from 'lucide-vue-next'
import { formatTime } from '@/utils/'
const user = inject('$user')
@@ -96,7 +94,7 @@ const props = defineProps({
},
})
const evaluation = reactive({
const evaluation = ref({
course: '',
date: '',
start_time: '',
@@ -106,49 +104,28 @@ const evaluation = reactive({
member: user.data.name,
})
const createEvaluation = createResource({
url: 'frappe.client.insert',
makeParams(values) {
return {
doc: {
doctype: 'LMS Certificate Request',
batch_name: values.batch,
...values,
},
}
},
})
function submitEvaluation(close) {
createEvaluation.submit(evaluation, {
validate() {
if (!evaluation.course) {
return 'Please select a course.'
}
if (!evaluation.date) {
return 'Please select a date.'
}
if (!evaluation.start_time) {
return 'Please select a slot.'
}
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
return 'Please select a future date.'
}
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
return `Please select a date before the end date ${dayjs(
props.endDate
).format('DD MMMM YYYY')}.`
}
},
onSuccess() {
evaluations.value.reload()
close()
},
onError(err) {
console.log(err.messages?.[0] || err)
toast.warning(__(err.messages?.[0] || err), { duration: 10000 })
if (!evaluation.value.date || !evaluation.value.start_time) {
toast.warning(__('Please select a slot for your evaluation.'), {
duration: 10,
})
return
}
call('frappe.client.insert', {
doc: {
doctype: 'LMS Certificate Request',
batch_name: evaluation.value.batch,
...evaluation.value,
},
})
.then(() => {
evaluations.value.reload()
close()
})
.catch((err) => {
console.log(err.messages?.[0] || err)
toast.warning(__(err.messages?.[0] || err), { duration: 20 })
})
}
const getCourses = () => {
@@ -163,7 +140,7 @@ const getCourses = () => {
}
if (courses.length === 1) {
evaluation.course = courses[0].value
evaluation.value.course = courses[0].value
}
return courses
@@ -174,34 +151,22 @@ const slots = createResource({
makeParams(values) {
return {
course: values.course,
date: values.date,
batch: props.batch,
}
},
})
watch(
() => evaluation.date,
(date) => {
evaluation.start_time = ''
if (date && evaluation.course) {
slots.submit(evaluation)
}
}
)
watch(
() => evaluation.course,
() => evaluation.value.course,
(course) => {
evaluation.date = ''
evaluation.start_time = ''
slots.reset()
slots.reload(evaluation.value)
}
)
const saveSlot = (slot) => {
evaluation.start_time = slot.start_time
evaluation.end_time = slot.end_time
evaluation.day = slot.day
const saveSlot = (slot, row) => {
evaluation.value.start_time = slot.start_time
evaluation.value.end_time = slot.end_time
evaluation.value.date = row.date
evaluation.value.day = row.day
}
</script>
+30 -10
View File
@@ -1,17 +1,26 @@
<template>
<Tooltip :text="user.full_name">
<Avatar
class="avatar border border-outline-gray-2 cursor-auto"
v-if="user"
:label="user.full_name"
:image="user.user_image"
:size="size"
v-bind="$attrs"
/>
</Tooltip>
<Avatar
class="avatar border border-outline-gray-2 cursor-auto"
v-if="user"
:label="user.full_name"
:image="user.user_image"
:size="size"
v-bind="$attrs"
>
<template v-if="user.looking_for_job" #indicator>
<Tooltip :text="__('Open to Opportunities')" placement="right">
<div class="rounded-full bg-surface-green-3 w-fit">
<BadgeCheckIcon :class="'text-ink-white ' + checkSize" />
</div>
</Tooltip>
</template>
</Avatar>
</template>
<script setup>
import { Avatar, Tooltip } from 'frappe-ui'
import { BadgeCheckIcon } from 'lucide-vue-next'
import { computed } from 'vue'
const props = defineProps({
user: {
type: Object,
@@ -21,4 +30,15 @@ const props = defineProps({
type: String,
},
})
const checkSize = computed(() => {
let sizeMap = {
sm: 'size-1',
md: 'size-2',
lg: 'size-3',
xl: 'size-3',
'2xl': 'size-3',
}
return sizeMap[props.size] || 'size-3'
})
</script>
+1 -1
View File
@@ -148,7 +148,7 @@ const assignmentFilter = computed(() => {
const assignments = createListResource({
doctype: 'LMS Assignment',
fields: ['name', 'title', 'type', 'creation', 'question'],
fields: ['name', 'title', 'type', 'creation', 'question', 'course'],
orderBy: 'modified desc',
cache: ['assignments'],
transform(data) {
+14
View File
@@ -144,6 +144,20 @@
</span>
</div>
</div>
<div
v-if="batch.data.evaluation_end_date && isStudent"
class="text-sm leading-5 bg-surface-amber-1 text-ink-amber-3 p-2 rounded-md mb-10"
>
{{ __('The last day to schedule your evaluations is ') }}
<span class="font-medium">
{{
dayjs(batch.data.evaluation_end_date).format('DD MMMM YYYY')
}} </span
>.
{{
__('Please make sure to schedule your evaluation before this date.')
}}
</div>
<div v-if="dayjs().isSameOrAfter(dayjs(batch.data.start_date))">
<div class="text-ink-gray-7 font-semibold mb-2">
{{ __('Feedback') }}
+12 -10
View File
@@ -38,8 +38,8 @@
</div>
</div>
</div>
<div v-if="participants.data?.length" class="divide-y">
<template v-for="participant in participants.data">
<div v-if="participants.data?.length" class="">
<template v-for="(participant, index) in participants.data">
<router-link
:to="{
name: 'ProfileCertificates',
@@ -47,15 +47,16 @@
username: participant.username,
},
}"
class="flex sm:rounded px-3 py-2 sm:h-15 hover:bg-surface-gray-2"
class="flex h-15 rounded-md hover:bg-surface-gray-2 px-3"
>
<div class="flex items-center w-full space-x-3">
<Avatar
:image="participant.user_image"
class="size-8 rounded-full object-contain"
:label="participant.full_name"
size="2xl"
/>
<div
class="flex items-center w-full space-x-3 py-2"
:class="{
'border-b': index < participants.data.length - 1,
}"
>
<UserAvatar :user="participant" size="2xl" />
<div class="flex flex-col md:flex-row w-full">
<div class="flex-1">
<div class="text-base font-medium text-ink-gray-8">
@@ -115,6 +116,7 @@ import { computed, inject, onMounted, ref } from 'vue'
import { GraduationCap } from 'lucide-vue-next'
import { sessionStore } from '../stores/session'
import EmptyState from '@/components/EmptyState.vue'
import UserAvatar from '@/components/UserAvatar.vue'
const currentCategory = ref('')
const filters = ref({})
+31 -30
View File
@@ -11,8 +11,8 @@
route: { name: 'Jobs' },
},
{
label: job.doc?.job_title,
route: { name: 'JobDetail', params: { job: job.doc?.name } },
label: job.data?.job_title,
route: { name: 'JobDetail', params: { job: job.data?.name } },
},
]"
/>
@@ -24,7 +24,7 @@
v-if="canManageJob && applicationCount.data > 0"
:to="{
name: 'JobApplications',
params: { job: job.doc?.name },
params: { job: job.data?.name },
}"
>
<Button variant="subtle">
@@ -35,7 +35,7 @@
v-if="canManageJob"
:to="{
name: 'JobForm',
params: { jobName: job.doc?.name },
params: { jobName: job.data?.name },
}"
>
<Button>
@@ -45,7 +45,7 @@
{{ __('Edit') }}
</Button>
</router-link>
<Button @click="redirectToWebsite(job.doc?.company_website)">
<Button @click="redirectToWebsite(job.data?.company_website)">
<template #prefix>
<SquareArrowOutUpRight class="h-4 w-4 stroke-1.5" />
</template>
@@ -69,30 +69,30 @@
</Badge>
</div>
<div v-else-if="!readOnlyMode">
<Button @click="redirectToLogin(job.doc?.name)">
<Button @click="redirectToLogin(job.data?.name)">
<span>
{{ __('Login to apply') }}
</span>
</Button>
</div>
</header>
<div v-if="job.doc" class="max-w-3xl mx-auto pt-5">
<div v-if="job.data" class="max-w-3xl mx-auto pt-5">
<div class="p-4">
<div class="space-y-5 mb-12">
<div class="flex">
<img
:src="job.doc.company_logo"
:src="job.data.company_logo"
class="size-10 rounded-lg object-contain cursor-pointer mr-4"
:alt="job.doc.company_name"
@click="redirectToWebsite(job.doc.company_website)"
:alt="job.data.company_name"
@click="redirectToWebsite(job.data.company_website)"
/>
<div class="">
<div class="text-2xl text-ink-gray-9 font-semibold mb-1">
{{ job.doc.job_title }}
{{ job.data.job_title }}
</div>
<div class="text-sm text-ink-gray-5 font-semibold">
{{ job.doc.company_name }} - {{ job.doc.location }},
{{ job.doc.country }}
{{ job.data.company_name }} - {{ job.data.location }},
{{ job.data.country }}
</div>
</div>
</div>
@@ -102,19 +102,19 @@
<template #prefix>
<CalendarDays class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ dayjs(job.doc.creation).fromNow() }}
{{ dayjs(job.data.creation).fromNow() }}
</Badge>
<Badge size="lg">
<template #prefix>
<ClipboardType class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ job.doc.type }}
{{ job.data.type }}
</Badge>
<Badge v-if="job.doc?.work_mode" size="lg">
<Badge v-if="job.data?.work_mode" size="lg">
<template #prefix>
<BriefcaseBusiness class="size-3 stroke-2 text-ink-gray-7" />
</template>
{{ job.doc.work_mode }}
{{ job.data.work_mode }}
</Badge>
<Badge v-if="applicationCount.data" size="lg">
<template #prefix>
@@ -137,14 +137,14 @@
</div>
<p
v-html="job.doc.description"
v-html="job.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-12"
></p>
</div>
<JobApplicationModal
v-model="showApplicationModal"
v-model:application="jobApplication"
:job="job.doc.name"
:job="job.data.name"
/>
</div>
</div>
@@ -155,7 +155,6 @@ import {
Button,
Breadcrumbs,
createResource,
createDocumentResource,
usePageMeta,
} from 'frappe-ui'
import { inject, ref, computed } from 'vue'
@@ -187,16 +186,18 @@ const props = defineProps({
},
})
const job = createDocumentResource({
doctype: 'Job Opportunity',
name: props.job,
auto: true,
const job = createResource({
url: 'lms.lms.api.get_job_details',
params: {
job: props.job,
},
cache: ['job', props.job],
auto: true,
onSuccess: (data) => {
if (user.data?.name) {
jobApplication.submit()
applicationCount.submit()
}
applicationCount.submit()
},
})
@@ -206,7 +207,7 @@ const jobApplication = createResource({
return {
doctype: 'LMS Job Application',
filters: {
job: job.doc?.name,
job: job.data?.name,
user: user.data?.name,
},
}
@@ -219,7 +220,7 @@ const applicationCount = createResource({
return {
doctype: 'LMS Job Application',
filters: {
job: job.doc?.name,
job: job.data?.name,
},
}
},
@@ -238,13 +239,13 @@ const redirectToWebsite = (url) => {
}
const canManageJob = computed(() => {
if (!user.data?.name || !job.doc) return false
return user.data.name === job.doc.owner || user.data?.is_moderator
if (!user.data?.name || !job.data) return false
return user.data.name === job.data.owner || user.data?.is_moderator
})
usePageMeta(() => {
return {
title: job.doc?.job_title,
title: job.data?.job_title,
icon: brand.favicon,
}
})
+4
View File
@@ -137,6 +137,10 @@ const isModerator = computed(() => {
})
const getClosedJobCount = () => {
if (!user.data?.name) {
return
}
const filters = {
status: 'Closed',
}
+5
View File
@@ -342,6 +342,7 @@ import {
TabButtons,
Tooltip,
usePageMeta,
toast,
} from 'frappe-ui'
import {
computed,
@@ -799,6 +800,10 @@ const enrollStudent = () => {
onSuccess() {
window.location.reload()
},
onError(err) {
toast.error(__(err.messages?.[0] || err))
console.error(err)
},
}
)
}
+54 -15
View File
@@ -50,24 +50,51 @@
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
<div class="flex flex-col md:flex-row items-center">
<div>
<img
v-if="profile.data.user_image"
:src="profile.data.user_image"
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
/>
<UserAvatar
v-else
:user="profile.data"
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
/>
<div class="relative">
<img
v-if="profile.data.user_image"
:src="profile.data.user_image"
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
/>
<Tooltip
v-if="profile.data.looking_for_job"
:text="__('Open to Opportunities')"
placement="right"
>
<div
class="absolute bottom-3 right-1 p-0.5 bg-surface-white rounded-full"
>
<div class="rounded-full bg-surface-green-3 w-fit">
<BadgeCheckIcon class="text-ink-white size-5" />
</div>
</div>
</Tooltip>
</div>
</div>
<div class="ml-6">
<h2 class="mt-2 text-3xl font-semibold text-ink-gray-9">
<div class="ml-6 mt-5">
<h2 class="text-3xl font-semibold text-ink-gray-9">
{{ profile.data.full_name }}
</h2>
<div class="mt-2 text-base text-ink-gray-7">
<div class="text-base text-ink-gray-7 mt-1">
{{ profile.data.headline }}
</div>
<div class="flex items-center space-x-4 mt-2">
<Twitter
v-if="profile.data.twitter"
class="size-4 text-ink-gray-5 cursor-pointer"
@click="navigateTo(profile.data.twitter)"
/>
<Linkedin
v-if="profile.data.linkedin"
class="size-4 text-ink-gray-5 cursor-pointer"
@click="navigateTo(profile.data.linkedin)"
/>
<Github
v-if="profile.data.github"
class="size-4 text-ink-gray-5 cursor-pointer"
@click="navigateTo(profile.data.github)"
/>
</div>
</div>
<Button
v-if="isSessionUser() && !readOnlyMode"
@@ -81,7 +108,7 @@
</Button>
</div>
<div class="mb-4 mt-6">
<div class="mb-4 mt-10">
<TabButtons
class="inline-block"
:buttons="getTabButtons()"
@@ -104,11 +131,19 @@ import {
call,
createResource,
TabButtons,
Tooltip,
usePageMeta,
} from 'frappe-ui'
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
import { sessionStore } from '@/stores/session'
import { Edit, RefreshCcw } from 'lucide-vue-next'
import {
BadgeCheckIcon,
Edit,
Github,
Linkedin,
RefreshCcw,
Twitter,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import { convertToTitleCase } from '@/utils'
import UserAvatar from '@/components/UserAvatar.vue'
@@ -229,6 +264,10 @@ const reloadUser = () => {
})
}
const navigateTo = (url) => {
window.open(url, '_blank')
}
const breadcrumbs = computed(() => {
let crumbs = [
{
+7 -5
View File
@@ -56,11 +56,13 @@
</template>
<template #body-main>
<div class="w-[250px] text-base">
<img
:src="badge.badge_image"
:alt="badge.badge"
class="bg-surface-gray-2 rounded-t-md h-[200px] mx-auto"
/>
<div class="bg-surface-gray-2 rounded-t-md py-5">
<img
:src="badge.badge_image"
:alt="badge.badge"
class="h-[200px] mx-auto"
/>
</div>
<div class="p-5">
<div class="text-2xl font-semibold mb-2">
{{ badge.badge }}
@@ -11,7 +11,7 @@
</div>
</template>
<template #body-content>
<div v-if="program.data" class="text-base">
<div v-if="program.data" class="text-base text-ink-gray-9">
<div class="bg-surface-blue-2 text-ink-blue-3 p-2 rounded-md leading-5">
<span>
{{
@@ -46,9 +46,9 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div
v-for="course in program.data.courses"
class="flex flex-col border p-2 rounded-md h-full"
class="flex flex-col border border-outline-gray-2 p-2 rounded-md h-full"
>
<div class="font-semibold leading-5 mb-2">
<div class="font-semibold text-ink-gray-9 leading-5 mb-2">
{{ course.title }}
</div>
@@ -85,7 +85,7 @@
<div class="flex items-center space-x-1 mt-auto">
<UserAvatar :user="course.instructors[0]" />
<span>
<span class="text-ink-gray-9">
{{ course.instructors[0].full_name }}
</span>
</div>
@@ -17,7 +17,7 @@
@click="openDetails(program.name, category)"
class="border rounded-md p-3 hover:border-outline-gray-3 cursor-pointer"
>
<div class="text-lg font-semibold mb-2">
<div class="text-lg font-semibold text-ink-gray-9 mb-2">
{{ program.name }}
</div>
@@ -40,7 +40,7 @@
<div v-if="Object.keys(program).includes('progress')" class="mt-5">
<ProgressBar :progress="program.progress" />
<div class="text-sm mt-1">
<div class="text-sm text-ink-gray-7 mt-1">
{{ Math.ceil(program.progress) }}% {{ __('completed') }}
</div>
</div>
+8 -2
View File
@@ -70,7 +70,10 @@
</Tooltip>
<div class="space-y-1 w-full">
<div class="flex items-center">
<div class="font-medium" v-html="result.title"></div>
<div
class="font-medium text-ink-gray-9"
v-html="result.title"
></div>
<div class="text-sm text-ink-gray-5 ml-2">
{{ getDocTypeTitle(result.doctype) }}
</div>
@@ -89,7 +92,10 @@
}}
</div>
</div>
<div class="leading-5" v-html="result.content"></div>
<div
class="leading-5 text-ink-gray-7"
v-html="result.content"
></div>
</div>
</div>
</div>
+2
View File
@@ -4,6 +4,7 @@ import AssessmentPlugin from '@/components/AssessmentPlugin.vue'
import translationPlugin from '../translation'
import { usersStore } from '@/stores/user'
import { call } from 'frappe-ui'
import router from '@/router'
export class Assignment {
constructor({ data, api, readOnly }) {
@@ -84,6 +85,7 @@ export class Assignment {
},
})
app.use(translationPlugin)
app.use(router)
app.mount(this.wrapper)
}
+79 -63
View File
@@ -1,70 +1,86 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import frappeui from 'frappe-ui/vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
frappeTypes: {
input: {},
},
buildConfig: {
indexHtmlPath: '../lms/www/lms.html',
},
}),
vue({
script: {
defineModel: true,
propsDestructure: true,
},
}),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true,
},
workbox: {
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globDirectory: '/assets/lms/frontend',
globPatterns: ['**/*.{js,ts,css,html,png,svg}'],
runtimeCaching: [
{
urlPattern: ({ request }) =>
request.destination === 'document',
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache',
},
},
],
},
manifest: false,
}),
],
server: {
host: '0.0.0.0', // Accept connections from any network interface
allowedHosts: ['ps', 'fs', 'home'], // Explicitly allow this host
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
export default defineConfig(async ({ mode }) => {
const isDev = mode === 'development'
const frappeui = await importFrappeUIPlugin(isDev)
const config = {
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
},
},
optimizeDeps: {
include: [
'feather-icons',
'engine.io-client',
'interactjs',
'highlight.js',
'plyr',
plugins: [
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
buildConfig: {
indexHtmlPath: '../lms/www/lms.html',
},
}),
vue(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: false,
},
workbox: {
cleanupOutdatedCaches: true,
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
globDirectory: '/assets/lms/frontend',
globPatterns: ['**/*.{js,ts,css,html,png,svg}'],
runtimeCaching: [
{
urlPattern: ({ request }) =>
request.destination === 'document',
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache',
},
},
],
},
manifest: false,
}),
],
exclude: mode === 'production' ? [] : ['frappe-ui'],
},
}))
server: {
host: '0.0.0.0', // Accept connections from any network interface
allowedHosts: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
optimizeDeps: {
include: [
'feather-icons',
'tailwind.config.js',
'interactjs',
'highlight.js',
'plyr',
],
exclude: mode === 'production' ? [] : ['frappe-ui'],
},
}
return config
})
async function importFrappeUIPlugin(isDev) {
if (isDev) {
try {
const module = await import('../frappe-ui/vite')
return module.default
} catch (error) {
console.warn(
'Local frappe-ui not found, falling back to npm package:',
error.message
)
}
}
// Fall back to npm package if local import fails
const module = await import('frappe-ui/vite')
return module.default
}
-5561
View File
File diff suppressed because it is too large Load Diff
+59 -3
View File
@@ -250,10 +250,10 @@
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "interest",
"insert_after": "verify_terms",
"is_system_generated": 1,
"is_virtual": 0,
"label": "I am looking for a job",
"label": "Open to Opportunities",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
@@ -406,7 +406,7 @@
"dt": "User",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "medium",
"fieldname": "twitter",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
@@ -421,6 +421,62 @@
"insert_after": "github",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Twitter ID",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
"modified": "2025-12-15 14:46:55.834145",
"module": null,
"name": "User-twitter",
"no_copy": 0,
"non_negative": 0,
"options": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
"translatable": 1,
"unique": 0,
"width": null
},
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"doctype": "Custom Field",
"dt": "User",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "medium",
"fieldtype": "Data",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "twitter",
"is_system_generated": 1,
"is_virtual": 0,
"label": "Medium ID",
"length": 0,
"link_filters": null,
+28 -1
View File
@@ -147,6 +147,29 @@ def verify_billing_access(doctype, name, billing_type):
return access, message
@frappe.whitelist(allow_guest=True)
def get_job_details(job):
return frappe.db.get_value(
"Job Opportunity",
job,
[
"job_title",
"location",
"country",
"type",
"work_mode",
"company_name",
"company_logo",
"company_website",
"name",
"creation",
"description",
"owner",
],
as_dict=1,
)
@frappe.whitelist(allow_guest=True)
def get_job_opportunities(filters=None, orFilters=None):
if not filters:
@@ -286,7 +309,7 @@ def get_certified_participants(filters=None, start=0, page_length=100):
details = frappe.db.get_value(
"User",
participant.member,
["full_name", "user_image", "username", "country", "headline"],
["full_name", "user_image", "username", "country", "headline", "looking_for_job"],
as_dict=1,
)
details["certificate_count"] = count
@@ -1612,6 +1635,10 @@ def get_profile_details(username):
"headline",
"language",
"cover_image",
"looking_for_job",
"linkedin",
"github",
"twitter",
],
as_dict=True,
)
@@ -6,7 +6,7 @@ from datetime import datetime
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_time, getdate
from frappe.utils import add_days, get_time, getdate, nowdate
from lms.lms.utils import get_evaluator
@@ -58,33 +58,125 @@ class CourseEvaluator(Document):
@frappe.whitelist()
def get_schedule(course, date, batch=None):
def get_schedule(course, batch=None):
evaluator = get_evaluator(course, batch)
day = datetime.strptime(date, "%Y-%m-%d").strftime("%A")
start_date = nowdate()
end_date = get_schedule_range_end_date(start_date, batch)
all_slots = get_all_slots(evaluator, start_date, end_date)
booked_slots = get_booked_slots(evaluator, start_date, end_date)
all_slots = remove_booked_slots(all_slots, booked_slots)
return all_slots
all_slots = frappe.get_all(
def get_all_slots(evaluator, start_date, end_date):
schedule = get_evaluator_schedule(evaluator)
unavailable_dates = get_unavailable_dates(evaluator)
all_slots = []
current_date = getdate(start_date)
end_date = getdate(end_date)
while current_date <= end_date:
if current_date in unavailable_dates:
current_date = add_days(current_date, 1)
continue
day_of_week = current_date.strftime("%A")
slots_for_day = [x for x in schedule if x.day == day_of_week]
for slot in slots_for_day:
all_slots.append(
frappe._dict(
{
"day": day_of_week,
"date": current_date,
"start_time": slot.start_time,
"end_time": slot.end_time,
}
)
)
current_date = add_days(current_date, 1)
return all_slots
def get_evaluator_schedule(evaluator):
return frappe.get_all(
"Evaluator Schedule",
filters={
"parent": evaluator,
"day": day,
},
fields=["day", "start_time", "end_time"],
order_by="start_time",
)
booked_slots = frappe.get_all(
def get_booked_slots(evaluator, start_date, end_date):
date = ["between", [start_date, end_date]]
return frappe.get_all(
"LMS Certificate Request",
filters={
"evaluator": evaluator,
"date": date,
"status": ["!=", "Cancelled"],
},
fields=["start_time", "day"],
fields=["start_time", "day", "date"],
)
for slot in booked_slots:
same_slot = [x for x in all_slots if x.start_time == slot.start_time and x.day == slot.day]
if len(same_slot):
all_slots.remove(same_slot[0])
return all_slots
def remove_booked_slots(all_slots, booked_slots):
slots_to_remove = []
for slot in all_slots:
for booked in booked_slots:
if slot.date == booked.date and slot.start_time == booked.start_time:
slots_to_remove.append(slot)
for slot in slots_to_remove:
all_slots.remove(slot)
return group_slots_by_date(all_slots)
def group_slots_by_date(all_slots):
slots_by_date = []
dates_included = set()
for slot in all_slots:
date_str = slot.get("date").strftime("%Y-%m-%d")
if date_str not in dates_included:
slots_by_date.append({"date": date_str, "day": slot.day, "slots": []})
dates_included.add(date_str)
for date_slot in slots_by_date:
if date_slot.get("date") == date_str:
date_slot.get("slots").append(
{
"start_time": slot.get("start_time"),
"end_time": slot.get("end_time"),
}
)
return slots_by_date
def get_evaluator_availability(evaluator):
return frappe.db.get_value(
"Course Evaluator", evaluator, ["unavailable_from", "unavailable_to"], as_dict=1
)
def get_unavailable_dates(evaluator):
availability = get_evaluator_availability(evaluator)
unavailable_dates = []
if availability.unavailable_from and availability.unavailable_to:
current_date = getdate(availability.unavailable_from)
end_date = getdate(availability.unavailable_to)
while current_date <= end_date:
unavailable_dates.append(current_date)
current_date = add_days(current_date, 1)
return unavailable_dates
def get_schedule_range_end_date(start_date, batch=None):
end_date = add_days(start_date, 60)
if batch:
batch_end_date = frappe.db.get_value("LMS Batch", batch, "evaluation_end_date")
if batch_end_date and batch_end_date < getdate(end_date):
end_date = getdate(batch_end_date)
return end_date
@@ -3,7 +3,62 @@
# 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
from lms.lms.test_utils import TestUtils
class TestCourseEvaluator(UnitTestCase):
pass
def setUp(self):
self.admin = TestUtils.create_user(
self, "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)
def test_schedule_day_and_time(self):
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
days = ["Monday", "Wednesday"]
self.assertGreaterEqual(len(schedule), 14)
for row in schedule:
self.assertIn(row.get("day"), days)
if row.get("day") == "Monday":
for slot in row.get("slots"):
self.assertEqual(format_time(slot.get("start_time"), "HH:mm:ss"), "10:00:00")
self.assertEqual(format_time(slot.get("end_time"), "HH:mm:ss"), "12:00:00")
if row.get("day") == "Wednesday":
for slot in row.get("slots"):
self.assertEqual(format_time(slot.get("start_time"), "HH:mm:ss"), "14:00:00")
self.assertEqual(format_time(slot.get("end_time"), "HH:mm:ss"), "16:00:00")
def test_schedule_dates(self):
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
first_date = self.calculated_first_date_of_schedule()
last_date = self.calculated_last_date_of_schedule(first_date)
self.assertEqual(getdate(schedule[0].get("date")), first_date)
self.assertEqual(getdate(schedule[-1].get("date")), last_date)
def calculated_first_date_of_schedule(self):
today = getdate()
offset_monday = (0 - today.weekday() + 7) % 7 # 0 for Monday
offset_wednesday = (2 - today.weekday() + 7) % 7 # 2 for Wednesday
if offset_monday < offset_wednesday:
first_date = add_days(today, offset_monday)
else:
first_date = add_days(today, offset_wednesday)
return first_date
def calculated_last_date_of_schedule(self, first_date):
last_date = add_days(first_date, 56) # 8 weeks course
return last_date
def test_unavailability_dates(self):
unavailable_from = getdate(self.evaluator.unavailable_from)
unavailable_to = getdate(self.evaluator.unavailable_to)
schedule = get_schedule(self.batch.courses[0].course, self.batch.name)
for row in schedule:
schedule_date = getdate(row.get("date"))
self.assertFalse(unavailable_from < schedule_date < unavailable_to)
@@ -9,10 +9,11 @@
"engine": "InnoDB",
"field_order": [
"title",
"question",
"column_break_hmwv",
"type",
"grade_assignment",
"course",
"column_break_hmwv",
"question",
"section_break_sjti",
"show_answer",
"answer"
@@ -68,12 +69,18 @@
{
"fieldname": "section_break_sjti",
"fieldtype": "Section Break"
},
{
"fieldname": "course",
"fieldtype": "Link",
"label": "Course",
"options": "LMS Course"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-11-10 11:40:38.157448",
"modified": "2025-12-19 16:30:58.531722",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment",
@@ -112,6 +119,28 @@
"report": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
@@ -150,7 +150,7 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-07-14 10:24:23.526176",
"modified": "2025-12-17 14:47:22.944223",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Assignment Submission",
@@ -195,7 +195,6 @@
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
@@ -207,7 +206,6 @@
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
+5 -1
View File
@@ -377,9 +377,13 @@
{
"link_doctype": "LMS Certificate",
"link_fieldname": "batch_name"
},
{
"link_doctype": "LMS Payment",
"link_fieldname": "payment_for_document"
}
],
"modified": "2025-12-04 12:54:11.190967",
"modified": "2025-12-23 11:27:00.424331",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Batch",
@@ -48,8 +48,12 @@ class LMSBatchEnrollment(Document):
self.payment = payment
def validate_self_enrollment(self):
allow_self_enrollment = frappe.db.get_value("LMS Batch", self.batch, "allow_self_enrollment")
if not allow_self_enrollment and not self.is_admin():
batch_details = frappe.db.get_value(
"LMS Batch", self.batch, ["allow_self_enrollment", "paid_batch"], as_dict=True
)
if batch_details.paid_batch:
return
if not batch_details.allow_self_enrollment and not self.is_admin():
frappe.throw(_("Enrollment in this batch is restricted. Please contact the Administrator."))
def is_admin(self):
@@ -123,7 +123,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-07 19:24:12.272810",
"modified": "2025-12-17 16:50:31.128747",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Certificate",
@@ -164,6 +164,39 @@
"role": "LMS Student",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Batch Evaluator",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Course Creator",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
@@ -67,6 +67,7 @@ class LMSCertificateRequest(Document):
{
"evaluator": self.evaluator,
"date": self.date,
"status": ["!=", "Cancelled"],
"start_time": self.start_time,
"member": ["!=", self.member],
},
@@ -141,7 +141,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-23 12:50:13.622277",
"modified": "2025-12-23 11:11:23.908797",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Enrollment",
@@ -72,20 +72,3 @@ def update_program_progress(member):
average_progress = ceil(total_progress / len(courses))
frappe.db.set_value("LMS Program Member", program.name, "progress", average_progress)
@frappe.whitelist()
def create_membership(course, batch=None, member=None, member_type="Student", role="Member"):
enrollment = frappe.new_doc("LMS Enrollment")
enrollment.update(
{
"doctype": "LMS Enrollment",
"batch_old": batch,
"course": course,
"role": role,
"member_type": member_type,
"member": member or frappe.session.user,
}
)
enrollment.insert()
return enrollment
@@ -76,6 +76,7 @@
],
"fields": [
{
"default": "https://falcon.frappe.io/",
"fieldname": "livecode_url",
"fieldtype": "Data",
"label": "LiveCode URL"
@@ -451,7 +452,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-12-10 17:36:15.689695",
"modified": "2025-12-22 11:30:13.868031",
"modified_by": "sayali@frappe.io",
"module": "LMS",
"name": "LMS Settings",
+57 -9
View File
@@ -1,12 +1,13 @@
import unittest
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, is_certified
from .utils import (
get_average_rating,
get_chapters,
get_evaluator,
get_instructors,
get_lesson_index,
get_lesson_url,
@@ -23,7 +24,7 @@ from .utils import (
)
class TestUtils(unittest.TestCase):
class TestUtils(UnitTestCase):
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"])
@@ -31,7 +32,7 @@ class TestUtils(unittest.TestCase):
"frappe@example.com", "Frappe", "Admin", ["Moderator", "Course Creator", "Batch Evaluator"]
)
self.create_a_course()
self.course = self.create_a_course()
self.add_chapters()
self.add_lessons()
@@ -43,7 +44,14 @@ class TestUtils(unittest.TestCase):
self.create_certificate(self.course.name, self.student1.email)
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"
@@ -52,7 +60,7 @@ class TestUtils(unittest.TestCase):
course.published = 1
course.append("instructors", {"instructor": "frappe@example.com"})
course.save()
self.course = course
return course
def add_chapters(self):
chapters = []
@@ -66,7 +74,6 @@ class TestUtils(unittest.TestCase):
self.course.reload()
for chapter in chapters:
self.course.append("chapters", {"chapter": chapter.name})
self.course.save()
def add_lessons(self):
@@ -87,8 +94,43 @@ class TestUtils(unittest.TestCase):
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 not frappe.db.exists("User", email):
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
@@ -98,8 +140,6 @@ class TestUtils(unittest.TestCase):
user.append("roles", {"role": role})
user.save()
return user
else:
return frappe.get_doc("User", email)
def create_certificate(self, course_name, member):
certificate = frappe.new_doc("LMS Certificate")
@@ -232,7 +272,14 @@ class TestUtils(unittest.TestCase):
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)
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})
@@ -242,6 +289,7 @@ class TestUtils(unittest.TestCase):
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")
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff
+217 -457
View File
File diff suppressed because it is too large Load Diff
+218 -458
View File
File diff suppressed because it is too large Load Diff