Merge pull request #1340 from frappe/develop
chore: merge 'develop' into 'main'
This commit is contained in:
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
||||
openMode: 0,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://lms1:8000",
|
||||
baseUrl: "http://testui:8000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ describe("Course Creation", () => {
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("header").children().last().children().last().click();
|
||||
cy.get("button").contains("New").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
@@ -84,9 +84,8 @@ describe("Course Creation", () => {
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
|
||||
cy.get("#content .ce-block").type(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
"{enter}This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
cy.button("Save").click();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"@editorjs/table": "^2.4.2",
|
||||
"@vueuse/router": "^12.7.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"apexcharts": "^4.3.0",
|
||||
"chart.js": "^4.4.1",
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="batch.data.paid_batch && batch.data.seats_left"
|
||||
v-else-if="
|
||||
batch.data.paid_batch &&
|
||||
batch.data.seats_left > 0 &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<span>
|
||||
@@ -80,7 +84,11 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||
v-else-if="
|
||||
batch.data.allow_self_enrollment &&
|
||||
batch.data.seats_left &&
|
||||
batch.data.accept_enrollments
|
||||
"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
{{ __('Enroll Now') }}
|
||||
@@ -112,6 +120,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
|
||||
67
frontend/src/components/CertificationLinks.vue
Normal file
67
frontend/src/components/CertificationLinks.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
certification.data &&
|
||||
certification.data.membership &&
|
||||
certification.data.paid_certificate &&
|
||||
user.data?.is_student
|
||||
"
|
||||
>
|
||||
<router-link
|
||||
v-if="!certification.data.membership.purchased_certificate"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'certificate',
|
||||
name: courseName,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full">
|
||||
<template #prefix>
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Get Certified') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else-if="!certification.data.membership.certficate"
|
||||
:to="{
|
||||
name: 'CourseCertification',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full">
|
||||
<template #prefix>
|
||||
<GraduationCap class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Get Certified') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { inject } from 'vue'
|
||||
import { GraduationCap } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const certification = createResource({
|
||||
url: 'lms.lms.api.get_certification_details',
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
cache: ['certificationData', user.data?.name],
|
||||
})
|
||||
</script>
|
||||
@@ -100,9 +100,15 @@
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
</div>
|
||||
|
||||
<div class="font-semibold">
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
<div
|
||||
v-if="course.paid_certificate || course.enable_certification"
|
||||
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
|
||||
>
|
||||
{{ __('Certification') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,30 +6,32 @@
|
||||
class="rounded-t-md min-h-56 w-full"
|
||||
/>
|
||||
<div class="p-5">
|
||||
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
|
||||
<div v-if="course.data.paid_course" class="text-2xl font-semibold mb-3">
|
||||
{{ course.data.price }}
|
||||
</div>
|
||||
<router-link
|
||||
v-if="course.data.membership"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[0]
|
||||
: 1,
|
||||
lessonNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[1]
|
||||
: 1,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<span>
|
||||
{{ __('Continue Learning') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div v-if="course.data.membership" class="space-y-2">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[0]
|
||||
: 1,
|
||||
lessonNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[1]
|
||||
: 1,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<span>
|
||||
{{ __('Continue Learning') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<CertificationLinks :courseName="course.data.name" />
|
||||
</div>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
:to="{
|
||||
@@ -113,17 +115,36 @@
|
||||
{{ course.data.rating }} {{ __('Rating') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="course.data.enable_certification"
|
||||
class="flex items-center font-semibold text-ink-gray-9"
|
||||
>
|
||||
<GraduationCap class="h-4 w-4 stroke-2" />
|
||||
<span class="ml-2">
|
||||
{{ __('Certificate of Completion') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="course.data.paid_certificate"
|
||||
class="flex items-center font-semibold text-ink-gray-9"
|
||||
>
|
||||
<GraduationCap class="h-4 w-4 stroke-2" />
|
||||
<span class="ml-2">
|
||||
{{ __('Paid Certificate after Evaluation') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||
import { showToast, formatAmount } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="flex mt-2">
|
||||
<Star
|
||||
v-for="index in 5"
|
||||
class="h-5 w-5 text-ink-gray-2 rounded-sm mr-2"
|
||||
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
|
||||
:class="
|
||||
index <= Math.ceil(review.rating)
|
||||
? 'fill-orange-500'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex space-x-4 border rounded-md p-2">
|
||||
<Avatar :image="job.company_logo" :label="job.job_title" size="2xl" />
|
||||
<img :src="job.company_logo" class="size-10 rounded-full object-contain" />
|
||||
<div class="flex flex-col space-y-2 flex-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-ink-gray-9">
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<FormControl
|
||||
v-model="member.first_name"
|
||||
:placeholder="__('First Name')"
|
||||
type="test"
|
||||
type="text"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button @click="addMember()" variant="subtle">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource } from 'frappe-ui'
|
||||
import { ref, defineModel } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
FormControl,
|
||||
Switch,
|
||||
} from 'frappe-ui'
|
||||
import { defineModel, reactive, watch } from 'vue'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { showToast, getFileSize } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||
import { reactive, defineModel } from 'vue'
|
||||
import { reactive } from 'vue'
|
||||
import { showToast, singularize } from '@/utils'
|
||||
|
||||
const topics = defineModel('reloadTopics')
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
createResource,
|
||||
TextEditor,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch, defineModel } from 'vue'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize, showToast, escapeHTML } from '@/utils'
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Date') }}
|
||||
</div>
|
||||
<FormControl type="date" v-model="evaluation.date" />
|
||||
<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">
|
||||
@@ -58,7 +66,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
|
||||
import { defineModel, reactive, watch, inject } from 'vue'
|
||||
import { reactive, watch, inject } from 'vue'
|
||||
import { createToast, formatTime } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
@@ -161,6 +169,11 @@ const getCourses = () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (courses.length == 1) {
|
||||
evaluation.course = courses[0].value
|
||||
}
|
||||
|
||||
return courses
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<script setup>
|
||||
import { Dialog, FileUploader, Button, createResource } from 'frappe-ui'
|
||||
import { FileText } from 'lucide-vue-next'
|
||||
import { ref, inject, defineModel } from 'vue'
|
||||
import { ref, inject } from 'vue'
|
||||
import { createToast, getFileSize } from '@/utils/'
|
||||
|
||||
const resume = ref(null)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Textarea, createResource } from 'frappe-ui'
|
||||
import { defineModel, reactive } from 'vue'
|
||||
import { reactive } from 'vue'
|
||||
import Rating from '@/components/Controls/Rating.vue'
|
||||
import { createToast } from '@/utils/'
|
||||
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<Button @click="openEvalModal">
|
||||
<Button
|
||||
v-if="
|
||||
!upcoming_evals.data?.length ||
|
||||
upcoming_evals.length == courses.length
|
||||
"
|
||||
@click="openEvalModal"
|
||||
>
|
||||
{{ __('Schedule Evaluation') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border rounded-md p-3">
|
||||
<div class="font-semibold mb-3">
|
||||
@@ -28,17 +34,39 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<UserCog2 class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2 font-medium">
|
||||
<GraduationCap class="w-4 h-4 stroke-1.5" />
|
||||
<span class="ml-2">
|
||||
{{ evl.evaluator_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between space-x-2 mt-4">
|
||||
<Button
|
||||
v-if="evl.google_meet_link"
|
||||
@click="openEvalCall(evl)"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<HeadsetIcon class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Join Call') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="evl.date > dayjs().format()"
|
||||
@click="cancelEvaluation(evl)"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<Ban class="w-4 h-4 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('Cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-ink-gray-5">
|
||||
{{ __('No upcoming evaluations.') }}
|
||||
{{ __('Please schedule an evaluation to get certified.') }}
|
||||
</div>
|
||||
</div>
|
||||
<EvaluationModal
|
||||
@@ -50,15 +78,23 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Calendar, Clock, UserCog2 } from 'lucide-vue-next'
|
||||
import { inject, ref } from 'vue'
|
||||
import {
|
||||
Ban,
|
||||
Calendar,
|
||||
Clock,
|
||||
GraduationCap,
|
||||
HeadsetIcon,
|
||||
} from 'lucide-vue-next'
|
||||
import { inject, ref, getCurrentInstance } from 'vue'
|
||||
import { formatTime } from '../utils'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { Button, createResource, call } from 'frappe-ui'
|
||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject('$user')
|
||||
const showEvalModal = ref(false)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
@@ -77,10 +113,10 @@ const props = defineProps({
|
||||
|
||||
const upcoming_evals = createResource({
|
||||
url: 'lms.lms.utils.get_upcoming_evals',
|
||||
cache: ['upcoming_evals', user.data.name],
|
||||
params: {
|
||||
student: user.data.name,
|
||||
courses: props.courses.map((course) => course.course),
|
||||
batch: props.batch,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
@@ -88,4 +124,32 @@ const upcoming_evals = createResource({
|
||||
function openEvalModal() {
|
||||
showEvalModal.value = true
|
||||
}
|
||||
|
||||
const openEvalCall = (evl) => {
|
||||
window.open(evl.google_meet_link, '_blank')
|
||||
}
|
||||
|
||||
const cancelEvaluation = (evl) => {
|
||||
$dialog({
|
||||
title: __('Cancel this evaluation?'),
|
||||
message: __(
|
||||
'Are you sure you want to cancel this evaluation? This action cannot be undone.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Cancel'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
call('lms.lms.api.cancel_evaluation', { evaluation: evl }).then(
|
||||
() => {
|
||||
upcoming_evals.reload()
|
||||
}
|
||||
)
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -193,8 +193,9 @@
|
||||
<BulkCertificates v-model="openCertificateDialog" :batch="batch.data" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useRouteQuery } from '@vueuse/router'
|
||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import {
|
||||
@@ -270,7 +271,7 @@ const isStudent = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabIndex = useRouteQuery('tab', 0)
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
batchTabs.push({
|
||||
|
||||
@@ -12,20 +12,15 @@
|
||||
v-if="access.data?.access && orderSummary.data"
|
||||
class="pt-5 pb-10 mx-5"
|
||||
>
|
||||
<!-- <div class="mb-5">
|
||||
<div class="text-lg font-semibold">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex flex-col lg:flex-row justify-between">
|
||||
<div
|
||||
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 text-sm font-medium lg:w-1/4"
|
||||
class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4 lg:order-last mb-10 lg:mt-10 font-medium lg:w-1/3"
|
||||
>
|
||||
<div class="flex items-center justify-between space-x-2">
|
||||
<div class="flex items-baseline justify-between space-y-2">
|
||||
<div class="text-ink-gray-5">
|
||||
{{ __('Ordered Item') }}
|
||||
{{ __('Payment for ') }} {{ type }}:
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="leading-5">
|
||||
{{ orderSummary.data.title }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +121,7 @@
|
||||
<p class="text-ink-gray-5">
|
||||
{{
|
||||
__(
|
||||
'Make sure to enter the right billing name as the same will be used in your invoice.'
|
||||
'Make sure to enter the correct billing name as the same will be used in your invoice.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
@@ -140,10 +135,10 @@
|
||||
<div v-else-if="access.data?.message">
|
||||
<NotPermitted
|
||||
:text="access.data.message"
|
||||
:buttonLabel="
|
||||
type == 'course' ? 'Checkout Courses' : 'Checkout Batches'
|
||||
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
|
||||
:buttonLink="
|
||||
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
|
||||
"
|
||||
:buttonLink="type == 'course' ? '/lms/courses' : '/lms/batches'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!user.data?.name">
|
||||
@@ -163,7 +158,7 @@ import {
|
||||
Breadcrumbs,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, ref } from 'vue'
|
||||
import { reactive, inject, onMounted, computed } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
import { showToast } from '@/utils/'
|
||||
@@ -193,7 +188,7 @@ const props = defineProps({
|
||||
const access = createResource({
|
||||
url: 'lms.lms.api.validate_billing_access',
|
||||
params: {
|
||||
type: props.type,
|
||||
billing_type: props.type,
|
||||
name: props.name,
|
||||
},
|
||||
onSuccess(data) {
|
||||
@@ -206,7 +201,7 @@ const orderSummary = createResource({
|
||||
url: 'lms.lms.utils.get_order_summary',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||
docname: props.name,
|
||||
country: billingDetails.country,
|
||||
}
|
||||
@@ -236,22 +231,26 @@ const paymentLink = createResource({
|
||||
url: 'lms.lms.payments.get_payment_link',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||
docname: props.name,
|
||||
title: orderSummary.data.title,
|
||||
amount: orderSummary.data.original_amount,
|
||||
total_amount: orderSummary.data.amount,
|
||||
currency: orderSummary.data.currency,
|
||||
address: billingDetails,
|
||||
redirect_to: redirectTo.value,
|
||||
payment_for_certificate: props.type == 'certificate',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const generatePaymentLink = () => {
|
||||
console.log('called')
|
||||
paymentLink.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
console.log('validation start')
|
||||
if (!billingDetails.source) {
|
||||
return __('Please let us know where you heard about us from.')
|
||||
}
|
||||
@@ -330,6 +329,8 @@ const validateAddress = () => {
|
||||
!states.includes(billingDetails.state)
|
||||
)
|
||||
return 'Please enter a valid state with correct spelling and the first letter capitalized.'
|
||||
|
||||
console.log('validation address')
|
||||
}
|
||||
|
||||
const showError = (err) => {
|
||||
@@ -347,4 +348,14 @@ const changeCurrency = (country) => {
|
||||
billingDetails.country = country
|
||||
orderSummary.reload()
|
||||
}
|
||||
|
||||
const redirectTo = computed(() => {
|
||||
if (props.type == 'course') {
|
||||
return `/lms/courses/${props.name}`
|
||||
} else if (props.type == 'batch') {
|
||||
return `/lms/batches/${props.name}`
|
||||
} else if (props.type == 'certificate') {
|
||||
return `/lms/courses/${props.name}/certification`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
117
frontend/src/pages/CourseCertification.vue
Normal file
117
frontend/src/pages/CourseCertification.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<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="p-5">
|
||||
<div v-if="certificate.data && Object.keys(certificate.data).length">
|
||||
<div class="text-lg text-ink-gray-9 font-semibold mb-1">
|
||||
{{ __('Certification') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-9 text-sm">
|
||||
{{
|
||||
__(
|
||||
'You are already certified for this course. Click on the card below to open your certificate.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="border p-3 w-fit min-w-60 rounded-md space-y-2 hover:bg-surface-gray-1 cursor-pointer mt-5"
|
||||
@click="openCertificate(certificate.data)"
|
||||
>
|
||||
<div class="text-ink-gray-9 font-semibold">
|
||||
{{ courseTitle }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7 font-medium">
|
||||
{{ __('Issued On') }}:
|
||||
{{ dayjs(certificate.data.issue_date).format('DD MMM YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<UpcomingEvaluations v-if="courses.length" :courses="courses" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { Breadcrumbs, call, createResource } from 'frappe-ui'
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
|
||||
const courseTitle = ref(null)
|
||||
const evaluator = ref(null)
|
||||
const courses = ref([])
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchCourseDetails()
|
||||
})
|
||||
|
||||
const certificate = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
params: {
|
||||
doctype: 'LMS Certificate',
|
||||
filters: {
|
||||
member: user.data?.name,
|
||||
course: props.courseName,
|
||||
},
|
||||
fieldname: ['name', 'template', 'issue_date'],
|
||||
},
|
||||
auto: true,
|
||||
cache: [user.data?.name, props.courseName],
|
||||
})
|
||||
|
||||
const fetchCourseDetails = () => {
|
||||
call('frappe.client.get_value', {
|
||||
doctype: 'LMS Course',
|
||||
filters: { name: props.courseName },
|
||||
fieldname: ['title', 'evaluator'],
|
||||
}).then((data) => {
|
||||
courseTitle.value = data.title
|
||||
evaluator.value = data.evaluator
|
||||
populateCourses()
|
||||
})
|
||||
}
|
||||
|
||||
const populateCourses = () => {
|
||||
courses.value = [
|
||||
{
|
||||
course: props.courseName,
|
||||
title: courseTitle.value,
|
||||
evaluator: evaluator.value,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openCertificate = (certificate) => {
|
||||
window.open(
|
||||
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||
certificate.name
|
||||
}&format=${encodeURIComponent(certificate.template)}`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{
|
||||
label: __('Courses'),
|
||||
route: { name: 'Courses' },
|
||||
},
|
||||
{
|
||||
label: courseTitle.value,
|
||||
route: { name: 'CourseDetail', params: { courseName: props.courseName } },
|
||||
},
|
||||
{
|
||||
label: __('Certification'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Settings') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||
<div class="grid grid-cols-2 gap-10 mb-4">
|
||||
<div
|
||||
v-if="user.data?.is_moderator"
|
||||
class="flex flex-col space-y-4"
|
||||
@@ -188,43 +188,48 @@
|
||||
v-model="course.featured"
|
||||
:label="__('Featured')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.disable_self_learning"
|
||||
:label="__('Disable Self Enrollment')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container border-t">
|
||||
<div class="text-lg font-semibold mt-5 mb-4">
|
||||
{{ __('Pricing') }}
|
||||
<div class="container border-t space-y-4">
|
||||
<div class="text-lg font-semibold mt-5">
|
||||
{{ __('Pricing and Certification') }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="grid grid-cols-3">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_course"
|
||||
:label="__('Paid Course')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.enable_certification"
|
||||
:label="__('Completion Certificate')"
|
||||
/>
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
v-model="course.paid_certificate"
|
||||
:label="__('Paid Certificate')"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="course.course_price"
|
||||
:label="__('Course Price')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl v-model="course.course_price" :label="__('Amount')" />
|
||||
<Link
|
||||
doctype="Currency"
|
||||
v-model="course.currency"
|
||||
:filters="{ enabled: 1 }"
|
||||
:label="__('Currency')"
|
||||
/>
|
||||
<Link
|
||||
v-if="course.paid_certificate"
|
||||
doctype="Course Evaluator"
|
||||
v-model="course.evaluator"
|
||||
:label="__('Evaluator')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,8 +301,10 @@ const course = reactive({
|
||||
disable_self_learning: false,
|
||||
enable_certification: false,
|
||||
paid_course: false,
|
||||
paid_certificate: false,
|
||||
course_price: '',
|
||||
currency: '',
|
||||
evaluator: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -391,6 +398,7 @@ const courseResource = createResource({
|
||||
'paid_course',
|
||||
'featured',
|
||||
'enable_certification',
|
||||
'paid_certifiate',
|
||||
]
|
||||
for (let idx in checkboxes) {
|
||||
let key = checkboxes[idx]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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" />
|
||||
<CertificationLinks :courseName="courseName" />
|
||||
</header>
|
||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||
<div
|
||||
@@ -197,13 +198,14 @@ import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import CourseOutline from '@/components/CourseOutline.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { ChevronLeft, ChevronRight, GraduationCap } from 'lucide-vue-next'
|
||||
import Discussions from '@/components/Discussions.vue'
|
||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||
import EditorJS from '@editorjs/editorjs'
|
||||
import LessonContent from '@/components/LessonContent.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
const router = useRouter()
|
||||
|
||||
@@ -139,7 +139,7 @@ const renderEditor = (holder) => {
|
||||
const lesson = reactive({
|
||||
title: '',
|
||||
include_in_preview: false,
|
||||
body: 'Test',
|
||||
body: '',
|
||||
instructor_notes: '',
|
||||
content: '',
|
||||
})
|
||||
@@ -294,7 +294,7 @@ const convertToJSON = (lessonData) => {
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: video,
|
||||
file_type: 'video',
|
||||
file_type: video.split('.').pop(),
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ Audio')) {
|
||||
@@ -303,7 +303,7 @@ const convertToJSON = (lessonData) => {
|
||||
type: 'upload',
|
||||
data: {
|
||||
file_url: audio,
|
||||
file_type: 'audio',
|
||||
file_type: audio.split('.').pop(),
|
||||
},
|
||||
})
|
||||
} else if (block.includes('{{ PDF')) {
|
||||
|
||||
@@ -28,6 +28,12 @@ const routes = [
|
||||
component: () => import('@/pages/Lesson.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/certification',
|
||||
name: 'CourseCertification',
|
||||
component: () => import('@/pages/CourseCertification.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/courses/:courseName/learn/:chapterName',
|
||||
name: 'SCORMChapter',
|
||||
|
||||
@@ -50,10 +50,18 @@ export class Markdown {
|
||||
this.wrapper.innerHTML = this.text
|
||||
|
||||
this.wrapper.addEventListener('keydown', (event) => {
|
||||
const value = event.target.textContent
|
||||
let value = event.target.textContent
|
||||
if (event.keyCode === 32 && value.startsWith('#')) {
|
||||
this.convertToHeader(event, value)
|
||||
} else if (event.keyCode === 13) {
|
||||
} else if (event.keyCode == 189) {
|
||||
this.convertBlock('list', {
|
||||
style: 'unordered',
|
||||
})
|
||||
} else if (/^[a-zA-Z]/.test(event.key)) {
|
||||
this.convertBlock('paragraph', {
|
||||
text: value,
|
||||
})
|
||||
} else if (event.keyCode === 13 || event.keyCode === 190) {
|
||||
this.parseContent(event)
|
||||
}
|
||||
})
|
||||
@@ -75,7 +83,11 @@ export class Markdown {
|
||||
|
||||
parseContent(event) {
|
||||
event.preventDefault()
|
||||
const previousLine = this.wrapper.textContent
|
||||
let previousLine = this.wrapper.textContent
|
||||
if (event.keyCode === 190) {
|
||||
previousLine = previousLine + '.'
|
||||
}
|
||||
|
||||
if (previousLine && this.hasImage(previousLine)) {
|
||||
this.wrapper.textContent = ''
|
||||
this.convertBlock('image')
|
||||
@@ -94,12 +106,12 @@ export class Markdown {
|
||||
},
|
||||
],
|
||||
})
|
||||
} else if (previousLine && previousLine.startsWith('1. ')) {
|
||||
} else if (previousLine && previousLine.startsWith('1.')) {
|
||||
this.convertBlock('list', {
|
||||
style: 'ordered',
|
||||
items: [
|
||||
{
|
||||
content: previousLine.replace('1. ', ''),
|
||||
content: previousLine.replace('1.', ''),
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -108,6 +120,10 @@ export class Markdown {
|
||||
this.convertBlock('embed', {
|
||||
source: previousLine,
|
||||
})
|
||||
} else {
|
||||
this.convertBlock('paragraph', {
|
||||
text: previousLine,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from frappe.translate import get_all_translations
|
||||
from frappe import _
|
||||
from frappe.utils import (
|
||||
get_datetime,
|
||||
getdate,
|
||||
cint,
|
||||
flt,
|
||||
now,
|
||||
@@ -189,24 +190,24 @@ def get_translations():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_billing_access(type, name):
|
||||
def validate_billing_access(billing_type, name):
|
||||
access = True
|
||||
message = ""
|
||||
doctype = "LMS Course" if type == "course" else "LMS Batch"
|
||||
doctype = "LMS Batch" if billing_type == "batch" else "LMS Course"
|
||||
|
||||
if frappe.session.user == "Guest":
|
||||
access = False
|
||||
message = _("Please login to continue with payment.")
|
||||
|
||||
if type not in ["course", "batch"]:
|
||||
if access and billing_type not in ["course", "batch", "certificate"]:
|
||||
access = False
|
||||
message = _("Module is incorrect.")
|
||||
|
||||
if not frappe.db.exists(doctype, name):
|
||||
if access and not frappe.db.exists(doctype, name):
|
||||
access = False
|
||||
message = _("Module Name is incorrect or does not exist.")
|
||||
|
||||
if type == "course":
|
||||
if access and billing_type == "course":
|
||||
membership = frappe.db.exists(
|
||||
"LMS Enrollment", {"member": frappe.session.user, "course": name}
|
||||
)
|
||||
@@ -214,7 +215,7 @@ def validate_billing_access(type, name):
|
||||
access = False
|
||||
message = _("You are already enrolled for this course.")
|
||||
|
||||
else:
|
||||
elif access and billing_type == "batch":
|
||||
membership = frappe.db.exists(
|
||||
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
|
||||
)
|
||||
@@ -222,6 +223,19 @@ def validate_billing_access(type, name):
|
||||
access = False
|
||||
message = _("You are already enrolled for this batch.")
|
||||
|
||||
elif access and billing_type == "certificate":
|
||||
purchased_certificate = frappe.db.exists(
|
||||
"LMS Enrollment",
|
||||
{
|
||||
"course": name,
|
||||
"member": frappe.session.user,
|
||||
"purchased_certificate": 1,
|
||||
},
|
||||
)
|
||||
if purchased_certificate:
|
||||
access = False
|
||||
message = _("You have already purchased the certificate for this course.")
|
||||
|
||||
address = frappe.db.get_value(
|
||||
"Address",
|
||||
{"email_id": frappe.session.user},
|
||||
@@ -369,7 +383,7 @@ def get_evaluator_details(evaluator):
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
|
||||
def get_certified_participants(filters=None, start=0, page_length=30):
|
||||
or_filters = {}
|
||||
if not filters:
|
||||
filters = {}
|
||||
@@ -1228,3 +1242,57 @@ def get_notifications(filters):
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def is_guest_allowed():
|
||||
return frappe.get_cached_value("LMS Settings", None, "allow_guest_access")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_evaluation(evaluation):
|
||||
evaluation = frappe._dict(evaluation)
|
||||
|
||||
if evaluation.member != frappe.session.user:
|
||||
return
|
||||
|
||||
frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Cancelled")
|
||||
events = frappe.get_all(
|
||||
"Event Participants",
|
||||
{
|
||||
"email": evaluation.member,
|
||||
},
|
||||
["parent", "name"],
|
||||
)
|
||||
|
||||
for event in events:
|
||||
info = frappe.db.get_value("Event", event.parent, ["starts_on", "subject"], as_dict=1)
|
||||
date = str(info.starts_on).split(" ")[0]
|
||||
|
||||
if (
|
||||
date == str(evaluation.date.format("YYYY-MM-DD"))
|
||||
and evaluation.member_name in info.subject
|
||||
):
|
||||
communication = frappe.db.get_value(
|
||||
"Communication",
|
||||
{"reference_doctype": "Event", "reference_name": event.parent},
|
||||
"name",
|
||||
)
|
||||
if communication:
|
||||
frappe.delete_doc("Communication", communication, ignore_permissions=True)
|
||||
|
||||
frappe.delete_doc("Event Participants", event.name, ignore_permissions=True)
|
||||
frappe.delete_doc("Event", event.parent, ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_certification_details(course):
|
||||
membership = None
|
||||
filters = {"course": course, "member": frappe.session.user}
|
||||
|
||||
if frappe.db.exists("LMS Enrollment", filters):
|
||||
membership = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
filters,
|
||||
["name", "certificate", "purchased_certificate"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
paid_certificate = frappe.db.get_value("LMS Course", course, "paid_certificate")
|
||||
|
||||
return {"membership": membership, "paid_certificate": paid_certificate}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-04-15 18:45:08.614466",
|
||||
"modified": "2025-02-24 12:17:08.436659",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "Course Evaluator",
|
||||
|
||||
@@ -10,7 +10,6 @@ from datetime import timedelta
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
|
||||
from lms.lms.utils import (
|
||||
get_lessons,
|
||||
get_lesson_index,
|
||||
get_lesson_url,
|
||||
get_quiz_details,
|
||||
@@ -176,6 +175,10 @@ def create_live_class(
|
||||
class_details = frappe.get_doc(payload)
|
||||
class_details.save()
|
||||
return class_details
|
||||
else:
|
||||
frappe.throw(
|
||||
_("Error creating live class. Please try again. {0}").format(response.text)
|
||||
)
|
||||
|
||||
|
||||
def authenticate():
|
||||
@@ -254,17 +257,6 @@ def create_batch(
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def fetch_lessons(courses):
|
||||
lessons = []
|
||||
courses = json.loads(courses)
|
||||
|
||||
for course in courses:
|
||||
lessons.extend(get_lessons(course.get("course")))
|
||||
|
||||
return lessons
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_course(course, parent, name=None, evaluator=None):
|
||||
frappe.only_for("Moderator")
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-17 11:57:02.859109",
|
||||
"modified": "2025-02-19 16:26:05.902473",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate",
|
||||
@@ -156,6 +156,7 @@
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"if_owner": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
|
||||
@@ -122,11 +122,6 @@ def create_certificate(course):
|
||||
)
|
||||
|
||||
else:
|
||||
expires_after_yrs = int(frappe.db.get_value("LMS Course", course, "expiry"))
|
||||
expiry_date = None
|
||||
if expires_after_yrs:
|
||||
expiry_date = add_years(nowdate(), expires_after_yrs)
|
||||
|
||||
default_certificate_template = frappe.db.get_value(
|
||||
"Property Setter",
|
||||
{
|
||||
@@ -148,7 +143,6 @@ def create_certificate(course):
|
||||
"member": frappe.session.user,
|
||||
"course": course,
|
||||
"issue_date": nowdate(),
|
||||
"expiry_date": expiry_date,
|
||||
"template": default_certificate_template,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ class TestLMSCertificate(unittest.TestCase):
|
||||
"Test Certificate",
|
||||
{
|
||||
"enable_certification": 1,
|
||||
"expiry": 2,
|
||||
},
|
||||
)
|
||||
certificate = create_certificate(course.name)
|
||||
@@ -24,7 +23,6 @@ class TestLMSCertificate(unittest.TestCase):
|
||||
self.assertEqual(certificate.member, "Administrator")
|
||||
self.assertEqual(certificate.course, course.name)
|
||||
self.assertEqual(certificate.issue_date, nowdate())
|
||||
self.assertEqual(certificate.expiry_date, add_years(nowdate(), cint(course.expiry)))
|
||||
|
||||
frappe.db.delete("LMS Certificate", certificate.name)
|
||||
frappe.db.delete("LMS Course", course.name)
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"google_meet_link",
|
||||
"column_break_ddyh",
|
||||
"start_time",
|
||||
"end_time"
|
||||
"end_time",
|
||||
"status"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -144,11 +145,19 @@
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Batch Title"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"options": "Upcoming\nCompleted\nCancelled",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-11 11:19:44.669132",
|
||||
"modified": "2025-02-19 17:20:02.526294",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Certificate Request",
|
||||
@@ -204,6 +213,19 @@
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"states": [
|
||||
{
|
||||
"color": "Blue",
|
||||
"title": "Upcoming"
|
||||
},
|
||||
{
|
||||
"color": "Green",
|
||||
"title": "Completed"
|
||||
},
|
||||
{
|
||||
"color": "Red",
|
||||
"title": "Cancelled"
|
||||
}
|
||||
],
|
||||
"title_field": "member_name"
|
||||
}
|
||||
@@ -40,15 +40,13 @@
|
||||
"pricing_tab",
|
||||
"pricing_section",
|
||||
"paid_course",
|
||||
"enable_certification",
|
||||
"paid_certificate",
|
||||
"evaluator",
|
||||
"column_break_acoj",
|
||||
"course_price",
|
||||
"currency",
|
||||
"amount_usd",
|
||||
"certification_tab",
|
||||
"certification_section",
|
||||
"enable_certification",
|
||||
"column_break_rxww",
|
||||
"expiry",
|
||||
"tab_4_tab",
|
||||
"statistics_section",
|
||||
"enrollments",
|
||||
@@ -134,22 +132,11 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Course Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "certification_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_certification",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Certification"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "enable_certification",
|
||||
"fieldname": "expiry",
|
||||
"fieldtype": "Int",
|
||||
"label": "Certification Expires After (Years)"
|
||||
"label": "Completion Certificate"
|
||||
},
|
||||
{
|
||||
"fieldname": "related_courses",
|
||||
@@ -181,7 +168,6 @@
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "paid_course",
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
@@ -195,22 +181,16 @@
|
||||
"label": "Paid Course"
|
||||
},
|
||||
{
|
||||
"depends_on": "paid_course",
|
||||
"fieldname": "course_price",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Course Price",
|
||||
"label": "Amount",
|
||||
"mandatory_depends_on": "paid_course"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_rxww",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_acoj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "paid_course",
|
||||
"description": "If you set an amount here, then the USD equivalent setting will not get applied.",
|
||||
"fieldname": "amount_usd",
|
||||
"fieldtype": "Currency",
|
||||
@@ -238,12 +218,7 @@
|
||||
{
|
||||
"fieldname": "pricing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Pricing"
|
||||
},
|
||||
{
|
||||
"fieldname": "certification_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Certification"
|
||||
"label": "Pricing and Certification"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_htgn",
|
||||
@@ -284,6 +259,19 @@
|
||||
"fieldtype": "Data",
|
||||
"label": "Rating",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "paid_certificate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Paid Certificate"
|
||||
},
|
||||
{
|
||||
"depends_on": "paid_certificate",
|
||||
"fieldname": "evaluator",
|
||||
"fieldtype": "Link",
|
||||
"label": "Evaluator",
|
||||
"options": "Course Evaluator"
|
||||
}
|
||||
],
|
||||
"is_published_field": "published",
|
||||
@@ -310,7 +298,7 @@
|
||||
}
|
||||
],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2024-10-30 23:08:31.842860",
|
||||
"modified": "2025-02-24 11:50:58.325804",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Course",
|
||||
|
||||
@@ -5,9 +5,8 @@ import json
|
||||
import random
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, today
|
||||
from frappe.utils.telemetry import capture
|
||||
from lms.lms.utils import get_chapters, can_create_courses
|
||||
from frappe.utils import today, cint
|
||||
from lms.lms.utils import get_chapters
|
||||
from ...utils import generate_slug, validate_image, update_payment_record
|
||||
from frappe import _
|
||||
|
||||
@@ -19,6 +18,7 @@ class LMSCourse(Document):
|
||||
self.validate_video_link()
|
||||
self.validate_status()
|
||||
self.validate_payments_app()
|
||||
self.validate_certification()
|
||||
self.validate_amount_and_currency()
|
||||
self.image = validate_image(self.image)
|
||||
|
||||
@@ -52,10 +52,22 @@ class LMSCourse(Document):
|
||||
if "payments" not in installed_apps:
|
||||
frappe.throw(_("Please install the Payments app to create a paid courses."))
|
||||
|
||||
def validate_certification(self):
|
||||
if self.enable_certification and self.paid_certificate:
|
||||
frappe.throw(
|
||||
_("A course cannot have both paid certificate and certificate of completion.")
|
||||
)
|
||||
|
||||
if self.paid_certificate and not self.evaluator:
|
||||
frappe.throw(_("Evaluator is required for paid certificates."))
|
||||
|
||||
def validate_amount_and_currency(self):
|
||||
if self.paid_course and (not self.course_price and not self.currency):
|
||||
if self.paid_course and (cint(self.course_price) < 0 or not self.currency):
|
||||
frappe.throw(_("Amount and currency are required for paid courses."))
|
||||
|
||||
if self.paid_certificate and (cint(self.course_price) <= 0 or not self.currency):
|
||||
frappe.throw(_("Amount and currency are required for paid certificates."))
|
||||
|
||||
def on_update(self):
|
||||
if not self.upcoming and self.has_value_changed("upcoming"):
|
||||
self.send_email_to_interested_users()
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"member",
|
||||
"member_name",
|
||||
"member_username",
|
||||
"certification_section",
|
||||
"purchased_certificate",
|
||||
"certificate",
|
||||
"section_break_8",
|
||||
"cohort",
|
||||
"subgroup",
|
||||
@@ -123,11 +126,28 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Payment",
|
||||
"options": "LMS Payment"
|
||||
},
|
||||
{
|
||||
"fieldname": "certification_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Certification"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "purchased_certificate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Purchased Certificate"
|
||||
},
|
||||
{
|
||||
"fieldname": "certificate",
|
||||
"fieldtype": "Link",
|
||||
"label": "Certificate",
|
||||
"options": "LMS Certificate"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-10-30 12:44:16.103598",
|
||||
"modified": "2025-02-21 17:11:37.986157",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Enrollment",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"payment_for_document_type",
|
||||
"payment_for_document",
|
||||
"payment_received",
|
||||
"payment_for_certificate",
|
||||
"payment_details_section",
|
||||
"currency",
|
||||
"amount",
|
||||
@@ -136,6 +137,12 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Source",
|
||||
"options": "LMS Source"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "payment_for_certificate",
|
||||
"fieldtype": "Check",
|
||||
"label": "Payment for Certificate"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -149,7 +156,7 @@
|
||||
"link_fieldname": "payment"
|
||||
}
|
||||
],
|
||||
"modified": "2025-02-18 15:54:25.383353",
|
||||
"modified": "2025-02-21 18:29:55.436611",
|
||||
"modified_by": "Administrator",
|
||||
"module": "LMS",
|
||||
"name": "LMS Payment",
|
||||
|
||||
@@ -18,19 +18,26 @@ def validate_currency(payment_gateway, currency):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_link(doctype, docname, title, amount, total_amount, currency, address):
|
||||
def get_payment_link(
|
||||
doctype,
|
||||
docname,
|
||||
title,
|
||||
amount,
|
||||
total_amount,
|
||||
currency,
|
||||
address,
|
||||
redirect_to,
|
||||
payment_for_certificate,
|
||||
):
|
||||
payment_gateway = get_payment_gateway()
|
||||
address = frappe._dict(address)
|
||||
amount_with_gst = total_amount if total_amount != amount else 0
|
||||
|
||||
payment = record_payment(address, doctype, docname, amount, currency, amount_with_gst)
|
||||
payment = record_payment(
|
||||
address, doctype, docname, amount, currency, amount_with_gst, payment_for_certificate
|
||||
)
|
||||
controller = get_controller(payment_gateway)
|
||||
|
||||
if doctype == "LMS Course":
|
||||
redirect_to = f"/lms/courses/{docname}/learn/1-1"
|
||||
elif doctype == "LMS Batch":
|
||||
redirect_to = f"/lms/batches/{docname}"
|
||||
|
||||
payment_details = {
|
||||
"amount": total_amount,
|
||||
"title": f"Payment for {doctype} {title} {docname}",
|
||||
@@ -53,7 +60,15 @@ def get_payment_link(doctype, docname, title, amount, total_amount, currency, ad
|
||||
return url
|
||||
|
||||
|
||||
def record_payment(address, doctype, docname, amount, currency, amount_with_gst=0):
|
||||
def record_payment(
|
||||
address,
|
||||
doctype,
|
||||
docname,
|
||||
amount,
|
||||
currency,
|
||||
amount_with_gst=0,
|
||||
payment_for_certificate=0,
|
||||
):
|
||||
address = frappe._dict(address)
|
||||
address_name = save_address(address)
|
||||
|
||||
@@ -71,6 +86,7 @@ def record_payment(address, doctype, docname, amount, currency, amount_with_gst=
|
||||
"source": address.source,
|
||||
"payment_for_document_type": doctype,
|
||||
"payment_for_document": docname,
|
||||
"payment_for_certificate": payment_for_certificate,
|
||||
}
|
||||
)
|
||||
payment_doc.save(ignore_permissions=True)
|
||||
|
||||
111
lms/lms/utils.py
111
lms/lms/utils.py
@@ -68,27 +68,26 @@ def generate_slug(title, doctype):
|
||||
return slugify(title, used_slugs=slugs)
|
||||
|
||||
|
||||
def get_membership(course, member=None, batch=None):
|
||||
def get_membership(course, member=None):
|
||||
if not member:
|
||||
member = frappe.session.user
|
||||
|
||||
filters = {"member": member, "course": course}
|
||||
if batch:
|
||||
filters["batch_old"] = batch
|
||||
|
||||
is_member = frappe.db.exists("LMS Enrollment", filters)
|
||||
if is_member:
|
||||
if frappe.db.exists("LMS Enrollment", filters):
|
||||
membership = frappe.db.get_value(
|
||||
"LMS Enrollment",
|
||||
filters,
|
||||
["name", "batch_old", "current_lesson", "member_type", "progress", "member"],
|
||||
[
|
||||
"name",
|
||||
"current_lesson",
|
||||
"progress",
|
||||
"member",
|
||||
"purchased_certificate",
|
||||
"certificate",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if membership and membership.batch_old:
|
||||
membership.batch_title = frappe.db.get_value(
|
||||
"LMS Batch Old", membership.batch_old, "title"
|
||||
)
|
||||
return membership
|
||||
|
||||
return False
|
||||
@@ -856,26 +855,44 @@ def is_onboarding_complete():
|
||||
}
|
||||
|
||||
|
||||
def get_evaluator(course, batch):
|
||||
def get_evaluator(course, batch=None):
|
||||
evaluator = None
|
||||
evaluator = frappe.db.get_value(
|
||||
"Batch Course",
|
||||
{"parent": batch, "course": course},
|
||||
"evaluator",
|
||||
)
|
||||
if batch:
|
||||
evaluator = frappe.db.get_value(
|
||||
"Batch Course",
|
||||
{"parent": batch, "course": course},
|
||||
"evaluator",
|
||||
)
|
||||
else:
|
||||
evaluator = frappe.db.get_value("LMS Course", course, "evaluator")
|
||||
return evaluator
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_upcoming_evals(student, courses):
|
||||
def get_upcoming_evals(student, courses, batch=None):
|
||||
filters = {
|
||||
"member": student,
|
||||
"course": ["in", courses],
|
||||
"date": [">=", frappe.utils.nowdate()],
|
||||
"status": "Upcoming",
|
||||
}
|
||||
|
||||
if batch:
|
||||
filters["batch_name"] = batch
|
||||
|
||||
upcoming_evals = frappe.get_all(
|
||||
"LMS Certificate Request",
|
||||
{
|
||||
"member": student,
|
||||
"course": ["in", courses],
|
||||
"date": [">=", frappe.utils.nowdate()],
|
||||
},
|
||||
["date", "start_time", "course", "evaluator", "google_meet_link"],
|
||||
filters,
|
||||
[
|
||||
"name",
|
||||
"date",
|
||||
"start_time",
|
||||
"course",
|
||||
"evaluator",
|
||||
"google_meet_link",
|
||||
"member",
|
||||
"member_name",
|
||||
],
|
||||
order_by="date",
|
||||
)
|
||||
|
||||
@@ -999,6 +1016,7 @@ def get_course_details(course):
|
||||
"category",
|
||||
"status",
|
||||
"paid_course",
|
||||
"paid_certificate",
|
||||
"course_price",
|
||||
"currency",
|
||||
"amount_usd",
|
||||
@@ -1013,7 +1031,7 @@ def get_course_details(course):
|
||||
|
||||
course_details.instructors = get_instructors(course_details.name)
|
||||
# course_details.is_instructor = is_instructor(course_details.name)
|
||||
if course_details.paid_course:
|
||||
if course_details.paid_course or course_details.paid_certificate:
|
||||
"""course_details.course_price, course_details.currency = check_multicurrency(
|
||||
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
||||
)"""
|
||||
@@ -1126,14 +1144,21 @@ def get_lesson(course, chapter, lesson):
|
||||
return {}
|
||||
|
||||
membership = get_membership(course)
|
||||
course_title = frappe.db.get_value("LMS Course", course, "title")
|
||||
course_info = frappe.db.get_value(
|
||||
"LMS Course", course, ["title", "paid_certificate"], as_dict=1
|
||||
)
|
||||
|
||||
if (
|
||||
not lesson_details.include_in_preview
|
||||
and not membership
|
||||
and not has_course_moderator_role()
|
||||
and not is_instructor(course)
|
||||
):
|
||||
return {"no_preview": 1, "title": lesson_details.title, "course_title": course_title}
|
||||
return {
|
||||
"no_preview": 1,
|
||||
"title": lesson_details.title,
|
||||
"course_title": course_info.title,
|
||||
}
|
||||
|
||||
lesson_details = frappe.db.get_value(
|
||||
"Course Lesson",
|
||||
@@ -1168,7 +1193,8 @@ def get_lesson(course, chapter, lesson):
|
||||
lesson_details.prev = neighbours["prev"]
|
||||
lesson_details.membership = membership
|
||||
lesson_details.instructors = get_instructors(course)
|
||||
lesson_details.course_title = course_title
|
||||
lesson_details.course_title = course_info.title
|
||||
lesson_details.paid_certificate = course_info.paid_certificate
|
||||
return lesson_details
|
||||
|
||||
|
||||
@@ -1233,6 +1259,7 @@ def get_batch_details(batch):
|
||||
)
|
||||
|
||||
batch_details.instructors = get_instructors(batch)
|
||||
batch_details.accept_enrollments = batch_details.start_date > getdate()
|
||||
|
||||
batch_details.courses = frappe.get_all(
|
||||
"Batch Course", filters={"parent": batch}, fields=["course", "title", "evaluator"]
|
||||
@@ -1601,11 +1628,19 @@ def get_order_summary(doctype, docname, country=None):
|
||||
details = frappe.db.get_value(
|
||||
"LMS Course",
|
||||
docname,
|
||||
["title", "name", "paid_course", "course_price as amount", "currency", "amount_usd"],
|
||||
[
|
||||
"title",
|
||||
"name",
|
||||
"paid_course",
|
||||
"paid_certificate",
|
||||
"course_price as amount",
|
||||
"currency",
|
||||
"amount_usd",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
if not details.paid_course:
|
||||
if not details.paid_course and not details.paid_certificate:
|
||||
raise frappe.throw(_("This course is free."))
|
||||
|
||||
else:
|
||||
@@ -1719,9 +1754,14 @@ def update_payment_record(doctype, docname):
|
||||
"order_id": data.get("order_id"),
|
||||
},
|
||||
)
|
||||
payment_for_certificate = frappe.db.get_value(
|
||||
"LMS Payment", data.payment, "payment_for_certificate"
|
||||
)
|
||||
|
||||
try:
|
||||
if doctype == "LMS Course":
|
||||
if payment_for_certificate:
|
||||
update_certificate_purchase(docname)
|
||||
elif doctype == "LMS Course":
|
||||
enroll_in_course(data.payment, docname)
|
||||
else:
|
||||
enroll_in_batch(docname, data.payment)
|
||||
@@ -1781,6 +1821,15 @@ def enroll_in_batch(batch, payment_name=None):
|
||||
new_student.save()
|
||||
|
||||
|
||||
def update_certificate_purchase(course):
|
||||
frappe.db.set_value(
|
||||
"LMS Enrollment",
|
||||
{"member": frappe.session.user, "course": course},
|
||||
"purchased_certificate",
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_programs():
|
||||
if (
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
{% set chapters = get_chapters(course.name) %}
|
||||
{% set is_instructor = is_instructor(course.name) %}
|
||||
|
||||
{% if chapters | length %}
|
||||
<div class="course-home-outline">
|
||||
|
||||
{% if not lesson_page %}
|
||||
<div class="page-title mb-8" id="outline-heading" data-course="{{ course.name }}">
|
||||
{{ _("Course Content") }}
|
||||
</div>
|
||||
|
||||
<!-- <div class="mb-2">
|
||||
<span>
|
||||
{{ chapters | length }} chapters
|
||||
</span>
|
||||
<span>
|
||||
. {{ get_lessons(course.name, None, False) }} lessons
|
||||
</span>
|
||||
</div> -->
|
||||
{% endif %}
|
||||
|
||||
{% if chapters | length %}
|
||||
<div>
|
||||
{% for chapter in chapters %}
|
||||
{% set lessons = get_lessons(course.name, chapter) %}
|
||||
|
||||
<div class="chapter-parent" data-chapter="{{ chapter.name }}">
|
||||
|
||||
<div class="chapter-title" data-toggle="collapse" aria-expanded="false"
|
||||
data-target="#{{ get_slugified_chapter_title(chapter.title) }}">
|
||||
|
||||
<img class="chapter-icon" src="/assets/lms/icons/chevron-right.svg">
|
||||
<div class="chapter-title-main">
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
<!-- <div class="small ml-auto">
|
||||
{{ lessons | length }} lessons
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
|
||||
<div class="chapter-content collapse navbar-collapse" id="{{ get_slugified_chapter_title(chapter.title) }}">
|
||||
|
||||
{% if chapter.description %}
|
||||
<div class="chapter-description">
|
||||
{{ chapter.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="lessons">
|
||||
|
||||
{% if lessons | length %}
|
||||
|
||||
{% for lesson in lessons %}
|
||||
{% set active = membership.current_lesson == lesson.name %}
|
||||
<div data-lesson="{{ lesson.name }}" class="lesson-info {% if active %} active-lesson {% endif %}">
|
||||
|
||||
{% if membership or lesson.include_in_preview or is_instructor or has_course_moderator_role() %}
|
||||
<a class="lesson-links"
|
||||
href="{{ get_lesson_url(course.name, lesson.number) }}{% if classname %}?class={{ classname }}{% endif %}{{course.query_parameter}}"
|
||||
{% if is_instructor and not lesson.include_in_preview %}
|
||||
title="{{ _('This lesson is not available for preview. As you are the Instructor of the course only you can see it.') }}"
|
||||
{% endif %}>
|
||||
|
||||
<svg class="icon icon-sm mr-2">
|
||||
<use class="" href="#{{ lesson.icon }}">
|
||||
</svg>
|
||||
|
||||
<span>{{ lesson.title }}</span>
|
||||
|
||||
{% if membership %}
|
||||
<svg class="icon icon-md lesson-progress-tick ml-auto {{ get_progress(course.name, lesson.name) != 'Complete' and 'hide' }}">
|
||||
<use class="" href="#icon-success">
|
||||
</svg>
|
||||
{% endif %}
|
||||
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<div class="no-preview" title="This lesson is not available for preview">
|
||||
<div class="lesson-links">
|
||||
<svg class="icon icon-sm mr-2">
|
||||
<use class="" href="#icon-lock-gray">
|
||||
</svg>
|
||||
<div>{{ lesson.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if chapters | length %}
|
||||
<!-- No Preview Modal -->
|
||||
{{ widgets.NoPreviewModal(course=course, membership=membership) }}
|
||||
|
||||
{% endif %}
|
||||
348
lms/locale/ar.po
348
lms/locale/ar.po
File diff suppressed because it is too large
Load Diff
396
lms/locale/bs.po
396
lms/locale/bs.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/de.po
348
lms/locale/de.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/eo.po
348
lms/locale/eo.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/es.po
348
lms/locale/es.po
File diff suppressed because it is too large
Load Diff
364
lms/locale/fa.po
364
lms/locale/fa.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/fr.po
348
lms/locale/fr.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/hu.po
348
lms/locale/hu.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
348
lms/locale/pl.po
348
lms/locale/pl.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/ru.po
348
lms/locale/ru.po
File diff suppressed because it is too large
Load Diff
350
lms/locale/sv.po
350
lms/locale/sv.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/tr.po
348
lms/locale/tr.po
File diff suppressed because it is too large
Load Diff
348
lms/locale/zh.po
348
lms/locale/zh.po
File diff suppressed because it is too large
Load Diff
@@ -100,4 +100,5 @@ lms.patches.v2_0.convert_quiz_duration_to_minutes
|
||||
lms.patches.v2_0.allow_guest_access #05-02-2025
|
||||
lms.patches.v2_0.migrate_batch_student_data #10-02-2025
|
||||
lms.patches.v2_0.delete_old_enrollment_doctypes
|
||||
lms.patches.v2_0.delete_unused_custom_fields
|
||||
lms.patches.v2_0.delete_unused_custom_fields
|
||||
lms.patches.v2_0.update_certificate_request_status
|
||||
14
lms/patches/v2_0/update_certificate_request_status.py
Normal file
14
lms/patches/v2_0/update_certificate_request_status.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
|
||||
def execute():
|
||||
evaluations = frappe.get_all("LMS Certificate Request", fields=["name", "date"])
|
||||
|
||||
for evaluation in evaluations:
|
||||
if evaluation.date > getdate():
|
||||
frappe.db.set_value("LMS Certificate Request", evaluation.name, "status", "Upcoming")
|
||||
else:
|
||||
frappe.db.set_value(
|
||||
"LMS Certificate Request", evaluation.name, "status", "Completed"
|
||||
)
|
||||
Reference in New Issue
Block a user