feat: paid certifications on courses
This commit is contained in:
@@ -100,9 +100,15 @@
|
|||||||
<CourseInstructors :instructors="course.instructors" />
|
<CourseInstructors :instructors="course.instructors" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="font-semibold">
|
<div v-if="course.paid_course" class="font-semibold">
|
||||||
{{ course.price }}
|
{{ course.price }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="course.paid_certificate"
|
||||||
|
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
|
||||||
|
>
|
||||||
|
{{ __('Paid Certificate') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
class="rounded-t-md min-h-56 w-full"
|
class="rounded-t-md min-h-56 w-full"
|
||||||
/>
|
/>
|
||||||
<div class="p-5">
|
<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 }}
|
{{ course.data.price }}
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
@@ -113,12 +113,21 @@
|
|||||||
{{ course.data.rating }} {{ __('Rating') }}
|
{{ course.data.rating }} {{ __('Rating') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 { computed, inject } from 'vue'
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { showToast, formatAmount } from '@/utils/'
|
import { showToast, formatAmount } from '@/utils/'
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<div class="flex mt-2">
|
<div class="flex mt-2">
|
||||||
<Star
|
<Star
|
||||||
v-for="index in 5"
|
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="
|
:class="
|
||||||
index <= Math.ceil(review.rating)
|
index <= Math.ceil(review.rating)
|
||||||
? 'fill-orange-500'
|
? 'fill-orange-500'
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
<div
|
<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 text-sm font-medium lg:w-1/4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between space-x-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<div class="text-ink-gray-5">
|
<div class="text-ink-gray-5">
|
||||||
{{ __('Ordered Item') }}
|
{{ __('Payment for ') }} {{ type }}:
|
||||||
</div>
|
</div>
|
||||||
<div class="">
|
<div class="leading-5">
|
||||||
{{ orderSummary.data.title }}
|
{{ orderSummary.data.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
<p class="text-ink-gray-5">
|
<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>
|
</p>
|
||||||
@@ -140,10 +140,10 @@
|
|||||||
<div v-else-if="access.data?.message">
|
<div v-else-if="access.data?.message">
|
||||||
<NotPermitted
|
<NotPermitted
|
||||||
:text="access.data.message"
|
:text="access.data.message"
|
||||||
:buttonLabel="
|
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
|
||||||
type == 'course' ? 'Checkout Courses' : 'Checkout Batches'
|
:buttonLink="
|
||||||
|
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
|
||||||
"
|
"
|
||||||
:buttonLink="type == 'course' ? '/lms/courses' : '/lms/batches'"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!user.data?.name">
|
<div v-else-if="!user.data?.name">
|
||||||
@@ -163,7 +163,7 @@ import {
|
|||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'frappe-ui'
|
} 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 Link from '@/components/Controls/Link.vue'
|
||||||
import NotPermitted from '@/components/NotPermitted.vue'
|
import NotPermitted from '@/components/NotPermitted.vue'
|
||||||
import { showToast } from '@/utils/'
|
import { showToast } from '@/utils/'
|
||||||
@@ -193,7 +193,7 @@ const props = defineProps({
|
|||||||
const access = createResource({
|
const access = createResource({
|
||||||
url: 'lms.lms.api.validate_billing_access',
|
url: 'lms.lms.api.validate_billing_access',
|
||||||
params: {
|
params: {
|
||||||
type: props.type,
|
billing_type: props.type,
|
||||||
name: props.name,
|
name: props.name,
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
@@ -206,7 +206,7 @@ const orderSummary = createResource({
|
|||||||
url: 'lms.lms.utils.get_order_summary',
|
url: 'lms.lms.utils.get_order_summary',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||||
docname: props.name,
|
docname: props.name,
|
||||||
country: billingDetails.country,
|
country: billingDetails.country,
|
||||||
}
|
}
|
||||||
@@ -236,13 +236,15 @@ const paymentLink = createResource({
|
|||||||
url: 'lms.lms.payments.get_payment_link',
|
url: 'lms.lms.payments.get_payment_link',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||||
docname: props.name,
|
docname: props.name,
|
||||||
title: orderSummary.data.title,
|
title: orderSummary.data.title,
|
||||||
amount: orderSummary.data.original_amount,
|
amount: orderSummary.data.original_amount,
|
||||||
total_amount: orderSummary.data.amount,
|
total_amount: orderSummary.data.amount,
|
||||||
currency: orderSummary.data.currency,
|
currency: orderSummary.data.currency,
|
||||||
address: billingDetails,
|
address: billingDetails,
|
||||||
|
redirect_to: redirectTo,
|
||||||
|
payment_for_certificate: props.type == 'certificate',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -347,4 +349,14 @@ const changeCurrency = (country) => {
|
|||||||
billingDetails.country = country
|
billingDetails.country = country
|
||||||
orderSummary.reload()
|
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>
|
</script>
|
||||||
|
|||||||
2
frontend/src/pages/CourseCertification.vue
Normal file
2
frontend/src/pages/CourseCertification.vue
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<template>Course Certificate</template>
|
||||||
|
<script setup></script>
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-10 mb-4">
|
<div class="grid grid-cols-2 gap-10 mb-4">
|
||||||
<div
|
<div
|
||||||
v-if="user.data?.is_moderator"
|
v-if="user.data?.is_moderator"
|
||||||
class="flex flex-col space-y-4"
|
class="flex flex-col space-y-4"
|
||||||
@@ -188,35 +188,38 @@
|
|||||||
v-model="course.featured"
|
v-model="course.featured"
|
||||||
:label="__('Featured')"
|
:label="__('Featured')"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div class="flex flex-col space-y-3">
|
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.disable_self_learning"
|
v-model="course.disable_self_learning"
|
||||||
:label="__('Disable Self Enrollment')"
|
:label="__('Disable Self Enrollment')"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
type="checkbox"
|
|
||||||
v-model="course.enable_certification"
|
|
||||||
:label="__('Completion Certificate')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
{{ __('Pricing') }}
|
{{ __('Pricing and Certification') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="grid grid-cols-3 mb-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.paid_course"
|
v-model="course.paid_course"
|
||||||
:label="__('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>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="course.course_price"
|
v-model="course.course_price"
|
||||||
:label="__('Course Price')"
|
:label="__('Amount')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -4,6 +4,45 @@
|
|||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
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" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
lesson.data && lesson.data.membership && lesson.data.paid_certificate
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-if="!lesson.data.membership.purchased_certificate"
|
||||||
|
:to="{
|
||||||
|
name: 'Billing',
|
||||||
|
params: {
|
||||||
|
type: 'certificate',
|
||||||
|
name: courseName,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Get Certified') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-else-if="!lesson.data.membership.certficate"
|
||||||
|
:to="{
|
||||||
|
name: 'CourseCertification',
|
||||||
|
params: {
|
||||||
|
courseName: courseName,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Get Certified') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||||
<div
|
<div
|
||||||
@@ -197,7 +236,7 @@ import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
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 Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools, updateDocumentTitle } from '../utils'
|
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ const routes = [
|
|||||||
component: () => import('@/pages/Lesson.vue'),
|
component: () => import('@/pages/Lesson.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/courses/:courseName/certification',
|
||||||
|
name: 'CourseCertification',
|
||||||
|
component: () => import('@/pages/CourseCertification.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/courses/:courseName/learn/:chapterName',
|
path: '/courses/:courseName/learn/:chapterName',
|
||||||
name: 'SCORMChapter',
|
name: 'SCORMChapter',
|
||||||
|
|||||||
@@ -190,24 +190,24 @@ def get_translations():
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def validate_billing_access(type, name):
|
def validate_billing_access(billing_type, name):
|
||||||
access = True
|
access = True
|
||||||
message = ""
|
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":
|
if frappe.session.user == "Guest":
|
||||||
access = False
|
access = False
|
||||||
message = _("Please login to continue with payment.")
|
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
|
access = False
|
||||||
message = _("Module is incorrect.")
|
message = _("Module is incorrect.")
|
||||||
|
|
||||||
if not frappe.db.exists(doctype, name):
|
if access and not frappe.db.exists(doctype, name):
|
||||||
access = False
|
access = False
|
||||||
message = _("Module Name is incorrect or does not exist.")
|
message = _("Module Name is incorrect or does not exist.")
|
||||||
|
|
||||||
if type == "course":
|
if access and billing_type == "course":
|
||||||
membership = frappe.db.exists(
|
membership = frappe.db.exists(
|
||||||
"LMS Enrollment", {"member": frappe.session.user, "course": name}
|
"LMS Enrollment", {"member": frappe.session.user, "course": name}
|
||||||
)
|
)
|
||||||
@@ -215,7 +215,7 @@ def validate_billing_access(type, name):
|
|||||||
access = False
|
access = False
|
||||||
message = _("You are already enrolled for this course.")
|
message = _("You are already enrolled for this course.")
|
||||||
|
|
||||||
else:
|
elif access and billing_type == "batch":
|
||||||
membership = frappe.db.exists(
|
membership = frappe.db.exists(
|
||||||
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
|
"LMS Batch Enrollment", {"member": frappe.session.user, "batch": name}
|
||||||
)
|
)
|
||||||
@@ -223,6 +223,19 @@ def validate_billing_access(type, name):
|
|||||||
access = False
|
access = False
|
||||||
message = _("You are already enrolled for this batch.")
|
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 = frappe.db.get_value(
|
||||||
"Address",
|
"Address",
|
||||||
{"email_id": frappe.session.user},
|
{"email_id": frappe.session.user},
|
||||||
@@ -370,7 +383,7 @@ def get_evaluator_details(evaluator):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def get_certified_participants(filters=None, start=0, page_length=30, search=None):
|
def get_certified_participants(filters=None, start=0, page_length=30):
|
||||||
or_filters = {}
|
or_filters = {}
|
||||||
if not filters:
|
if not filters:
|
||||||
filters = {}
|
filters = {}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from datetime import timedelta
|
|||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
|
from frappe.utils import cint, format_datetime, get_time, add_days, nowdate
|
||||||
from lms.lms.utils import (
|
from lms.lms.utils import (
|
||||||
get_lessons,
|
|
||||||
get_lesson_index,
|
get_lesson_index,
|
||||||
get_lesson_url,
|
get_lesson_url,
|
||||||
get_quiz_details,
|
get_quiz_details,
|
||||||
@@ -258,17 +257,6 @@ def create_batch(
|
|||||||
return doc
|
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()
|
@frappe.whitelist()
|
||||||
def add_course(course, parent, name=None, evaluator=None):
|
def add_course(course, parent, name=None, evaluator=None):
|
||||||
frappe.only_for("Moderator")
|
frappe.only_for("Moderator")
|
||||||
|
|||||||
@@ -40,15 +40,12 @@
|
|||||||
"pricing_tab",
|
"pricing_tab",
|
||||||
"pricing_section",
|
"pricing_section",
|
||||||
"paid_course",
|
"paid_course",
|
||||||
|
"enable_certification",
|
||||||
|
"paid_certificate",
|
||||||
"column_break_acoj",
|
"column_break_acoj",
|
||||||
"course_price",
|
"course_price",
|
||||||
"currency",
|
"currency",
|
||||||
"amount_usd",
|
"amount_usd",
|
||||||
"certification_tab",
|
|
||||||
"certification_section",
|
|
||||||
"enable_certification",
|
|
||||||
"column_break_rxww",
|
|
||||||
"expiry",
|
|
||||||
"tab_4_tab",
|
"tab_4_tab",
|
||||||
"statistics_section",
|
"statistics_section",
|
||||||
"enrollments",
|
"enrollments",
|
||||||
@@ -134,22 +131,11 @@
|
|||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break",
|
||||||
"label": "Course Settings"
|
"label": "Course Settings"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "certification_section",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "enable_certification",
|
"fieldname": "enable_certification",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Enable Certification"
|
"label": "Completion Certificate"
|
||||||
},
|
|
||||||
{
|
|
||||||
"default": "0",
|
|
||||||
"depends_on": "enable_certification",
|
|
||||||
"fieldname": "expiry",
|
|
||||||
"fieldtype": "Int",
|
|
||||||
"label": "Certification Expires After (Years)"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "related_courses",
|
"fieldname": "related_courses",
|
||||||
@@ -181,7 +167,6 @@
|
|||||||
"fieldtype": "Section Break"
|
"fieldtype": "Section Break"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "paid_course",
|
|
||||||
"fieldname": "currency",
|
"fieldname": "currency",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Currency",
|
"label": "Currency",
|
||||||
@@ -195,22 +180,16 @@
|
|||||||
"label": "Paid Course"
|
"label": "Paid Course"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "paid_course",
|
|
||||||
"fieldname": "course_price",
|
"fieldname": "course_price",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"label": "Course Price",
|
"label": "Amount",
|
||||||
"mandatory_depends_on": "paid_course"
|
"mandatory_depends_on": "paid_course"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "column_break_rxww",
|
|
||||||
"fieldtype": "Column Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_acoj",
|
"fieldname": "column_break_acoj",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "paid_course",
|
|
||||||
"description": "If you set an amount here, then the USD equivalent setting will not get applied.",
|
"description": "If you set an amount here, then the USD equivalent setting will not get applied.",
|
||||||
"fieldname": "amount_usd",
|
"fieldname": "amount_usd",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
@@ -238,12 +217,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "pricing_tab",
|
"fieldname": "pricing_tab",
|
||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Pricing"
|
"label": "Pricing and Certification"
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "certification_tab",
|
|
||||||
"fieldtype": "Tab Break",
|
|
||||||
"label": "Certification"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "column_break_htgn",
|
"fieldname": "column_break_htgn",
|
||||||
@@ -284,6 +258,12 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Rating",
|
"label": "Rating",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "paid_certificate",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Paid Certificate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"is_published_field": "published",
|
"is_published_field": "published",
|
||||||
@@ -310,7 +290,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"make_attachments_public": 1,
|
"make_attachments_public": 1,
|
||||||
"modified": "2024-10-30 23:08:31.842860",
|
"modified": "2025-02-20 16:44:38.891383",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Course",
|
"name": "LMS Course",
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import json
|
|||||||
import random
|
import random
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils import cint, today
|
from frappe.utils import today, cint
|
||||||
from frappe.utils.telemetry import capture
|
from lms.lms.utils import get_chapters
|
||||||
from lms.lms.utils import get_chapters, can_create_courses
|
|
||||||
from ...utils import generate_slug, validate_image, update_payment_record
|
from ...utils import generate_slug, validate_image, update_payment_record
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
@@ -53,9 +52,12 @@ class LMSCourse(Document):
|
|||||||
frappe.throw(_("Please install the Payments app to create a paid courses."))
|
frappe.throw(_("Please install the Payments app to create a paid courses."))
|
||||||
|
|
||||||
def validate_amount_and_currency(self):
|
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."))
|
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):
|
def on_update(self):
|
||||||
if not self.upcoming and self.has_value_changed("upcoming"):
|
if not self.upcoming and self.has_value_changed("upcoming"):
|
||||||
self.send_email_to_interested_users()
|
self.send_email_to_interested_users()
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"member",
|
"member",
|
||||||
"member_name",
|
"member_name",
|
||||||
"member_username",
|
"member_username",
|
||||||
|
"certification_section",
|
||||||
|
"purchased_certificate",
|
||||||
|
"certificate",
|
||||||
"section_break_8",
|
"section_break_8",
|
||||||
"cohort",
|
"cohort",
|
||||||
"subgroup",
|
"subgroup",
|
||||||
@@ -123,11 +126,28 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Payment",
|
"label": "Payment",
|
||||||
"options": "LMS 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,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-10-30 12:44:16.103598",
|
"modified": "2025-02-21 17:11:37.986157",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Enrollment",
|
"name": "LMS Enrollment",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"payment_for_document_type",
|
"payment_for_document_type",
|
||||||
"payment_for_document",
|
"payment_for_document",
|
||||||
"payment_received",
|
"payment_received",
|
||||||
|
"payment_for_certificate",
|
||||||
"payment_details_section",
|
"payment_details_section",
|
||||||
"currency",
|
"currency",
|
||||||
"amount",
|
"amount",
|
||||||
@@ -136,6 +137,12 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Source",
|
"label": "Source",
|
||||||
"options": "LMS Source"
|
"options": "LMS Source"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "0",
|
||||||
|
"fieldname": "payment_for_certificate",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Payment for Certificate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
@@ -149,7 +156,7 @@
|
|||||||
"link_fieldname": "payment"
|
"link_fieldname": "payment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2025-02-18 15:54:25.383353",
|
"modified": "2025-02-21 18:29:55.436611",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "LMS",
|
"module": "LMS",
|
||||||
"name": "LMS Payment",
|
"name": "LMS Payment",
|
||||||
|
|||||||
@@ -18,19 +18,26 @@ def validate_currency(payment_gateway, currency):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@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()
|
payment_gateway = get_payment_gateway()
|
||||||
address = frappe._dict(address)
|
address = frappe._dict(address)
|
||||||
amount_with_gst = total_amount if total_amount != amount else 0
|
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)
|
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 = {
|
payment_details = {
|
||||||
"amount": total_amount,
|
"amount": total_amount,
|
||||||
"title": f"Payment for {doctype} {title} {docname}",
|
"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
|
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 = frappe._dict(address)
|
||||||
address_name = save_address(address)
|
address_name = save_address(address)
|
||||||
|
|
||||||
@@ -71,6 +86,7 @@ def record_payment(address, doctype, docname, amount, currency, amount_with_gst=
|
|||||||
"source": address.source,
|
"source": address.source,
|
||||||
"payment_for_document_type": doctype,
|
"payment_for_document_type": doctype,
|
||||||
"payment_for_document": docname,
|
"payment_for_document": docname,
|
||||||
|
"payment_for_certificate": payment_for_certificate,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
payment_doc.save(ignore_permissions=True)
|
payment_doc.save(ignore_permissions=True)
|
||||||
|
|||||||
@@ -68,27 +68,26 @@ def generate_slug(title, doctype):
|
|||||||
return slugify(title, used_slugs=slugs)
|
return slugify(title, used_slugs=slugs)
|
||||||
|
|
||||||
|
|
||||||
def get_membership(course, member=None, batch=None):
|
def get_membership(course, member=None):
|
||||||
if not member:
|
if not member:
|
||||||
member = frappe.session.user
|
member = frappe.session.user
|
||||||
|
|
||||||
filters = {"member": member, "course": course}
|
filters = {"member": member, "course": course}
|
||||||
if batch:
|
|
||||||
filters["batch_old"] = batch
|
|
||||||
|
|
||||||
is_member = frappe.db.exists("LMS Enrollment", filters)
|
if frappe.db.exists("LMS Enrollment", filters):
|
||||||
if is_member:
|
|
||||||
membership = frappe.db.get_value(
|
membership = frappe.db.get_value(
|
||||||
"LMS Enrollment",
|
"LMS Enrollment",
|
||||||
filters,
|
filters,
|
||||||
["name", "batch_old", "current_lesson", "member_type", "progress", "member"],
|
[
|
||||||
|
"name",
|
||||||
|
"current_lesson",
|
||||||
|
"progress",
|
||||||
|
"member",
|
||||||
|
"purchased_certificate",
|
||||||
|
"certificate",
|
||||||
|
],
|
||||||
as_dict=True,
|
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 membership
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -1009,6 +1008,7 @@ def get_course_details(course):
|
|||||||
"category",
|
"category",
|
||||||
"status",
|
"status",
|
||||||
"paid_course",
|
"paid_course",
|
||||||
|
"paid_certificate",
|
||||||
"course_price",
|
"course_price",
|
||||||
"currency",
|
"currency",
|
||||||
"amount_usd",
|
"amount_usd",
|
||||||
@@ -1023,7 +1023,7 @@ def get_course_details(course):
|
|||||||
|
|
||||||
course_details.instructors = get_instructors(course_details.name)
|
course_details.instructors = get_instructors(course_details.name)
|
||||||
# course_details.is_instructor = is_instructor(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 = check_multicurrency(
|
||||||
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
course_details.course_price, course_details.currency, None, course_details.amount_usd
|
||||||
)"""
|
)"""
|
||||||
@@ -1136,14 +1136,21 @@ def get_lesson(course, chapter, lesson):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
membership = get_membership(course)
|
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 (
|
if (
|
||||||
not lesson_details.include_in_preview
|
not lesson_details.include_in_preview
|
||||||
and not membership
|
and not membership
|
||||||
and not has_course_moderator_role()
|
and not has_course_moderator_role()
|
||||||
and not is_instructor(course)
|
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(
|
lesson_details = frappe.db.get_value(
|
||||||
"Course Lesson",
|
"Course Lesson",
|
||||||
@@ -1178,7 +1185,8 @@ def get_lesson(course, chapter, lesson):
|
|||||||
lesson_details.prev = neighbours["prev"]
|
lesson_details.prev = neighbours["prev"]
|
||||||
lesson_details.membership = membership
|
lesson_details.membership = membership
|
||||||
lesson_details.instructors = get_instructors(course)
|
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
|
return lesson_details
|
||||||
|
|
||||||
|
|
||||||
@@ -1612,11 +1620,19 @@ def get_order_summary(doctype, docname, country=None):
|
|||||||
details = frappe.db.get_value(
|
details = frappe.db.get_value(
|
||||||
"LMS Course",
|
"LMS Course",
|
||||||
docname,
|
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,
|
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."))
|
raise frappe.throw(_("This course is free."))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -1730,9 +1746,14 @@ def update_payment_record(doctype, docname):
|
|||||||
"order_id": data.get("order_id"),
|
"order_id": data.get("order_id"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
payment_for_certificate = frappe.db.get_value(
|
||||||
|
"LMS Payment", data.payment, "payment_for_certificate"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if doctype == "LMS Course":
|
if payment_for_certificate:
|
||||||
|
update_certificate_purchase(docname)
|
||||||
|
elif doctype == "LMS Course":
|
||||||
enroll_in_course(data.payment, docname)
|
enroll_in_course(data.payment, docname)
|
||||||
else:
|
else:
|
||||||
enroll_in_batch(docname, data.payment)
|
enroll_in_batch(docname, data.payment)
|
||||||
@@ -1792,6 +1813,15 @@ def enroll_in_batch(batch, payment_name=None):
|
|||||||
new_student.save()
|
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()
|
@frappe.whitelist()
|
||||||
def get_programs():
|
def get_programs():
|
||||||
if (
|
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 %}
|
|
||||||
Reference in New Issue
Block a user