diff --git a/lms/lms/doctype/lms_certificate/lms_certificate.js b/lms/lms/doctype/lms_certificate/lms_certificate.js index 6a341a63..8d6f5e12 100644 --- a/lms/lms/doctype/lms_certificate/lms_certificate.js +++ b/lms/lms/doctype/lms_certificate/lms_certificate.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('LMS Certificate', { - onload: function (frm) { + onload: (frm) => { frm.set_query("member", function (doc) { return { filters: { @@ -10,5 +10,8 @@ frappe.ui.form.on('LMS Certificate', { } }; }); + }, + refresh: (frm) => { + if (frm.doc.name) frm.add_web_link(`/courses/${frm.doc.course}/${frm.doc.name}`, 'See on Website') } }); diff --git a/lms/lms/doctype/lms_certificate/lms_certificate.py b/lms/lms/doctype/lms_certificate/lms_certificate.py index 0a6e9b19..93487cad 100644 --- a/lms/lms/doctype/lms_certificate/lms_certificate.py +++ b/lms/lms/doctype/lms_certificate/lms_certificate.py @@ -10,16 +10,15 @@ from lms.lms.utils import is_certified class LMSCertificate(Document): - def validate(self): + def before_insert(self): certificates = frappe.get_all("LMS Certificate", { - "member": self.member, - "course": self.course, - "expiry_date": [">", nowdate()] - }) + "member": self.member, + "course": self.course + }) if len(certificates): full_name = frappe.db.get_value("User", self.member, "full_name") course_name = frappe.db.get_value("LMS Course", self.course, "title") - frappe.throw(_("There is already a valid certificate for user {0} for the course {1}").format(full_name, course_name)) + frappe.throw(_("{0} is already certified for the course {1}").format(full_name, course_name)) @frappe.whitelist() def create_certificate(course): @@ -35,11 +34,11 @@ def create_certificate(course): expiry_date = add_years(nowdate(), expires_after_yrs) certificate = frappe.get_doc({ - "doctype": "LMS Certificate", - "member": frappe.session.user, - "course": course, - "issue_date": nowdate(), - "expiry_date": expiry_date - }) + "doctype": "LMS Certificate", + "member": frappe.session.user, + "course": course, + "issue_date": nowdate(), + "expiry_date": expiry_date + }) certificate.save(ignore_permissions=True) return certificate diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.json b/lms/lms/doctype/lms_quiz/lms_quiz.json index 129291a4..e68caf67 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.json +++ b/lms/lms/doctype/lms_quiz/lms_quiz.json @@ -10,7 +10,9 @@ "field_order": [ "title", "questions", - "lesson" + "lesson", + "max_attempts", + "time" ], "fields": [ { @@ -31,11 +33,23 @@ "label": "Lesson", "options": "Course Lesson", "read_only": 1 + }, + { + "default": "0", + "fieldname": "max_attempts", + "fieldtype": "Int", + "label": "Max Attempts" + }, + { + "default": "0", + "fieldname": "time", + "fieldtype": "Int", + "label": "Time Per Question (in Seconds)" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-09-30 13:10:06.929358", + "modified": "2022-05-16 14:47:55.364743", "modified_by": "Administrator", "module": "LMS", "name": "LMS Quiz", @@ -57,5 +71,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/lms/lms/doctype/lms_quiz/lms_quiz.py b/lms/lms/doctype/lms_quiz/lms_quiz.py index dd0b4809..098fa7e4 100644 --- a/lms/lms/doctype/lms_quiz/lms_quiz.py +++ b/lms/lms/doctype/lms_quiz/lms_quiz.py @@ -46,13 +46,14 @@ class LMSQuiz(Document): @frappe.whitelist() def quiz_summary(quiz, results): score = 0 - results = json.loads(results) + results = results and json.loads(results) for result in results: correct = result["is_correct"][0] result["question"] = frappe.db.get_value("LMS Quiz Question", - {"parent": quiz, "idx": result["question_index"]}, - ["question"]) + {"parent": quiz, + "idx": result["question_index"]}, + ["question"]) for point in result["is_correct"]: correct = correct and point diff --git a/lms/lms/doctype/lms_settings/lms_settings.json b/lms/lms/doctype/lms_settings/lms_settings.json index c2b8b03b..afbbbc1a 100644 --- a/lms/lms/doctype/lms_settings/lms_settings.json +++ b/lms/lms/doctype/lms_settings/lms_settings.json @@ -10,6 +10,7 @@ "force_profile_completion", "column_break_2", "search_placeholder", + "custom_certificate_template", "livecode_url", "signup_settings_section", "terms_of_use", @@ -63,7 +64,7 @@ "depends_on": "show_search", "fieldname": "search_placeholder", "fieldtype": "Data", - "label": "Search Field Placeholder" + "label": "Course List Search Bar Placeholder" }, { "default": "0", @@ -137,12 +138,18 @@ "fieldname": "user_category", "fieldtype": "Check", "label": "Ask User Category during Signup" + }, + { + "fieldname": "custom_certificate_template", + "fieldtype": "Link", + "label": "Custom Certificate Template", + "options": "Web Template" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2022-04-20 09:09:12.369728", + "modified": "2022-05-09 09:55:24.519269", "modified_by": "Administrator", "module": "LMS", "name": "LMS Settings", diff --git a/lms/plugins.py b/lms/plugins.py index cd03d2a6..354fe281 100644 --- a/lms/plugins.py +++ b/lms/plugins.py @@ -103,7 +103,25 @@ def set_mandatory_fields_for_profile(): def quiz_renderer(quiz_name): quiz = frappe.get_doc("LMS Quiz", quiz_name) - context = dict(quiz=quiz) + + context = { + "quiz": quiz + } + + no_of_attempts = frappe.db.count("LMS Quiz Submission", { + "owner": frappe.session.user, + "quiz": quiz_name}) + + if quiz.max_attempts and no_of_attempts >= quiz.max_attempts: + last_attempt_score = frappe.db.get_value("LMS Quiz Submission", { + "owner": frappe.session.user, + "quiz": quiz_name + }, ["score"]) + + context.update({ + "attempts_exceeded": True, + "last_attempt_score": last_attempt_score + }) return frappe.render_template("templates/quiz.html", context) def exercise_renderer(argument): diff --git a/lms/public/css/style.css b/lms/public/css/style.css index 2cc604ec..835d909f 100644 --- a/lms/public/css/style.css +++ b/lms/public/css/style.css @@ -576,7 +576,7 @@ input[type=checkbox] { } .lesson-content-card { - margin-top: 1rem; + margin: 3rem 0; } .lesson-page { @@ -837,15 +837,22 @@ pre { } .certificate-content { - padding: 2.5rem 3rem; - background-color: #FFFFFF; + background-color: #FFFFFF; + border-width: 100px; + border-style: solid; +} + +@media (max-width: 500px) { + .certificate-content { + border-width: 50px; + } } .certificate-footer { display: flex; - justify-content: space-between; - width: 30%; - margin: 5rem auto 0; + justify-content: center; + margin: 4rem auto 0; + width: fit-content; } .certificate-price { @@ -854,15 +861,15 @@ pre { } .certificate-ribbon { - background-color: var(--primary-color); - padding: 0.5rem; - border-radius: var(--border-radius-md); + background-color: var(--primary-color); + padding: 0.5rem; + border-radius: var(--border-radius-md); } .certificate-heading { - font-size: 1.5rem; - font-weight: 500; - color: var(--text-color); + font-size: 2rem; + font-weight: 500; + color: var(--text-color); } .certificate-para { @@ -876,24 +883,19 @@ pre { box-shadow: var(--shadow-sm); padding: 1rem; text-align: center; - margin: 0 6rem; } .certificate-footer-item { color: var(--text-color); - font-weight: 500; + font-weight: bold; + font-family: cursive; + font-size: 1.25rem; } .certificate-logo { height: 1.5rem; } -@media (max-width: 1050px) { - .certificate-footer { - width: 50%; - } -} - @media (max-width: 768px) { .certificate-card { margin: 0; @@ -904,10 +906,6 @@ pre { .certificate-content { padding: 1rem; } - - .certificate-footer { - width: 100%; - } } .profile-card { @@ -1341,8 +1339,7 @@ pre { font-size: var(--text-lg); color: var(--gray-900); font-weight: 600; - flex-grow: 1; - margin-left: 1rem; + width: 75%; } .profile-page-body { diff --git a/lms/public/images/border.png b/lms/public/images/border.png new file mode 100644 index 00000000..f3c6e63b Binary files /dev/null and b/lms/public/images/border.png differ diff --git a/lms/templates/certificate.html b/lms/templates/certificate.html index b16ff782..8064b42c 100644 --- a/lms/templates/certificate.html +++ b/lms/templates/certificate.html @@ -1,50 +1,33 @@
-
-
- -
- {{ _("This certifies that") }} +
+ +
+ {{ _("This certifies that") }} +
+
{{ member.full_name }}
+
{{ _("has successfully completed the course on") }} + {{ course.title }} on {{ frappe.utils.format_date(certificate.issue_date, "medium") }}.
+ +
- diff --git a/lms/templates/quiz.html b/lms/templates/quiz.html index 1e68d995..8674f1ce 100644 --- a/lms/templates/quiz.html +++ b/lms/templates/quiz.html @@ -1,18 +1,35 @@ -
{{ quiz.title }}
+{% if attempts_exceeded %} +
+
{{ quiz.title }}
+
{{ _("You have already exceeded the maximum number of attempts allowed for this quiz.") }}
+
{{ _("Your latest score is {0}.").format(last_attempt_score) }}
+
+{% else %} +
{{ quiz.title }}
-
+
+
{{ quiz.title }}
+
{{ _("There are {0} questions in this quiz.").format(quiz.questions | length) }}{% if quiz.max_attempts %} + {% set suffix = "times" if quiz.max_attempts > 1 else "time" %} {{ _("This quiz can only be taken {0} {1}. If you attempt the quiz and leave the page before submitting, the quiz will be automatically submitted.").format(quiz.max_attempts, suffix) }}{% endif %} + {% if quiz.time %}{{ _("The quiz has a time limit. Each question will be given {0} seconds.").format(quiz.time) }}{% endif %} +
+ +
+ +
{% for question in quiz.questions %} {% set instruction = _("Choose all answers that apply") if question.multiple else _("Choose 1 answer") %} -
+
-
{{ loop.index }}
-
-
{{ instruction }}
- {{ frappe.utils.md_to_html(question.question) }} -
+
{{ loop.index }}
+
+ {{ frappe.utils.md_to_html(question.question) }} +
+
{{ instruction }}
{% set options = [question.option_1, question.option_2, question.option_3, question.option_4] %} @@ -40,15 +57,23 @@
{% endfor %}
+ -

-
+{% endif %} diff --git a/lms/www/batch/learn.html b/lms/www/batch/learn.html index 9b218b71..d6416f61 100644 --- a/lms/www/batch/learn.html +++ b/lms/www/batch/learn.html @@ -139,11 +139,6 @@ {% if next_url %} {{ _("Next") }} {% else %} {{ _("Mark as Complete") }} {% endif %} - {% if course.enable_certification %} -
- {{ _("Get Certificate") }} -
- {% endif %}
diff --git a/lms/www/batch/learn.js b/lms/www/batch/learn.js index b44d5af9..dc533e7d 100644 --- a/lms/www/batch/learn.js +++ b/lms/www/batch/learn.js @@ -45,6 +45,18 @@ frappe.ready(() => { clear_work(e); }); + $(".btn-start-quiz").click((e) => { + $("#start-banner").addClass("hide"); + $("#quiz-form").removeClass("hide"); + mark_active_question(); + }); + + if ($("#quiz-title").data("max-attempts")) { + window.addEventListener("beforeunload", (e) => { + e.returnValue = ""; + $(".active-question").length && quiz_summary(); + }); + } }); const save_current_lesson = () => { @@ -65,19 +77,19 @@ const enable_check = (e) => { }; const mark_active_question = (e = undefined) => { - var current_index; - var next_index = 1; - if (e) { - e.preventDefault(); - current_index = $(".active-question").attr("data-qt-index"); - next_index = parseInt(current_index) + 1; - } - $(".question").addClass("hide").removeClass("active-question"); - $(`.question[data-qt-index='${next_index}']`).removeClass("hide").addClass("active-question"); - $(".current-question").text(`${next_index}`); - $("#check").removeClass("hide").attr("disabled", true); - $("#next").addClass("hide"); - $(".explanation").addClass("hide"); + $(".timer").addClass("hide"); + calculate_and_display_time(100); + $(".timer").removeClass("hide"); + + let current_index = $(".active-question").attr("data-qt-index") || 0; + let next_index = parseInt(current_index) + 1; + $(".question").addClass("hide").removeClass("active-question"); + $(`.question[data-qt-index='${next_index}']`).removeClass("hide").addClass("active-question"); + $(".current-question").text(`${next_index}`); + $("#check").removeClass("hide").attr("disabled", true); + $("#next").addClass("hide"); + $(".explanation").addClass("hide"); + initialize_timer(); }; const mark_progress = (e) => { @@ -85,7 +97,7 @@ const mark_progress = (e) => { if ($(e.currentTarget).prop("nodeName") != "INPUT") e.preventDefault(); else - return + return; const target = $(e.currentTarget).attr("data-progress") ? $(e.currentTarget) : $("input.mark-progress"); const current_status = $(".lesson-progress").hasClass("hide") ? "Incomplete": "Complete"; @@ -152,57 +164,56 @@ const move_to_next_lesson = (status, e) => { } }; -const quiz_summary = (e) => { - e.preventDefault(); - var quiz_name = $("#quiz-title").text(); - var total_questions = $(".question").length; - - frappe.call({ - method: "lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary", - args: { - "quiz": quiz_name, - "results": localStorage.getItem(quiz_name) - }, - callback: (data) => { - var message = data.message == total_questions ? "Excellent Work" : "You were almost there." - $(".question").addClass("hide"); - $("#summary").addClass("hide"); - $("#quiz-form").parent().prepend( - `

${message} 👏

-
${data.message}/${total_questions} correct.
`); - $("#try-again").removeClass("hide"); - } - }) +const quiz_summary = (e=undefined) => { + e && e.preventDefault(); + var quiz_name = $("#quiz-title").text(); + var total_questions = $(".question").length; + frappe.call({ + method: "lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary", + args: { + "quiz": quiz_name, + "results": localStorage.getItem(quiz_name) + }, + callback: (data) => { + var message = data.message == total_questions ? __("Excellent Work 👏") : __("Better luck next time") + $(".question").addClass("hide"); + $("#summary").addClass("hide"); + $("#quiz-form").parent().prepend( + `

${message}

+
${data.message}/${total_questions}
`); + $("#try-again").removeClass("hide"); + } + }) }; const try_quiz_again = (e) => { window.location.reload(); }; -const check_answer = (e) => { - e.preventDefault(); +const check_answer = (e=undefined) => { + e && e.preventDefault(); + clearInterval(self.timer); + $(".timer").addClass("hide"); + var quiz_name = $("#quiz-title").text(); + var total_questions = $(".question").length; + var current_index = $(".active-question").attr("data-qt-index"); - var quiz_name = $("#quiz-title").text(); - var total_questions = $(".question").length; - var current_index = $(".active-question").attr("data-qt-index"); + $(".explanation").removeClass("hide"); + $("#check").addClass("hide"); - $(".explanation").removeClass("hide"); - $("#check").addClass("hide"); - - if (current_index == total_questions) { - if ($(".eligible-for-submission").length) { - $("#summary").removeClass("hide") + if (current_index == total_questions) { + if ($(".eligible-for-submission").length) { + $("#summary").removeClass("hide"); + } + else { + $("#submission-message").removeClass("hide"); + } } else { - $("#submission-message").removeClass("hide"); + $("#next").removeClass("hide"); } - } - else { - $("#next").removeClass("hide") - } - - var [answer, is_correct] = parse_options(); - add_to_local_storage(quiz_name, current_index, answer, is_correct) + var [answer, is_correct] = parse_options(); + add_to_local_storage(quiz_name, current_index, answer, is_correct); }; const parse_options = () => { @@ -381,3 +392,35 @@ const fetch_assignments = () => { } }); }; + +const initialize_timer = () => { + this.time_left = $(".timer").data("time"); + calculate_and_display_time(100, this.time_left); + $(".timer").removeClass("hide"); + const total_time = $(".timer").data("time"); + this.start_time = new Date().getTime(); + const self = this; + let old_diff; + + this.timer = setInterval(function () { + var diff = (new Date().getTime() - self.start_time)/1000; + var variation = old_diff ? diff - old_diff : diff; + old_diff = diff; + self.time_left -= variation; + let percent_time = (self.time_left / total_time) * 100; + calculate_and_display_time(percent_time); + if (self.time_left <= 0) { + clearInterval(self.timer); + $(".timer").addClass("hide"); + check_answer(); + } + }, 100); +}; + +const calculate_and_display_time = (percent_time) => { + $(".timer .progress-bar").attr("aria-valuenow", percent_time); + $(".timer .progress-bar").attr("aria-valuemax", percent_time); + $(".timer .progress-bar").css("width", `${percent_time}%`); + let progress_color = percent_time < 20 ? "red" : "var(--primary-color)"; + $(".timer .progress-bar").css("background-color", progress_color); +}; diff --git a/lms/www/courses/certificate.html b/lms/www/courses/certificate.html index dbc3cc06..dd5e873c 100644 --- a/lms/www/courses/certificate.html +++ b/lms/www/courses/certificate.html @@ -5,18 +5,23 @@
- {% if certificate.member == frappe.session.user %} + - +
+ {% if custom_template %} + {{ custom_template }} + {% else %} {% include "lms/templates/certificate.html" %} + {% endif %} + +
{% endblock %} diff --git a/lms/www/courses/certificate.js b/lms/www/courses/certificate.js index 4b8b66a2..f8fe5933 100644 --- a/lms/www/courses/certificate.js +++ b/lms/www/courses/certificate.js @@ -1,16 +1,16 @@ frappe.ready(() => { - $("#export-as-pdf").click((e) => { - export_as_pdf(e); - }) + $("#export-as-pdf").click((e) => { + export_as_pdf(e); + }) -}) +}); -var export_as_pdf = (e) => { - var button = $(e.currentTarget); - button.text(__("Exporting...")); +const export_as_pdf = (e) => { + let button = $(e.currentTarget); + button.text(__("Exporting...")); - html2canvas(document.querySelector('.common-card-style'), { + html2canvas(document.querySelector('.certificate-card'), { scrollY: -window.scrollY, scrollX: 0 }).then(function(canvas) { diff --git a/lms/www/courses/certificate.py b/lms/www/courses/certificate.py index fc2fa0be..bf0d2ec6 100644 --- a/lms/www/courses/certificate.py +++ b/lms/www/courses/certificate.py @@ -1,4 +1,6 @@ import frappe +from frappe.utils.jinja import render_template +from lms.lms.utils import get_instructors def get_context(context): context.no_cache = 1 @@ -15,16 +17,21 @@ def get_context(context): if context.certificate.course != course_name: redirect_to_course_list() - context.course = frappe.db.get_value("LMS Course", course_name, - ["instructor", "title", "name"], as_dict=True) - - context.instructor = frappe.db.get_value("User", context.course.instructor, - ["full_name", "username"], as_dict=True) - + context.course = frappe.db.get_value("LMS Course", course_name, ["title", "name", "image"], as_dict=True) + context.instructors = (", ").join([x.full_name for x in get_instructors(course_name)]) context.member = frappe.db.get_value("User", context.certificate.member, ["full_name"], as_dict=True) context.logo = frappe.db.get_single_value("Website Settings", "banner_image") + template_name = frappe.db.get_single_value("LMS Settings", "custom_certificate_template") + context.custom_certificate_template = frappe.db.get_value("Web Template", template_name, "template") + context.custom_template = render_template(context.custom_certificate_template, context) + + context.metatags = { + "title": f"{member.full_name} - {course.title}", + "image": course.image, + "keywords": course.title, member.full_name + } def redirect_to_course_list(): frappe.local.flags.redirect_location = "/courses"