Merge pull request #551 from pateljannat/quiz-in-classes

feat: quiz in classes
This commit is contained in:
Jannat Patel
2023-06-22 11:51:13 +05:30
committed by GitHub
22 changed files with 699 additions and 472 deletions

View File

@@ -184,6 +184,10 @@ website_route_rules = [
"from_route": "/assignment-submission/<assignment>/<submission>", "from_route": "/assignment-submission/<assignment>/<submission>",
"to_route": "assignment_submission/assignment_submission", "to_route": "assignment_submission/assignment_submission",
}, },
{
"from_route": "/quiz-submission/<quiz>/<submission>",
"to_route": "quiz_submission/quiz_submission",
},
] ]
website_redirects = [ website_redirects = [

View File

@@ -39,7 +39,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"default": "0", "default": "1",
"fieldname": "max_attempts", "fieldname": "max_attempts",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Max Attempts" "label": "Max Attempts"
@@ -48,6 +48,7 @@
"default": "0", "default": "0",
"fieldname": "time", "fieldname": "time",
"fieldtype": "Int", "fieldtype": "Int",
"hidden": 1,
"label": "Time Per Question (in Seconds)" "label": "Time Per Question (in Seconds)"
}, },
{ {
@@ -69,7 +70,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2022-11-15 15:36:39.585488", "modified": "2023-06-21 09:13:01.322701",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "LMS Quiz", "name": "LMS Quiz",

View File

@@ -2,12 +2,11 @@
# For license information, please see license.txt # For license information, please see license.txt
import json import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cstr from frappe.utils import cstr
from lms.lms.utils import generate_slug, has_course_moderator_role from lms.lms.utils import generate_slug, has_course_moderator_role, can_create_courses
class LMSQuiz(Document): class LMSQuiz(Document):
@@ -126,7 +125,7 @@ def quiz_summary(quiz, results):
score += correct score += correct
del result["question_index"] del result["question_index"]
frappe.get_doc( submission = frappe.get_doc(
{ {
"doctype": "LMS Quiz Submission", "doctype": "LMS Quiz Submission",
"quiz": quiz, "quiz": quiz,
@@ -134,19 +133,31 @@ def quiz_summary(quiz, results):
"score": score, "score": score,
"member": frappe.session.user, "member": frappe.session.user,
} }
).save(ignore_permissions=True) )
submission.save(ignore_permissions=True)
return score return {
"score": score,
"submission": submission.name,
}
@frappe.whitelist() @frappe.whitelist()
def save_quiz(quiz_title, quiz): def save_quiz(quiz_title, max_attempts=1, quiz=None):
if not can_create_courses():
return
values = {
"title": quiz_title,
"max_attempts": max_attempts,
}
if quiz: if quiz:
frappe.db.set_value("LMS Quiz", quiz, "title", quiz_title) frappe.db.set_value("LMS Quiz", quiz, values)
return quiz return quiz
else: else:
doc = frappe.new_doc("LMS Quiz") doc = frappe.new_doc("LMS Quiz")
doc.update({"title": quiz_title}) doc.update(values)
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
return doc.name return doc.name

View File

@@ -1,7 +1,4 @@
import hashlib import hashlib
import random
import re
import frappe import frappe
import requests import requests
from frappe import _ from frappe import _

View File

@@ -16,6 +16,7 @@ be loaded in a webpage.
import frappe import frappe
from urllib.parse import quote from urllib.parse import quote
from frappe import _
class PageExtension: class PageExtension:
@@ -102,21 +103,36 @@ def set_mandatory_fields_for_profile():
def quiz_renderer(quiz_name): def quiz_renderer(quiz_name):
if frappe.session.user == "Guest":
return " <div class='alert alert-info'>" + _(
"Quiz is not available to Guest users. Please login to continue."
)
+"</div>"
quiz = frappe.get_doc("LMS Quiz", quiz_name) quiz = frappe.get_doc("LMS Quiz", quiz_name)
context = {"quiz": quiz}
no_of_attempts = frappe.db.count( no_of_attempts = frappe.db.count(
"LMS Quiz Submission", {"owner": frappe.session.user, "quiz": quiz_name} "LMS Quiz Submission", {"owner": frappe.session.user, "quiz": quiz_name}
) )
if quiz.max_attempts and no_of_attempts >= quiz.max_attempts: all_submissions = frappe.get_all(
last_attempt_score = frappe.db.get_value( "LMS Quiz Submission",
"LMS Quiz Submission", {"owner": frappe.session.user, "quiz": quiz_name}, ["score"] {
"quiz": quiz.name,
"member": frappe.session.user,
},
["name", "score", "creation"],
order_by="creation desc",
) )
context.update({"attempts_exceeded": True, "last_attempt_score": last_attempt_score}) return frappe.render_template(
return frappe.render_template("templates/quiz.html", context) "templates/quiz/quiz.html",
{
"quiz": quiz,
"no_of_attempts": no_of_attempts,
"all_submissions": all_submissions,
"hide_quiz": False,
},
)
def exercise_renderer(argument): def exercise_renderer(argument):

View File

@@ -1950,7 +1950,7 @@ select {
} }
.modal-title { .modal-title {
font-size: var(--text-base) !important; font-size: var(--text-lg) !important;
} }
.class-form-title { .class-form-title {

View File

@@ -70,7 +70,7 @@ const file_size = (value) => {
const join_course = (e) => { const join_course = (e) => {
e.preventDefault(); e.preventDefault();
let course = $("#outline-heading").attr("data-course"); let course = $(e.currentTarget).attr("data-course");
if (frappe.session.user == "Guest") { if (frappe.session.user == "Guest") {
window.location.href = `/login?redirect-to=/courses/${course}`; window.location.href = `/login?redirect-to=/courses/${course}`;
return; return;

View File

@@ -1,16 +1,26 @@
{% if attempts_exceeded %} {% if not hide_quiz %}
<div class=""> <div class="mt-4">
<h2 class="mt-3"> <ul class="alert alert-info pl-8">
{{ quiz.title }}
</h2> <li>
<div class="alert alert-info medium mb-0"> {{ _("This quiz consists of {0} questions.").format(quiz.questions | length) }}
{{ _("You have already exceeded the maximum number of attempts allowed for this quiz.") }} </li>
{{ _("Your latest score is {0}.").format(last_attempt_score) }}
</div> {% if quiz.max_attempts %}
</div> {% set suffix = "times" if quiz.max_attempts > 1 else "time" %}
<li>
{{ _("You can attempt this quiz only {0} {1}").format(quiz.max_attempts, suffix) }}
</li>
{% endif %}
{% if quiz.time %}
<li class="alert alert-info medium">
{{ _("The quiz has a time limit. For each question you will be given {0} seconds.").format(quiz.time) }}
</li>
{% endif %}
</ul>
{% else %}
<div class="">
<div id="start-banner" class="common-card-style column-card align-items-center"> <div id="start-banner" class="common-card-style column-card align-items-center">
<div class="text-center my-10"> <div class="text-center my-10">
@@ -18,29 +28,16 @@
{{ quiz.title }} {{ quiz.title }}
</div> </div>
<div class=""> {% if not quiz.max_attempts or no_of_attempts < quiz.max_attempts %}
{{ _("This quiz consists of {0} questions.").format(quiz.questions | length) }}
</div>
{% if quiz.max_attempts %}
{% set suffix = "times" if quiz.max_attempts > 1 else "time" %}
<div class="alert alert-info medium">
{{ _("This quiz can only be taken {0} {1}. If you attempt the quiz but leave the page before submitting,
the quiz will be automatically submitted.").format(quiz.max_attempts, suffix) }}
</div>
{% endif %}
{% if quiz.time %}
<div class="alert alert-info medium">
{{ _("The quiz has a time limit. For each question you will be given {0} seconds.").format(quiz.time) }}
</div>
{% endif %}
<button class="btn btn-secondary btn-sm btn-start-quiz mt-4"> <button class="btn btn-secondary btn-sm btn-start-quiz mt-4">
{{ _("Start the Quiz") }} {{ _("Start") }}
</button> </button>
{% else %}
<div>
{{ _("You have already exceeded the maximum number of attempts allowed for this quiz.") }}
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -108,23 +105,50 @@
</div> </div>
{% endif %} {% endif %}
<button class="btn btn-secondary btn-sm pull-right" id="check" disabled> <button class="btn btn-secondary btn-sm pull-right" id="check" disabled>
{{ _("Check") }} {{ _("Check") }}
</button> </button>
<div class="btn btn-secondary btn-sm hide" id="next"> <button class="btn btn-secondary btn-sm hide" id="next">
{{ _("Next Question") }} {{ _("Next Question") }}
</div> </button>
<div class="btn btn-secondary btn-sm hide" id="summary"> <button class="btn btn-secondary btn-sm hide" id="summary">
{{ _("Submit") }} {{ _("Submit") }}
</div> </button>
<small id="submission-message" class="font-weight-bold hide"> <button class="btn btn-secondary btn-sm mx-auto hide" id="try-again" data-quiz="{{ quiz.name }}">
{{ _("Please join the course to submit the Quiz.") }}
</small>
<div class="btn btn-secondary btn-sm hide" id="try-again">
{{ _("Try Again") }} {{ _("Try Again") }}
</div> </button>
</div> </div>
</form> </form>
</div> </div>
{% endif %} {% endif %}
{% if all_submissions | length %}
<article class="mt-8">
<div class="field-label">
{{ _("All Submissions") }}
</div>
<div class="form-grid">
<div class="grid-heading-row">
<div class="grid-row">
<div class="data-row row">
<div class="col grid-static-col">{{ _("No.") }}</div>
<div class="col grid-static-col">{{ _("Date") }}</div>
<div class="col grid-static-col text-right">{{ _("Score") }}</div>
</div>
</div>
</div>
<div>
{% for submission in all_submissions %}
<div class="grid-row">
<div class="data-row row">
<div class="col grid-static-col">{{ loop.index }}</div>
<div class="col grid-static-col">{{ frappe.utils.format_datetime(submission.creation, "medium") }}</div>
<div class="col grid-static-col text-right">{{ submission.score }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
</article>
{% endif %}

266
lms/templates/quiz/quiz.js Normal file
View File

@@ -0,0 +1,266 @@
frappe.ready(() => {
this.quiz_submitted = false;
this.answer = [];
this.is_correct = [];
const self = this;
localStorage.removeItem($("#quiz-title").data("name"));
$(".btn-start-quiz").click((e) => {
$("#start-banner").addClass("hide");
$("#quiz-form").removeClass("hide");
mark_active_question();
});
$(".option").click((e) => {
if (!$("#check").hasClass("hide")) enable_check(e);
});
$(".possibility").keyup((e) => {
enable_check(e);
});
$("#summary").click((e) => {
e.preventDefault();
add_to_local_storage();
quiz_summary(e);
});
$("#check").click((e) => {
e.preventDefault();
check_answer(e);
});
$("#next").click((e) => {
e.preventDefault();
add_to_local_storage();
mark_active_question(e);
});
$("#try-again").click((e) => {
try_quiz_again(e);
});
});
const mark_active_question = (e = undefined) => {
$(".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 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);
};
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 enable_check = (e) => {
if ($(".option:checked").length || $(".possibility").val().trim()) {
$("#check").removeAttr("disabled");
$(".custom-checkbox").removeClass("active-option");
$(".option:checked")
.closest(".custom-checkbox")
.addClass("active-option");
}
};
const quiz_summary = (e = undefined) => {
e && e.preventDefault();
let quiz_name = $("#quiz-title").data("name");
let total_questions = $(".question").length;
let self = this;
frappe.call({
method: "lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary",
args: {
quiz: quiz_name,
results: localStorage.getItem(quiz_name),
},
callback: (data) => {
$(".question").addClass("hide");
$("#summary").addClass("hide");
$(".quiz-footer span").addClass("hide");
$("#quiz-form").prepend(
`<div class="summary bold-heading text-center">
${__("Your score is")} ${data.message.score}
${__("out of")} ${total_questions}
</div>`
);
$("#try-again").attr("data-submission", data.message.submission);
$("#try-again").removeClass("hide");
self.quiz_submitted = true;
},
});
};
const try_quiz_again = (e) => {
e.preventDefault();
if (window.location.href.includes("new-submission")) {
const target = $(e.currentTarget);
window.location.href = `/quiz-submission/
${target.data("quiz")}/
${target.data("submission")}`;
} else {
window.location.reload();
}
};
const check_answer = (e = undefined) => {
e && e.preventDefault();
let answer = $(".active-question textarea");
if (answer.length && !answer.val().trim()) {
frappe.throw(__("Please enter your answer"));
}
clearInterval(self.timer);
$(".timer").addClass("hide");
let total_questions = $(".question").length;
let current_index = $(".active-question").attr("data-qt-index");
$(".explanation").removeClass("hide");
$("#check").addClass("hide");
if (current_index == total_questions) {
$("#summary").removeClass("hide");
} else {
$("#next").removeClass("hide");
}
parse_options();
};
const parse_options = () => {
let type = $(".active-question").data("type");
if (type == "Choices") {
$(".active-question input").each((i, element) => {
is_answer_correct(type, element);
});
} else {
is_answer_correct(type, $(".active-question textarea"));
}
};
const is_answer_correct = (type, element) => {
let answer = decodeURIComponent($(element).val());
frappe.call({
async: false,
method: "lms.lms.doctype.lms_quiz.lms_quiz.check_answer",
args: {
question: $(".active-question").data("name"),
type: type,
answer: answer,
},
callback: (data) => {
type == "Choices"
? parse_choices(element, data.message)
: parse_possible_answers(element, data.message);
},
});
};
const parse_choices = (element, correct) => {
if ($(element).prop("checked")) {
self.answer.push(decodeURIComponent($(element).val()));
correct && self.is_correct.push(1);
correct ? add_icon(element, "check") : add_icon(element, "wrong");
} else {
correct && self.is_correct.push(0);
correct
? add_icon(element, "minus-circle-green")
: add_icon(element, "minus-circle");
}
};
const parse_possible_answers = (element, correct) => {
self.answer.push(decodeURIComponent($(element).val()));
if (correct) {
self.is_correct.push(1);
show_indicator("success", element);
} else {
self.is_correct.push(0);
show_indicator("failure", element);
}
};
const show_indicator = (class_name, element) => {
let label = class_name == "success" ? "Correct" : "Incorrect";
let icon =
class_name == "success" ? "#icon-solid-success" : "#icon-solid-error";
$(`<div class="answer-indicator ${class_name}">
<svg class="icon icon-md">
<use href=${icon}>
</svg>
<span style="font-weight: 500">${__(label)}</span>
</div>`).insertAfter(element);
};
const add_icon = (element, icon) => {
$(element).closest(".custom-checkbox").removeClass("active-option");
$(element).closest(".option").addClass("hide");
let label = $(element).siblings(".option-text").text();
$(element).siblings(".option-text").html(`
<div>
<img class="d-inline mr-3" src="/assets/lms/icons/${icon}.svg">
${label}
</div>
`);
};
const add_to_local_storage = () => {
let current_index = $(".active-question").attr("data-qt-index");
let quiz_name = $("#quiz-title").data("name");
let quiz_stored = JSON.parse(localStorage.getItem(quiz_name));
let quiz_obj = {
question_index: current_index,
answer: self.answer.join(),
is_correct: self.is_correct,
};
quiz_stored ? quiz_stored.push(quiz_obj) : (quiz_stored = [quiz_obj]);
localStorage.setItem(quiz_name, JSON.stringify(quiz_stored));
self.answer = [];
self.is_correct = [];
};

View File

@@ -32,6 +32,8 @@
<a class="dark-links" href="/classes"> <a class="dark-links" href="/classes">
{{ _("All Classes") }} {{ _("All Classes") }}
</a> </a>
<img class="icon icon-sm mr-0" src="/assets/lms/icons/chevron-right.svg">
<span class="breadcrumb-destination">{{ _("Assignment Submission") }}</span>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,10 @@ from lms.lms.utils import has_course_moderator_role
def get_context(context): def get_context(context):
context.no_cache = 1 context.no_cache = 1
if frappe.session.user == "Guest":
raise frappe.PermissionError(_("You don't have permission to access this page."))
context.is_moderator = has_course_moderator_role() context.is_moderator = has_course_moderator_role()
submission = frappe.form_dict["submission"] submission = frappe.form_dict["submission"]
assignment = frappe.form_dict["assignment"] assignment = frappe.form_dict["assignment"]

View File

@@ -87,8 +87,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="course-home-headings title {% if membership %} is-member {% endif %} <div class="course-home-headings title {% if membership %} is-member {% endif %}" id="title"
{% if membership or is_instructor %} eligible-for-submission {% endif %}" id="title"
data-index="{{ lesson_index }}" data-course="{{ course.name }}" data-chapter="{{ chapter }}" data-index="{{ lesson_index }}" data-course="{{ course.name }}" data-chapter="{{ chapter }}"
{% if lesson.name %} data-lesson="{{ lesson.name }}" {% endif %} {% if lesson.name %} data-lesson="{{ lesson.name }}" {% endif %}
>{% if lesson.title %}{{ lesson.title }}{% endif %}</div> >{% if lesson.title %}{{ lesson.title }}{% endif %}</div>
@@ -246,6 +245,7 @@
{{ super() }} {{ super() }}
<script type="text/javascript"> <script type="text/javascript">
var page_context = {{ page_context | tojson }}; var page_context = {{ page_context | tojson }};
{% include "lms/templates/quiz/quiz.js" %}
</script> </script>
{% for ext in page_extensions %} {% for ext in page_extensions %}
{{ ext.render_footer() }} {{ ext.render_footer() }}

View File

@@ -1,25 +1,13 @@
frappe.ready(() => { frappe.ready(() => {
this.marked_as_complete = false; this.marked_as_complete = false;
this.quiz_submitted = false;
this.answer = [];
this.is_correct = [];
let self = this; let self = this;
frappe.telemetry.capture("on_lesson_page", "lms"); frappe.telemetry.capture("on_lesson_page", "lms");
localStorage.removeItem($("#quiz-title").data("name"));
fetch_assignments(); fetch_assignments();
save_current_lesson(); save_current_lesson();
$(".option").click((e) => {
if (!$("#check").hasClass("hide")) enable_check(e);
});
$(".possibility").keyup((e) => {
enable_check(e);
});
$(window).scroll(() => { $(window).scroll(() => {
let self = this; let self = this;
if ( if (
@@ -32,24 +20,6 @@ frappe.ready(() => {
} }
}); });
$("#summary").click((e) => {
add_to_local_storage();
quiz_summary(e);
});
$("#check").click((e) => {
check_answer(e);
});
$("#next").click((e) => {
add_to_local_storage();
mark_active_question(e);
});
$("#try-again").click((e) => {
try_quiz_again(e);
});
$("#certification").click((e) => { $("#certification").click((e) => {
create_certificate(e); create_certificate(e);
}); });
@@ -62,12 +32,6 @@ frappe.ready(() => {
clear_work(e); clear_work(e);
}); });
$(".btn-start-quiz").click((e) => {
$("#start-banner").addClass("hide");
$("#quiz-form").removeClass("hide");
mark_active_question();
});
$(".btn-back").click((e) => { $(".btn-back").click((e) => {
window.location.href = window.location.href.split("?")[0]; window.location.href = window.location.href.split("?")[0];
}); });
@@ -76,15 +40,6 @@ frappe.ready(() => {
frappe.utils.copy_to_clipboard($(e.currentTarget).data("link")); frappe.utils.copy_to_clipboard($(e.currentTarget).data("link"));
$(".attachments").collapse("hide"); $(".attachments").collapse("hide");
}); });
if ($("#quiz-title").data("max-attempts")) {
window.addEventListener("beforeunload", (e) => {
e.returnValue = "";
if ($(".active-question").length && !self.quiz_submitted) {
quiz_summary();
}
});
}
}); });
const save_current_lesson = () => { const save_current_lesson = () => {
@@ -96,35 +51,6 @@ const save_current_lesson = () => {
} }
}; };
const enable_check = (e) => {
if ($(".option:checked").length || $(".possibility").val().trim()) {
$("#check").removeAttr("disabled");
$(".custom-checkbox").removeClass("active-option");
$(".option:checked")
.closest(".custom-checkbox")
.addClass("active-option");
}
};
const mark_active_question = (e = undefined) => {
$(".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 = () => { const mark_progress = () => {
let status = "Complete"; let status = "Complete";
frappe.call({ frappe.call({
@@ -155,166 +81,6 @@ const show_certificate_if_course_completed = (data) => {
} }
}; };
const quiz_summary = (e = undefined) => {
e && e.preventDefault();
let quiz_name = $("#quiz-title").data("name");
let total_questions = $(".question").length;
let self = this;
frappe.call({
method: "lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary",
args: {
quiz: quiz_name,
results: localStorage.getItem(quiz_name),
},
callback: (data) => {
$(".question").addClass("hide");
$("#summary").addClass("hide");
$("#quiz-form")
.parent()
.prepend(
`<div class="summary">
<div class="font-weight-bold"> ${__("Score")}: ${
data.message
}/${total_questions} </div>
</div>`
);
$("#try-again").removeClass("hide");
self.quiz_submitted = true;
},
});
};
const try_quiz_again = (e) => {
window.location.reload();
};
const check_answer = (e = undefined) => {
e && e.preventDefault();
let answer = $(".active-question textarea");
if (answer.length && !answer.val().trim()) {
frappe.throw(__("Please enter your answer"));
}
clearInterval(self.timer);
$(".timer").addClass("hide");
let total_questions = $(".question").length;
let current_index = $(".active-question").attr("data-qt-index");
$(".explanation").removeClass("hide");
$("#check").addClass("hide");
if (current_index == total_questions) {
if ($(".eligible-for-submission").length) {
$("#summary").removeClass("hide");
} else {
$("#submission-message").removeClass("hide");
}
} else {
$("#next").removeClass("hide");
}
parse_options();
};
const parse_options = () => {
let type = $(".active-question").data("type");
if (type == "Choices") {
$(".active-question input").each((i, element) => {
is_answer_correct(type, element);
});
} else {
is_answer_correct(type, $(".active-question textarea"));
}
};
const is_answer_correct = (type, element) => {
let answer = decodeURIComponent($(element).val());
frappe.call({
async: false,
method: "lms.lms.doctype.lms_quiz.lms_quiz.check_answer",
args: {
question: $(".active-question").data("name"),
type: type,
answer: answer,
},
callback: (data) => {
type == "Choices"
? parse_choices(element, data.message)
: parse_possible_answers(element, data.message);
},
});
};
const parse_choices = (element, correct) => {
if ($(element).prop("checked")) {
self.answer.push(decodeURIComponent($(element).val()));
correct && self.is_correct.push(1);
correct ? add_icon(element, "check") : add_icon(element, "wrong");
} else {
correct && self.is_correct.push(0);
correct
? add_icon(element, "minus-circle-green")
: add_icon(element, "minus-circle");
}
};
const parse_possible_answers = (element, correct) => {
self.answer.push(decodeURIComponent($(element).val()));
if (correct) {
self.is_correct.push(1);
show_indicator("success", element);
} else {
self.is_correct.push(0);
show_indicator("failure", element);
}
};
const show_indicator = (class_name, element) => {
let label = class_name == "success" ? "Correct" : "Incorrect";
let icon =
class_name == "success" ? "#icon-solid-success" : "#icon-solid-error";
$(`<div class="answer-indicator ${class_name}">
<svg class="icon icon-md">
<use href=${icon}>
</svg>
<span style="font-weight: 500">${__(label)}</span>
</div>`).insertAfter(element);
};
const add_icon = (element, icon) => {
$(element).closest(".custom-checkbox").removeClass("active-option");
$(element).closest(".option").addClass("hide");
let label = $(element).siblings(".option-text").text();
$(element).siblings(".option-text").html(`
<div>
<img class="d-inline mr-3" src="/assets/lms/icons/${icon}.svg">
${label}
</div>
`);
};
const add_to_local_storage = () => {
let current_index = $(".active-question").attr("data-qt-index");
let quiz_name = $("#quiz-title").data("name");
let quiz_stored = JSON.parse(localStorage.getItem(quiz_name));
let quiz_obj = {
question_index: current_index,
answer: self.answer.join(),
is_correct: self.is_correct,
};
quiz_stored ? quiz_stored.push(quiz_obj) : (quiz_stored = [quiz_obj]);
localStorage.setItem(quiz_name, JSON.stringify(quiz_stored));
self.answer = [];
self.is_correct = [];
};
const create_certificate = (e) => { const create_certificate = (e) => {
e.preventDefault(); e.preventDefault();
course = $(".title").attr("data-course"); course = $(".title").attr("data-course");
@@ -483,35 +249,3 @@ 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);
};

View File

@@ -58,13 +58,16 @@
</div> </div>
</div> </div>
{% if quiz.name %}
<div class="align-self-center"> <div class="align-self-center">
<button class="btn btn-primary btn-sm btn-add-question"> {% if quiz.name %}
<button class="btn btn-secondary btn-sm btn-add-question mr-2">
{{ _("Add Question") }} {{ _("Add Question") }}
</button> </button>
</div>
{% endif %} {% endif %}
<button class="btn btn-primary btn-sm btn-save-quiz">
{{ _("Save") }}
</button>
</div>
</div> </div>
</div> </div>
@@ -86,6 +89,19 @@
<input type="text" class="field-input" id="quiz-title" {% if quiz.name %} value="{{ quiz.title }}" data-title="{{ quiz.title }}" {% endif %}> <input type="text" class="field-input" id="quiz-title" {% if quiz.name %} value="{{ quiz.title }}" data-title="{{ quiz.title }}" {% endif %}>
</div> </div>
</div> </div>
<div class="field-group">
<div class="field-label">
{{ _("Max Attempts") }}
</div>
<div class="field-description">
{{ _("Enter the maximum number of times a user can attempt this quiz") }}
</div>
<div>
{% set max_attempts = quiz.max_attempts if quiz.name else 1 %}
<input type="number" class="field-input" id="max-attempts" value="{{ max_attempts }}">
</div>
</div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -1,8 +1,9 @@
frappe.ready(() => { frappe.ready(() => {
$("#quiz-title").focusout((e) => { $(".btn-save-quiz").click((e) => {
if ($("#quiz-title").val() != $("#quiz-title").data("title")) { save_quiz({
save_quiz({ quiz_title: $("#quiz-title").val() }); quiz_title: $("#quiz-title").val(),
} max_attempts: $("#max-attempts").val(),
});
}); });
$(".question-row").click((e) => { $(".question-row").click((e) => {
@@ -14,26 +15,6 @@ frappe.ready(() => {
}); });
}); });
const show_quiz_modal = () => {
let quiz_dialog = new frappe.ui.Dialog({
title: __("Create Quiz"),
fields: [
{
fieldtype: "Data",
label: __("Quiz Title"),
fieldname: "quiz_title",
reqd: 1,
},
],
primary_action: (values) => {
quiz_dialog.hide();
save_quiz(values);
},
});
quiz_dialog.show();
};
const show_question_modal = (values = {}) => { const show_question_modal = (values = {}) => {
let fields = get_question_fields(values); let fields = get_question_fields(values);
@@ -142,6 +123,7 @@ const save_quiz = (values) => {
method: "lms.lms.doctype.lms_quiz.lms_quiz.save_quiz", method: "lms.lms.doctype.lms_quiz.lms_quiz.save_quiz",
args: { args: {
quiz_title: values.quiz_title, quiz_title: values.quiz_title,
max_attempts: values.max_attempts,
quiz: $("#quiz-form").data("name") || "", quiz: $("#quiz-form").data("name") || "",
}, },
callback: (data) => { callback: (data) => {

View File

@@ -20,7 +20,9 @@ def get_context(context):
else: else:
fields_arr = ["name", "question", "type"] fields_arr = ["name", "question", "type"]
context.quiz = frappe.db.get_value("LMS Quiz", quizname, ["title", "name"], as_dict=1) context.quiz = frappe.db.get_value(
"LMS Quiz", quizname, ["title", "name", "max_attempts"], as_dict=1
)
context.quiz.questions = frappe.get_all( context.quiz.questions = frappe.get_all(
"LMS Quiz Question", {"parent": quizname}, fields_arr, order_by="idx" "LMS Quiz Question", {"parent": quizname}, fields_arr, order_by="idx"
) )

View File

@@ -260,24 +260,31 @@
{{ _("Create New") }} {{ _("Create New") }}
</div> </div>
<p class="field-description"> <p class="field-description">
{{ _("To create a new assignment for this class, click on the create assignment button. Once you have created the new assignment you can come back to the class and add the assignment from here.") }} {{ _("To create a new assignment or quiz for this class, click on the buttons below. Once you have created the new assignment or quiz you can come back and add it from here.") }}
</p> </p>
<div> <div class="flex">
<a class="btn btn-default btn-sm" href="/assignments/new-assignment"> <a class="btn btn-default btn-sm" href="/assignments/new-assignment">
{{ _("Create Assignment") }} {{ _("Create Assignment") }}
</a> </a>
<a class="btn btn-default btn-sm ml-2" href="/quizzes/new-quiz">
{{ _("Create Quiz") }}
</a>
</div> </div>
</div> </div>
<form class="" id="assessment-form"> <form class="" id="assessment-form">
<div class="field-label mb-2">
{{ _("Select Assessments") }}
</div>
<p class="field-description">
{{ _("Select the assessments you wish to include for this class. Your selections will be automatically saved upon clicking. If you decide to remove an item from the list, simply uncheck it.") }}
</p>
<div class="flex justify-content-between">
{% if all_assignments | length %} {% if all_assignments | length %}
<div> <div>
<div class="field-label mb-2"> <div class="field-label mb-2">
{{ _("Assignments") }} {{ _("Assignments") }}
</div> </div>
<p class="field-description">
{{ _("Select the assignments you wish to include for the assessment of this class. Your selections will be automatically saved upon clicking. If you decide to remove an item from the list, simply uncheck it.") }}
</p>
{% for assignment in all_assignments %} {% for assignment in all_assignments %}
<div> <div>
<label class="vertically-center"> <label class="vertically-center">
@@ -288,10 +295,10 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<!-- {% if all_quizzes | length %} {% if all_quizzes | length %}
<div> <div>
<div class="field-label mb-2"> <div class="field-label mb-2">
{{ _("Quiz") }} {{ _("Quizzes") }}
</div> </div>
{% for quiz in all_quizzes %} {% for quiz in all_quizzes %}
<div> <div>
@@ -302,7 +309,9 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} --> {% endif %}
</div>
</form> </form>
</div> </div>

View File

@@ -76,9 +76,8 @@ def get_context(context):
def get_all_quizzes(class_name): def get_all_quizzes(class_name):
all_quizzes = frappe.get_all( filters = {} if has_course_moderator_role() else {"owner": frappe.session.user}
"LMS Quiz", {"owner": frappe.session.user}, ["name", "title"] all_quizzes = frappe.get_all("LMS Quiz", filters, ["name", "title"])
)
for quiz in all_quizzes: for quiz in all_quizzes:
quiz.checked = frappe.db.exists( quiz.checked = frappe.db.exists(
{ {
@@ -92,9 +91,8 @@ def get_all_quizzes(class_name):
def get_all_assignments(class_name): def get_all_assignments(class_name):
all_assignments = frappe.get_all( filters = {} if has_course_moderator_role() else {"owner": frappe.session.user}
"LMS Assignment", {"owner": frappe.session.user}, ["name", "title"] all_assignments = frappe.get_all("LMS Assignment", filters, ["name", "title"])
)
for assignment in all_assignments: for assignment in all_assignments:
assignment.checked = frappe.db.exists( assignment.checked = frappe.db.exists(
{ {

View File

@@ -19,7 +19,7 @@
<div class="edit-header"> <div class="edit-header">
<div> <div>
<div class="page-title"> <div class="page-title">
{{ _("{0}'s Progress").format(student.full_name) }} {{ _("{0}").format(student.full_name) }}
</div> </div>
<div class="vertically-center small"> <div class="vertically-center small">
<a class="dark-links" href="/classes"> <a class="dark-links" href="/classes">
@@ -30,12 +30,15 @@
{{ class_info.name }} {{ class_info.name }}
</a> </a>
<img class="icon icon-sm mr-0" src="/assets/lms/icons/chevron-right.svg"> <img class="icon icon-sm mr-0" src="/assets/lms/icons/chevron-right.svg">
<span class="breadcrumb-destination">{{ _("{0}'s Progress").format(student.full_name) }}</span> <span class="breadcrumb-destination">{{ _("Student Progress").format(student.full_name) }}</span>
</div> </div>
</div> </div>
{% if is_moderator %} {% if is_moderator %}
<div class="align-self-center"> <div class="align-self-center">
<a class="btn btn-default btn-sm mr-2" href="/users/{{ student.username }}">
{{ _("View Profile") }}
</a>
<a class="btn btn-primary btn-sm btn-evaluate" href=/evaluation/new?member={{student.name}}&date={{frappe.utils.getdate()}}&class={{class_info.name}}"> <a class="btn btn-primary btn-sm btn-evaluate" href=/evaluation/new?member={{student.name}}&date={{frappe.utils.getdate()}}&class={{class_info.name}}">
{{ _("Evaluate") }} {{ _("Evaluate") }}
</a> </a>
@@ -50,27 +53,56 @@
{% macro Progress(class_info, student) %} {% macro Progress(class_info, student) %}
{% if assessments | length %} {% if assessments | length %}
<article> <article class="form-grid">
<div class="grid-heading-row">
<div class="grid-row">
<div class="data-row row">
<div class="col grid-static-col">
{{ _("Assessment") }}
</div>
<div class="col grid-static-col">
{{ _("Type") }}
</div>
<div class="col grid-static-col">
{{ _("Status/Score") }}
</div>
</div>
</div>
</div>
{% for assessment in assessments %} {% for assessment in assessments %}
<div class="list-row level"> {% set has_access = is_moderator and assessment.submission or frappe.session.user == student.name %}
<a {% if is_moderator and assessment.submission or frappe.session.user == student.name %} class="clickable" href="{{ assessment.url }}" {% endif %}> <div class="grid-row">
<div class="data-row row">
<a class="col grid-static-col {% if has_access %} clickable {% endif %}" {% if has_access %} href="{{ assessment.url }}" {% endif %}>
{{ assessment.title }} {{ assessment.title }}
</a> </a>
<div class="col grid-static-col">
{{ (assessment.assessment_type).split("LMS ")[1] }}
</div>
<div class="col grid-static-col mb-2">
{% if assessment.submission %} {% if assessment.submission %}
{% if assessment.assessment_type == "LMS Assignment" %}
{% set status = assessment.submission.status %} {% set status = assessment.submission.status %}
{% set color = "green" if status == "Pass" else "red" if status == "Fail" else "orange" %} {% set color = "green" if status == "Pass" else "red" if status == "Fail" else "orange" %}
<div>
<div class="indicator-pill {{ color }}"> <div class="indicator-pill {{ color }}">
{{ assessment.submission.status }} {{ status }}
</div> </div>
{% else %}
<div>
{{ assessment.submission.score }}
</div> </div>
{% endif %}
{% else %} {% else %}
<div class="indicator-pill red"> <div class="indicator-pill red">
{{ _("Not Attempted") }} {{ _("Not Attempted") }}
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
</div>
{% endfor %} {% endfor %}
</article> </article>
{% else %} {% else %}

View File

@@ -0,0 +1,55 @@
{% extends "lms/templates/lms_base.html" %}
{% block title %}
{{ quiz.title }}
{% endblock %}
{% block page_content %}
<main class="common-page-style">
{{ Header() }}
<div class="container form-width">
{{ SubmissionForm(quiz) }}
</div>
</main>
{% endblock %}
{% macro Header() %}
<header class="sticky mb-5">
<div class="container form-width">
<div class="edit-header">
<div>
<div class="vertically-center">
<div class="page-title">
{{ quiz.title }}
</div>
</div>
<div class="vertically-center small">
<a class="dark-links" href="/classes">
{{ _("All Classes") }}
</a>
<img class="icon icon-sm mr-0" src="/assets/lms/icons/chevron-right.svg">
<span class="breadcrumb-destination">{{ _("Quiz Submission") }}</span>
</div>
</div>
<div class="align-self-center">
<!-- <button class="btn btn-primary btn-sm btn-save-assignment" {% if quiz.name %} data-quiz="{{ quiz.name }}" {% endif %}
{% if submission.name %} data-submission="{{ submission.name }}" {% endif %}>
{{ _("Save") }}
</button> -->
</div>
</div>
</div>
</header>
{% endmacro %}
{% macro SubmissionForm(quiz) %}
{% include("lms/templates/quiz/quiz.html") %}
{% endmacro %}
{% block script %}
{{ super() }}
<script type="text/javascript">
{% include "lms/templates/quiz/quiz.js" %}
</script>
{% endblock %}

View File

@@ -0,0 +1,49 @@
import frappe
from lms.lms.utils import has_course_moderator_role
from frappe import _
def get_context(context):
context.no_cache = 1
if frappe.session.user == "Guest":
raise frappe.PermissionError(_("You don't have permission to access this page."))
context.is_moderator = has_course_moderator_role()
submission = frappe.form_dict["submission"]
quiz_name = frappe.form_dict["quiz"]
context.quiz = frappe.get_doc("LMS Quiz", quiz_name)
if submission == "new-submission":
context.submission = frappe._dict()
context.no_of_attempts = 0
context.hide_quiz = False
else:
context.submission = frappe.db.get_value(
"LMS Quiz Submission",
submission,
["name", "score", "member", "member_name"],
as_dict=True,
)
if not context.is_moderator and frappe.session.user != context.submission.member:
raise frappe.PermissionError(_("You don't have permission to access this page."))
if not context.quiz or not context.submission:
raise frappe.PermissionError(_("Invalid Submission URL"))
context.all_submissions = frappe.get_all(
"LMS Quiz Submission",
{
"quiz": context.quiz.name,
"member": context.submission.member,
},
["name", "score", "creation"],
order_by="creation desc",
)
context.no_of_attempts = len(context.all_submissions) or 0
context.hide_quiz = (
context.is_moderator and context.submission.member != frappe.session.user
)

View File

@@ -71,6 +71,15 @@ def get_assessments(class_name, member=None):
for assessment in assessments: for assessment in assessments:
if assessment.assessment_type == "LMS Assignment": if assessment.assessment_type == "LMS Assignment":
assessment = get_assignment_details(assessment, member)
elif assessment.assessment_type == "LMS Quiz":
assessment = get_quiz_details(assessment, member)
return assessments
def get_assignment_details(assessment, member):
assessment.title = frappe.db.get_value( assessment.title = frappe.db.get_value(
"LMS Assignment", assessment.assessment_name, "title" "LMS Assignment", assessment.assessment_name, "title"
) )
@@ -97,9 +106,25 @@ def get_assessments(class_name, member=None):
f"/assignment-submission/{assessment.assessment_name}/{submission_name}" f"/assignment-submission/{assessment.assessment_name}/{submission_name}"
) )
elif assessment.assessment_type == "LMS Quiz":
assessment.title = frappe.db.get_value( def get_quiz_details(assessment, member):
"LMS Quiz", assessment.assessment_name, "title" assessment.title = frappe.db.get_value("LMS Quiz", assessment.assessment_name, "title")
existing_submission = frappe.get_all(
"LMS Quiz Submission",
{
"member": member,
"quiz": assessment.assessment_name,
},
["name", "score"],
order_by="creation desc",
) )
assessment.url = f"/quizzes/{assessment.assessment_name}"
return assessments if len(existing_submission):
assessment.submission = existing_submission[0]
assessment.edit_url = f"/quizzes/{assessment.assessment_name}"
submission_name = (
existing_submission[0].name if len(existing_submission) else "new-submission"
)
assessment.url = f"/quiz-submission/{assessment.assessment_name}/{submission_name}"