From d1a252190b657f4ea64819760b5f653b7a00428e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 3 Jun 2019 12:57:38 +0530 Subject: [PATCH] refactor: refactored quiz api and added quiz.js --- .../course_enrollment/course_enrollment.py | 8 +- erpnext/education/doctype/quiz/quiz.py | 49 ++--- .../doctype/quiz_result/quiz_result.json | 183 +++++------------ erpnext/education/utils.py | 56 +++++- erpnext/public/js/education/lms/quiz.js | 185 ++++++++++++++++++ erpnext/www/lms/content.html | 47 ++++- erpnext/www/lms/content.py | 2 +- 7 files changed, 350 insertions(+), 180 deletions(-) create mode 100644 erpnext/public/js/education/lms/quiz.js diff --git a/erpnext/education/doctype/course_enrollment/course_enrollment.py b/erpnext/education/doctype/course_enrollment/course_enrollment.py index 064b075709..b082be2aa2 100644 --- a/erpnext/education/doctype/course_enrollment/course_enrollment.py +++ b/erpnext/education/doctype/course_enrollment/course_enrollment.py @@ -35,7 +35,7 @@ class CourseEnrollment(Document): if enrollment: frappe.throw(_("Student is already enrolled.")) - def add_quiz_activity(self, quiz_name, quiz_response,answers, score, status): + def add_quiz_activity(self, quiz_name, quiz_response, answers, score, status): result = {k: ('Correct' if v else 'Wrong') for k,v in answers.items()} result_data = [] for key in answers: @@ -43,7 +43,9 @@ class CourseEnrollment(Document): item['question'] = key item['quiz_result'] = result[key] try: - if isinstance(quiz_response[key], list): + if not quiz_response[key]: + item['selected_option'] = "Unattempted" + elif isinstance(quiz_response[key], list): item['selected_option'] = ', '.join(frappe.get_value('Options', res, 'option') for res in quiz_response[key]) else: item['selected_option'] = frappe.get_value('Options', quiz_response[key], 'option') @@ -59,7 +61,7 @@ class CourseEnrollment(Document): "result": result_data, "score": score, "status": status - }).insert() + }).insert(ignore_permissions = True) def add_activity(self, content_type, content): activity = check_activity_exists(self.name, content_type, content) diff --git a/erpnext/education/doctype/quiz/quiz.py b/erpnext/education/doctype/quiz/quiz.py index 6d00d33372..8e54745464 100644 --- a/erpnext/education/doctype/quiz/quiz.py +++ b/erpnext/education/doctype/quiz/quiz.py @@ -11,50 +11,43 @@ class Quiz(Document): if self.passing_score > 100: frappe.throw("Passing Score value should be between 0 and 100") - def validate_quiz_attempts(self, enrollment, quiz_name): - if self.max_attempts > 0: - try: - if len(frappe.get_all("Quiz Activity", {'enrollment': enrollment.name, 'quiz': quiz_name})) >= self.max_attempts: - frappe.throw('Maximum attempts reached!') - except Exception as e: - pass + def allowed_attempt(self, enrollment, quiz_name): + if self.max_attempts == 0: + return True + + try: + if len(frappe.get_all("Quiz Activity", {'enrollment': enrollment.name, 'quiz': quiz_name})) >= self.max_attempts: + frappe.msgprint("Maximum attempts for this quiz reached!") + return False + else: + return True + except Exception as e: + return False def evaluate(self, response_dict, quiz_name): - # self.validate_quiz_attempts(enrollment, quiz_name) questions = [frappe.get_doc('Question', question.question_link) for question in self.question] answers = {q.name:q.get_answer() for q in questions} - correct_answers = {} + result = {} for key in answers: try: if isinstance(response_dict[key], list): - result = compare_list_elementwise(response_dict[key], answers[key]) + is_correct = compare_list_elementwise(response_dict[key], answers[key]) else: - result = (response_dict[key] == answers[key]) - except: - result = False - correct_answers[key] = result - score = (sum(correct_answers.values()) * 100 ) / len(answers) + is_correct = (response_dict[key] == answers[key]) + except Exception as e: + is_correct = False + result[key] = is_correct + score = (sum(result.values()) * 100 ) / len(answers) if score >= self.passing_score: status = "Pass" else: status = "Fail" - return correct_answers, score, status + return result, score, status def get_questions(self): - quiz_question = self.get_all_children() - if quiz_question: - questions = [frappe.get_doc('Question', question.question_link).as_dict() for question in quiz_question] - for question in questions: - correct_options = [option.is_correct for option in question.options] - if sum(correct_options) > 1: - question['type'] = "MultipleChoice" - else: - question['type'] = "SingleChoice" - return questions - else: - return None + return [frappe.get_doc('Question', question.question_link) for question in self.question] def compare_list_elementwise(*args): try: diff --git a/erpnext/education/doctype/quiz_result/quiz_result.json b/erpnext/education/doctype/quiz_result/quiz_result.json index 86505ac756..67c7e2d449 100644 --- a/erpnext/education/doctype/quiz_result/quiz_result.json +++ b/erpnext/education/doctype/quiz_result/quiz_result.json @@ -1,145 +1,52 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-10-15 15:52:25.766374", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "creation": "2018-10-15 15:52:25.766374", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "question", + "selected_option", + "quiz_result" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "question", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Question", - "length": 0, - "no_copy": 0, - "options": "Question", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 1, - "translatable": 0, - "unique": 0 - }, + "fieldname": "question", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Question", + "options": "Question", + "read_only": 1, + "reqd": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "selected_option", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Selected Option", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 1, - "translatable": 0, - "unique": 0 - }, + "fieldname": "selected_option", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Selected Option", + "read_only": 1, + "set_only_once": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "quiz_result", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Result", - "length": 0, - "no_copy": 0, - "options": "\nCorrect\nWrong", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 1, - "translatable": 0, - "unique": 0 + "fieldname": "quiz_result", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Result", + "options": "\nCorrect\nWrong", + "read_only": 1, + "reqd": 1, + "set_only_once": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2019-03-27 17:58:54.388848", - "modified_by": "Administrator", - "module": "Education", - "name": "Quiz Result", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "istable": 1, + "modified": "2019-06-03 12:52:32.267392", + "modified_by": "Administrator", + "module": "Education", + "name": "Quiz Result", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/education/utils.py b/erpnext/education/utils.py index a4b71e310e..53f02f5f2f 100644 --- a/erpnext/education/utils.py +++ b/erpnext/education/utils.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For lice from __future__ import unicode_literals, division import frappe @@ -173,7 +172,7 @@ def has_super_access(): """Check if user has a role that allows full access to LMS Returns: - bool: true if user has access to all lms content + bool: true if user has access to all lms content """ current_user = frappe.get_doc('User', frappe.session.user) roles = set([role.role for role in current_user.roles]) @@ -189,7 +188,6 @@ def add_activity(course, content_type, content): return frappe.throw("Student with email {0} does not exist".format(frappe.session.user), frappe.DoesNotExistError) course_enrollment = get_enrollment("course", course, student.name) - print(course_enrollment) if not course_enrollment: return None @@ -199,6 +197,56 @@ def add_activity(course, content_type, content): else: return enrollment.add_activity(content_type, content) +@frappe.whitelist() +def evaluate_quiz(quiz_response, quiz_name, course): + import json + + student = get_current_student() + + quiz_response = json.loads(quiz_response) + quiz = frappe.get_doc("Quiz", quiz_name) + result, score, status = quiz.evaluate(quiz_response, quiz_name) + + if has_super_access(): + return {'result': result, 'score': score, 'status': status} + + if student: + course_enrollment = get_enrollment("course", course, student.name) + if course_enrollment: + enrollment = frappe.get_doc('Course Enrollment', course_enrollment) + if quiz.allowed_attempt(enrollment, quiz_name): + enrollment.add_quiz_activity(quiz_name, quiz_response, result, score, status) + return {'result': result, 'score': score, 'status': status} + else: + return None + else: + frappe.throw("Something went wrong. Pleae contact the administrator.") + +@frappe.whitelist() +def get_quiz(quiz_name, course): + try: + quiz = frappe.get_doc("Quiz", quiz_name) + questions = quiz.get_questions() + except: + frappe.throw("Quiz {0} does not exist".format(quiz_name)) + return None + + questions = [{ + 'name': question.name, + 'question': question.question, + 'type': question.question_type, + 'options': [{'name': option.name, 'option': option.option} + for option in question.options], + } for question in questions] + + if has_super_access(): + return {'questions': questions, 'activity': None} + + student = get_current_student() + course_enrollment = get_enrollment("course", course, student.name) + status, score, result = check_quiz_completion(quiz, course_enrollment) + return {'questions': questions, 'activity': {'is_complete': status, 'score': score, 'result': result}} + def create_student_from_current_user(): user = frappe.get_doc("User", frappe.session.user) @@ -226,7 +274,7 @@ def check_content_completion(content_name, content_type, enrollment_name): def check_quiz_completion(quiz, enrollment_name): attempts = frappe.get_all("Quiz Activity", filters={'enrollment': enrollment_name, 'quiz': quiz.name}, fields=["name", "activity_date", "score", "status"]) - status = False if quiz.max_attempts == 0 else bool(len(attempts) == quiz.max_attempts) + status = False if quiz.max_attempts == 0 else bool(len(attempts) >= quiz.max_attempts) score = None result = None if attempts: diff --git a/erpnext/public/js/education/lms/quiz.js b/erpnext/public/js/education/lms/quiz.js new file mode 100644 index 0000000000..f6dc4d08d5 --- /dev/null +++ b/erpnext/public/js/education/lms/quiz.js @@ -0,0 +1,185 @@ +class Quiz { + constructor(wrapper, options) { + this.wrapper = wrapper; + Object.assign(this, options); + this.questions = [] + this.refresh(); + } + + refresh() { + this.get_quiz(); + } + + get_quiz() { + frappe.call('erpnext.education.utils.get_quiz', { + quiz_name: this.name, + course: this.course + }).then(res => { + this.make(res.message) + }); + } + + make(data) { + data.questions.forEach(question_data => { + let question_wrapper = document.createElement('div'); + let question = new Question({ + wrapper: question_wrapper, + ...question_data + }); + this.questions.push(question) + this.wrapper.appendChild(question_wrapper); + }) + if (data.activity.is_complete) { + this.disable() + let indicator = 'red' + let message = 'Your are not allowed to attempt the quiz again.' + if (data.activity.result == 'Pass') { + indicator = 'green' + message = 'You have already cleared the quiz.' + } + + this.set_quiz_footer(message, indicator, data.activity.score) + } + else { + this.make_actions(); + } + } + + make_actions() { + const button = document.createElement("button"); + button.classList.add("btn", "btn-primary", "mt-5", "mr-2"); + + button.id = 'submit-button'; + button.innerText = 'Submit'; + button.onclick = () => this.submit(); + this.submit_btn = button + this.wrapper.appendChild(button); + } + + submit() { + this.submit_btn.innerText = 'Evaluating..' + this.submit_btn.disabled = true + this.disable() + frappe.call('erpnext.education.utils.evaluate_quiz', { + quiz_name: this.name, + quiz_response: this.get_selected(), + course: this.course + }).then(res => { + this.submit_btn.remove() + if (!res.message) { + frappe.throw("Something went wrong while evaluating the quiz.") + } + + let indicator = 'red' + let message = 'Fail' + if (res.message.status == 'Pass') { + indicator = 'green' + message = 'Congratulations, you cleared the quiz.' + } + + this.set_quiz_footer(message, indicator, res.message.score) + }); + } + + set_quiz_footer(message, indicator, score) { + const div = document.createElement("div"); + div.classList.add("mt-5"); + div.innerHTML = `
+
+

${message}

+
Score: ${score}/100
+
+ +
` + + this.wrapper.appendChild(div) + } + + disable() { + this.questions.forEach(que => que.disable()) + } + + get_selected() { + let que = {} + this.questions.forEach(question => { + que[question.name] = question.get_selected() + }) + return que + } +} + +class Question { + constructor(opts) { + Object.assign(this, opts); + this.make(); + } + + make() { + this.make_question() + this.make_options() + } + + get_selected() { + let selected = this.options.filter(opt => opt.input.checked) + if (this.type == 'Single Correct Answer') { + if (selected[0]) return selected[0].name + } + if (this.type == 'Multiple Correct Answer') { + return selected.map(opt => opt.name) + } + return null + } + + disable() { + let selected = this.options.forEach(opt => opt.input.disabled = true) + } + + make_question() { + let question_wrapper = document.createElement('h5'); + question_wrapper.classList.add('mt-3'); + question_wrapper.innerText = this.question; + this.wrapper.appendChild(question_wrapper); + } + + make_options() { + let make_input = (name, value) => { + let input = document.createElement('input'); + input.id = name; + input.name = this.name; + input.value = value; + input.type = 'radio'; + if (this.type == 'Multiple Correct Answer') + input.type = 'checkbox'; + input.classList.add('form-check-input'); + return input; + } + + let make_label = function(name, value) { + let label = document.createElement('label'); + label.classList.add('form-check-label'); + label.htmlFor = name; + label.innerText = value; + return label + } + + let make_option = function (wrapper, option) { + let option_div = document.createElement('div') + option_div.classList.add('form-check', 'pb-1') + let input = make_input(option.name, option.option); + let label = make_label(option.name, option.option); + option_div.appendChild(input) + option_div.appendChild(label) + wrapper.appendChild(option_div) + return {input: input, ...option} + } + + let options_wrapper = document.createElement('div') + options_wrapper.classList.add('ml-2') + let option_list = [] + this.options.forEach(opt => option_list.push(make_option(options_wrapper, opt))) + this.options = option_list + this.wrapper.appendChild(options_wrapper) + } +} \ No newline at end of file diff --git a/erpnext/www/lms/content.html b/erpnext/www/lms/content.html index a02b2c7500..41f27f3bc1 100644 --- a/erpnext/www/lms/content.html +++ b/erpnext/www/lms/content.html @@ -36,7 +36,7 @@

{{ content.name }} ({{ position + 1 }}/{{length}})

-
+
-
+
{{ content.description }}
{% endmacro %} @@ -95,6 +95,18 @@
{% endmacro %} +{% macro quiz() %} +
+
+
+

{{ content.name }} ({{ position + 1 }}/{{length}})

+
+
+
+
+
+{% endmacro %} + {% block content %}
@@ -104,7 +116,7 @@ {% elif content_type=='Article'%} {{ article() }} {% elif content_type=='Quiz' %} -

Quiz: {{ content.name }}

+ {{ quiz() }} {% endif %}
@@ -113,20 +125,41 @@ {% block script %} {% if content_type=='Video' %} - + + {% elif content_type == 'Quiz' %} + {% endif %} {% endblock %} \ No newline at end of file diff --git a/erpnext/www/lms/content.py b/erpnext/www/lms/content.py index 51a8e32bad..f804cee3bd 100644 --- a/erpnext/www/lms/content.py +++ b/erpnext/www/lms/content.py @@ -27,7 +27,7 @@ def get_context(context): # Set context for content to be displayer - context.content = frappe.get_doc(content_type, content) + context.content = frappe.get_doc(content_type, content).as_dict() context.content_type = content_type context.program = program context.course = course