feat: generate certificate from course page

This commit is contained in:
Jannat Patel
2024-07-12 15:56:50 +05:30
parent 6e1d62340f
commit 23b2e8d682
11 changed files with 146 additions and 228 deletions

View File

@@ -63,7 +63,13 @@
{{ __('Start Learning') }}
</span>
</Button>
<Button v-if="canGetCertificate">
<Button
v-if="canGetCertificate"
@click="fetchCertificate()"
variant="subtle"
class="w-full mt-2"
size="md"
>
{{ __('Get Certificate') }}
</Button>
<router-link
@@ -139,7 +145,7 @@ function enrollStudent() {
})
setTimeout(() => {
window.location.href = `/login?redirect-to=${window.location.pathname}`
}, 3000)
}, 2000)
} else {
const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
@@ -179,6 +185,37 @@ const is_instructor = () => {
}
const canGetCertificate = computed(() => {
console.log(props.course)
if (
props.course.data?.enable_certification &&
props.course.data?.membership?.progress == 100
) {
return true
}
return false
})
const certificate = createResource({
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
makeParams(values) {
return {
course: values.course,
}
},
onSuccess(data) {
console.log(data)
window.open(
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
data.name
}&format=${encodeURIComponent(data.template)}`,
'_blank'
)
},
})
const fetchCertificate = () => {
certificate.submit({
course: props.course.data?.name,
member: user.data?.name,
})
}
</script>

View File

@@ -1,10 +1,14 @@
<template>
<div ref="videoContainer" class="video-block group relative">
<video @timeupdate="updateTime" @ended="videoEnded" class="rounded-lg">
<video
@timeupdate="updateTime"
@ended="videoEnded"
class="rounded-lg border border-gray-100"
>
<source :src="fileURL" :type="type" />
</video>
<div
class="flex items-center space-x-2 bg-gray-200 rounded-lg p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
>
<Button variant="ghost">
<template #icon>

View File

@@ -171,7 +171,7 @@
{{ lesson.data.course_title }}
</div>
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
{{ Math.ceil(lessonProgress) }}% completed
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
</div>
<ProgressBar
@@ -190,7 +190,7 @@
</template>
<script setup>
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
import { computed, watch, inject, ref } from 'vue'
import { computed, watch, inject, ref, onMounted, onBeforeUnmount } from 'vue'
import CourseOutline from '@/components/CourseOutline.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRoute } from 'vue-router'
@@ -208,6 +208,8 @@ const allowDiscussions = ref(false)
const editor = ref(null)
const instructorEditor = ref(null)
const lessonProgress = ref(0)
const timer = ref(0)
let timerInterval
const props = defineProps({
courseName: {
@@ -224,6 +226,10 @@ const props = defineProps({
},
})
onMounted(() => {
startTimer()
})
const lesson = createResource({
url: 'lms.lms.utils.get_lesson',
cache: ['lesson', props.courseName, props.chapterNumber, props.lessonNumber],
@@ -237,7 +243,6 @@ const lesson = createResource({
auto: true,
onSuccess(data) {
lessonProgress.value = data.membership?.progress
markProgress(data)
if (data.content) editor.value = renderEditor('editor', data.content)
if (data.instructor_content?.blocks?.length)
instructorEditor.value = renderEditor(
@@ -269,11 +274,9 @@ const renderEditor = (holder, content) => {
})
}
const markProgress = (data) => {
if (user.data && !data.progress) {
setTimeout(() => {
progress.submit()
}, 30000)
const markProgress = () => {
if (user.data && !lesson.data?.progress) {
progress.submit()
}
}
@@ -325,10 +328,32 @@ watch(
chapter: newChapterNumber,
lesson: newLessonNumber,
})
clearInterval(timerInterval)
timer.value = 0
startTimer()
}
}
)
const startTimer = () => {
console.log('starting timer')
timerInterval = setInterval(() => {
timer.value++
console.log(timer.value)
if (timer.value == 30) {
console.log('30 seconds passed')
console.log(lesson.data?.title)
clearInterval(timerInterval)
markProgress()
}
}, 1000)
}
onBeforeUnmount(() => {
console.log('clearing interval')
clearInterval(timerInterval)
})
const checkIfDiscussionsAllowed = () => {
let quizPresent = false
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {

View File

@@ -176,52 +176,7 @@ update_website_context = [
]
jinja = {
"methods": [
"lms.page_renderers.get_profile_url",
"lms.overrides.user.get_enrolled_courses",
"lms.overrides.user.get_course_membership",
"lms.overrides.user.get_authored_courses",
"lms.overrides.user.get_palette",
"lms.lms.utils.get_membership",
"lms.lms.utils.get_lessons",
"lms.lms.utils.get_tags",
"lms.lms.utils.get_instructors",
"lms.lms.utils.get_students",
"lms.lms.utils.get_average_rating",
"lms.lms.utils.is_certified",
"lms.lms.utils.get_lesson_index",
"lms.lms.utils.get_lesson_url",
"lms.lms.utils.get_chapters",
"lms.lms.utils.get_slugified_chapter_title",
"lms.lms.utils.get_progress",
"lms.lms.utils.render_html",
"lms.lms.utils.is_mentor",
"lms.lms.utils.is_cohort_staff",
"lms.lms.utils.get_mentors",
"lms.lms.utils.get_reviews",
"lms.lms.utils.is_eligible_to_review",
"lms.lms.utils.get_initial_members",
"lms.lms.utils.get_sorted_reviews",
"lms.lms.utils.is_instructor",
"lms.lms.utils.convert_number_to_character",
"lms.lms.utils.get_signup_optin_checks",
"lms.lms.utils.get_popular_courses",
"lms.lms.utils.format_amount",
"lms.lms.utils.first_lesson_exists",
"lms.lms.utils.get_courses_under_review",
"lms.lms.utils.has_course_instructor_role",
"lms.lms.utils.has_course_moderator_role",
"lms.lms.utils.get_certificates",
"lms.lms.utils.format_number",
"lms.lms.utils.get_lesson_count",
"lms.lms.utils.get_all_memberships",
"lms.lms.utils.get_filtered_membership",
"lms.lms.utils.show_start_learing_cta",
"lms.lms.utils.can_create_courses",
"lms.lms.utils.get_telemetry_boot_info",
"lms.lms.utils.is_onboarding_complete",
"lms.www.utils.is_student",
],
"methods": ["lms.lms.utils.get_signup_optin_checks"],
"filters": [],
}
## Specify the additional tabs to be included in the user profile page.

View File

@@ -93,7 +93,7 @@ def save_progress(lesson, course):
"LMS Enrollment", {"course": course, "member": frappe.session.user}
)
if not membership:
return 0
return
frappe.db.set_value("LMS Enrollment", membership, "current_lesson", lesson)
@@ -104,7 +104,7 @@ def save_progress(lesson, course):
if frappe.db.exists(
"LMS Course Progress", {"lesson": lesson, "member": frappe.session.user}
):
return 0
return
frappe.get_doc(
{
@@ -116,9 +116,14 @@ def save_progress(lesson, course):
).save(ignore_permissions=True)
progress = get_course_progress(course)
frappe.db.set_value("LMS Enrollment", membership, "progress", progress)
print("Progress", progress)
# Had to get doc, as on_change doesn't trigger when you use set_value. The trigger is necesary for badge to get assigned.
enrollment = frappe.get_doc("LMS Enrollment", membership)
enrollment.progress = progress
enrollment.save()
enrollment.run_method("on_change")
print("Progress", progress)
return progress

View File

@@ -86,7 +86,6 @@
"label": "Comments"
},
{
"fetch_from": "course.evaluator",
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",

View File

@@ -8,14 +8,16 @@
"field_order": [
"course",
"course_title",
"column_break_3",
"member",
"member_name",
"column_break_3",
"published",
"section_break_tnnm",
"template",
"issue_date",
"expiry_date",
"batch_name",
"published"
"column_break_qtzo",
"issue_date",
"expiry_date"
],
"fields": [
{
@@ -85,11 +87,19 @@
"fieldtype": "Data",
"label": "Course Title",
"read_only": 1
},
{
"fieldname": "section_break_tnnm",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_qtzo",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-06-21 18:14:30.491841",
"modified": "2024-07-12 12:39:50.076937",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Certificate",
@@ -120,13 +130,15 @@
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "LMS Student",
"share": 1
"share": 1,
"write": 1
}
],
"sort_field": "modified",

View File

@@ -71,8 +71,11 @@ class LMSCertificate(Document):
def has_website_permission(doc, ptype, user, verbose=False):
print(doc.member, user, ptype)
if ptype in ["read", "print"]:
return True
if doc.member == user and ptype == "create":
return True
return False
@@ -81,7 +84,9 @@ def create_certificate(course):
certificate = is_certified(course)
if certificate:
return certificate
return frappe.db.get_value(
"LMS Certificate", certificate, ["name", "course", "template"], as_dict=True
)
else:
expires_after_yrs = int(frappe.db.get_value("LMS Course", course, "expiry"))

View File

@@ -30,23 +30,23 @@
"disable_self_learning",
"section_break_18",
"short_introduction",
"column_break_viqw",
"description",
"section_break_gglp",
"chapters",
"related_courses",
"pricing_tab",
"pricing_section",
"paid_course",
"column_break_acoj",
"course_price",
"currency",
"amount_usd",
"certification_tab",
"certification_section",
"enable_certification",
"expiry",
"max_attempts",
"column_break_rxww",
"grant_certificate_after",
"evaluator",
"duration"
"expiry"
],
"fields": [
{
@@ -129,8 +129,7 @@
},
{
"fieldname": "certification_section",
"fieldtype": "Section Break",
"label": "Certification"
"fieldtype": "Section Break"
},
{
"default": "0",
@@ -170,25 +169,9 @@
"fieldname": "column_break_10",
"fieldtype": "Column Break"
},
{
"depends_on": "enable_certification",
"fieldname": "grant_certificate_after",
"fieldtype": "Select",
"label": "Grant Certificate After",
"options": "Completion\nEvaluation"
},
{
"depends_on": "eval: doc.grant_certificate_after == \"Evaluation\"",
"fieldname": "evaluator",
"fieldtype": "Link",
"label": "Evaluator",
"mandatory_depends_on": "eval: doc.grant_certificate_after == \"Evaluation\"",
"options": "Course Evaluator"
},
{
"fieldname": "pricing_section",
"fieldtype": "Section Break",
"label": "Pricing"
"fieldtype": "Section Break"
},
{
"depends_on": "paid_course",
@@ -198,20 +181,6 @@
"mandatory_depends_on": "paid_course",
"options": "Currency"
},
{
"default": "1",
"depends_on": "eval: doc.grant_certificate_after == \"Evaluation\"",
"fieldname": "max_attempts",
"fieldtype": "Int",
"label": "Max Attempts for Evaluations"
},
{
"depends_on": "eval: doc.grant_certificate_after == \"Evaluation\"",
"fieldname": "duration",
"fieldtype": "Select",
"label": "Duration for Attempts",
"options": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12"
},
{
"default": "0",
"fieldname": "paid_course",
@@ -250,6 +219,24 @@
"fieldname": "featured",
"fieldtype": "Check",
"label": "Featured"
},
{
"fieldname": "column_break_viqw",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_gglp",
"fieldtype": "Section Break"
},
{
"fieldname": "pricing_tab",
"fieldtype": "Tab Break",
"label": "Pricing"
},
{
"fieldname": "certification_tab",
"fieldtype": "Tab Break",
"label": "Certification"
}
],
"is_published_field": "published",
@@ -276,7 +263,7 @@
}
],
"make_attachments_public": 1,
"modified": "2024-06-24 17:44:45.903164",
"modified": "2024-07-12 13:54:40.474097",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course",

View File

@@ -1,11 +1,7 @@
import unittest
import frappe
from frappe.utils import getdate
from lms.lms.doctype.lms_course.test_lms_course import new_course, new_user
from .utils import get_evaluation_details, slugify
from .utils import slugify
class TestUtils(unittest.TestCase):
@@ -20,58 +16,3 @@ class TestUtils(unittest.TestCase):
self.assertEqual(
slugify("Hello World", ["hello-world", "hello-world-2"]), "hello-world-3"
)
def test_evaluation_details(self):
user = new_user("Eval", "eval@test.com")
course = new_course(
"Test Evaluation Details",
{
"enable_certification": 1,
"grant_certificate_after": "Evaluation",
"evaluator": "evaluator@example.com",
"max_attempts": 3,
"duration": 2,
"instructors": [{"instructor": user.name}],
},
)
# Two evaluations failed within max attempts. Check eligibility for a third evaluation
create_evaluation(user.name, course.name, getdate("21-03-2022"), 0.4, "Fail")
create_evaluation(user.name, course.name, getdate("12-04-2022"), 0.4, "Fail")
details = get_evaluation_details(course.name, user.name)
self.assertTrue(details.eligible)
# Three evaluations failed within max attempts. Check eligibility for a forth evaluation
create_evaluation(user.name, course.name, getdate("21-03-2022"), 0.4, "Fail")
create_evaluation(user.name, course.name, getdate("12-04-2022"), 0.4, "Fail")
create_evaluation(user.name, course.name, getdate("16-04-2022"), 0.4, "Fail")
details = get_evaluation_details(course.name, user.name)
self.assertFalse(details.eligible)
# Three evaluations failed within max attempts. Check eligibility for a forth evaluation. Different Dates
create_evaluation(user.name, course.name, getdate("01-03-2022"), 0.4, "Fail")
create_evaluation(user.name, course.name, getdate("12-04-2022"), 0.4, "Fail")
create_evaluation(user.name, course.name, getdate("16-04-2022"), 0.4, "Fail")
details = get_evaluation_details(course.name, user.name)
self.assertFalse(details.eligible)
frappe.db.delete("LMS Certificate Evaluation", {"course": course.name})
frappe.db.delete("LMS Course", course.name)
frappe.db.delete("User", user.name)
def create_evaluation(user, course, date, rating, status):
evaluation = frappe.get_doc(
{
"doctype": "LMS Certificate Evaluation",
"member": user,
"course": course,
"date": date,
"start_time": "12:00:00",
"end_time": "13:00:00",
"rating": rating,
"status": status,
}
)
evaluation.save()

View File

@@ -452,45 +452,6 @@ def get_popular_courses():
return course_membership[:3]
def get_evaluation_details(course, member=None):
info = frappe.db.get_value(
"LMS Course",
course,
["grant_certificate_after", "max_attempts", "duration"],
as_dict=True,
)
request = frappe.db.get_value(
"LMS Certificate Request",
{
"course": course,
"member": member or frappe.session.user,
"date": [">=", getdate()],
},
["date", "start_time", "end_time"],
as_dict=True,
)
no_of_attempts = frappe.db.count(
"LMS Certificate Evaluation",
{
"course": course,
"member": member or frappe.session.user,
"status": ["!=", "Pass"],
"creation": [">=", add_months(getdate(), -abs(cint(info.duration)))],
},
)
return frappe._dict(
{
"eligible": info.grant_certificate_after == "Evaluation"
and not request
and no_of_attempts < info.max_attempts,
"request": request,
"no_of_attempts": no_of_attempts,
}
)
def format_amount(amount, currency):
amount_reduced = amount / 1000
if amount_reduced < 1:
@@ -612,14 +573,6 @@ def get_courses_under_review():
)
def get_certificates(member=None):
return frappe.get_all(
"LMS Certificate",
{"member": member or frappe.session.user},
["course", "member", "issue_date", "expiry_date", "name"],
)
def validate_image(path):
if path and "/private" in path:
file = frappe.get_doc("File", {"file_url": path})
@@ -944,19 +897,13 @@ def has_graded_assessment(submission):
return False if status == "Not Graded" else True
def get_evaluator(course, batch=None):
def get_evaluator(course, batch):
evaluator = None
if batch:
evaluator = frappe.db.get_value(
"Batch Course",
{"parent": batch, "course": course},
"evaluator",
)
if not evaluator:
evaluator = frappe.db.get_value("LMS Course", course, "evaluator")
evaluator = frappe.db.get_value(
"Batch Course",
{"parent": batch, "course": course},
"evaluator",
)
return evaluator
@@ -1285,6 +1232,7 @@ def get_course_details(course):
"course_price",
"currency",
"amount_usd",
"enable_certification",
],
as_dict=1,
)