diff options
Diffstat (limited to 'addons')
25 files changed, 1077 insertions, 102 deletions
diff --git a/addons/hr_recruitment_survey/data/survey_demo.xml b/addons/hr_recruitment_survey/data/survey_demo.xml index 26ec8876a3e..08b93472297 100644 --- a/addons/hr_recruitment_survey/data/survey_demo.xml +++ b/addons/hr_recruitment_survey/data/survey_demo.xml @@ -2,7 +2,7 @@ <odoo><data noupdate="1"> <record id="survey_recruitment_form" model="survey.survey"> <field name="title">Recruitment Form</field> - <field name="stage_id" ref="survey.stage_permanent"/> + <field name="stage_id" ref="survey.stage_in_progress"/> <field name="access_mode">token</field> <field name="category">hr_recruitment</field> <field name="users_can_go_back" eval="True"/> diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index 246b1775397..3d8aeab4541 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -22,6 +22,8 @@ sent mails with personal token for the invitation of the survey. 'mail', 'web_tour'], 'data': [ + 'views/survey_report_templates.xml', + 'views/survey_reports.xml', 'data/mail_template_data.xml', 'data/survey_data.xml', 'data/ir_actions_data.xml', @@ -39,6 +41,7 @@ sent mails with personal token for the invitation of the survey. 'demo': [ 'data/survey_demo_user.xml', 'data/survey_demo_feedback.xml', + 'data/survey_demo_certification.xml', 'data/survey.user_input_line.csv' ], 'installable': True, diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 8111459f11d..35d505721de 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -9,7 +9,7 @@ from datetime import datetime from dateutil.relativedelta import relativedelta from math import ceil -from odoo import fields, http +from odoo import fields, http, _ from odoo.addons.base.models.ir_ui_view import keep_query from odoo.exceptions import UserError from odoo.http import request @@ -120,7 +120,7 @@ class Survey(http.Controller): answer_sudo = access_data['answer_sudo'] if error_key == 'survey_void' and access_data['can_answer']: - return request.render("survey.survey_void", {'survey': survey_sudo}) + return request.render("survey.survey_void", {'survey': survey_sudo, 'answer': answer_sudo}) elif error_key == 'survey_closed' and access_data['can_answer']: return request.render("survey.survey_expired", {'survey': survey_sudo}) elif error_key == 'survey_auth' and answer_sudo.token: @@ -136,7 +136,7 @@ class Survey(http.Controller): elif error_key == 'answer_deadline' and answer_sudo.token: return request.render("survey.survey_expired", {'survey': survey_sudo}) elif error_key == 'answer_done' and answer_sudo.token: - return request.render("survey.sfinished", {'survey': survey_sudo, 'token': answer_sudo.token, 'user_input': answer_sudo}) + return request.render("survey.sfinished", {'survey': survey_sudo, 'token': answer_sudo.token, 'answer': answer_sudo}) return werkzeug.utils.redirect("/") @@ -151,6 +151,25 @@ class Survey(http.Controller): return werkzeug.utils.redirect('/') return request.redirect('/survey/start/%s?%s' % (survey_sudo.access_token, keep_query('*', answer_token=answer_sudo.token))) + @http.route('/survey/retry/<string:survey_token>/<string:answer_token>', type='http', auth='public', website=True) + def survey_retry(self, survey_token, answer_token, **post): + """ This route is called whenever the user has attempts left and hits the 'Retry' button + after failing the survey.""" + access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) + if access_data['validity_code'] is not True and access_data['validity_code'] != 'answer_done': + return self._redirect_with_error(access_data, access_data['validity_code']) + + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + if not answer_sudo: + # attempts to 'retry' without having tried first + return werkzeug.utils.redirect("/") + + try: + retry_answer_sudo = survey_sudo._create_answer(user=request.env.user, partner=answer_sudo.partner_id, email=answer_sudo.email) + except: + return werkzeug.utils.redirect("/") + return request.redirect('/survey/start/%s?%s' % (survey_sudo.access_token, keep_query('*', answer_token=retry_answer_sudo.token))) + @http.route('/survey/start/<string:survey_token>', type='http', auth='public', website=True) def survey_start(self, survey_token, answer_token=None, email=False, **post): """ Start a survey by providing @@ -179,11 +198,34 @@ class Survey(http.Controller): # Select the right page if answer_sudo.state == 'new': # Intro page - data = {'survey': survey_sudo, 'page': None, 'answer': answer_sudo} + data = {'survey': survey_sudo, 'answer': answer_sudo, 'page': 0} return request.render('survey.survey_init', data) else: return request.redirect('/survey/fill/%s/%s' % (survey_sudo.access_token, answer_sudo.token)) + # Survey direct link to a specific page + @http.route('/survey/page/<string:survey_token>/<string:answer_token>/<int:page_id>', + type='http', auth='public', website=True) + def survey_change_page(self, survey_token, answer_token, page_id, **post): + """ Method called when the user switches from one page to another using the breadcrumbs links + in the survey layout. + TODO: Right now, the answers that are not submitted are LOST when changing from one page to another + using this method. + + The survey "submit" mechanism needs to be refactored entirely to make this more user-friendly.""" + # Controls if the survey can be displayed + access_data = self._get_access_data(survey_token, answer_token, ensure_token=False) + if access_data['validity_code'] is not True: + return self._redirect_with_error(access_data, access_data['validity_code']) + + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + + return request.render('survey.survey', { + 'survey': survey_sudo, + 'page': request.env['survey.question'].sudo().browse(page_id), + 'answer': answer_sudo + }) + @http.route('/survey/fill/<string:survey_token>/<string:answer_token>', type='http', auth='public', website=True) def survey_display_page(self, survey_token, answer_token, prev=None, **post): access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) @@ -192,27 +234,42 @@ class Survey(http.Controller): survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + if survey_sudo.is_time_limited and not answer_sudo.start_datetime: + # init start date when user starts filling in the survey + answer_sudo.write({ + 'start_datetime': fields.Datetime.now() + }) + # Select the right page if answer_sudo.state == 'new': # First page - page, page_nr, last = survey_sudo.next_page(answer_sudo, 0, go_back=False) - data = {'survey': survey_sudo, 'page': page, 'page_nr': page_nr, 'answer': answer_sudo} + page, last = survey_sudo.next_page(answer_sudo, 0, go_back=False) + data = { + 'survey': survey_sudo, + 'page': page, + 'answer': answer_sudo + } if last: data.update({'last': True}) return request.render('survey.survey', data) elif answer_sudo.state == 'done': # Display success message return request.render('survey.sfinished', {'survey': survey_sudo, - 'user_input': answer_sudo}) + 'answer': answer_sudo}) elif answer_sudo.state == 'skip': flag = (True if prev and prev == 'prev' else False) - page, page_nr, last = survey_sudo.next_page(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=flag) + page, last = survey_sudo.next_page(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=flag) #special case if you click "previous" from the last page, then leave the survey, then reopen it from the URL, avoid crash if not page: - page, page_nr, last = survey_sudo.next_page(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=True) + page, last = survey_sudo.next_page(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=True) - data = {'survey': survey_sudo, 'page': page, 'page_nr': page_nr, 'answer': answer_sudo} + data = { + 'survey': survey_sudo, + 'page': page, + 'answer': answer_sudo + } if last: data.update({'last': True}) + return request.render('survey.survey', data) else: return request.render("survey.403", {'survey': survey_sudo}) @@ -280,17 +337,25 @@ class Survey(http.Controller): ret = {} for answer in answer_sudo.user_input_line_ids: tmp_score = ret.get(answer.question_id.id, 0.0) - ret.update({answer.question_id.id: tmp_score + answer.quizz_mark}) + ret.update({answer.question_id.id: tmp_score + answer.answer_score}) return json.dumps(ret) @http.route('/survey/submit/<string:survey_token>/<string:answer_token>', type='http', methods=['POST'], auth='public', website=True) def survey_submit(self, survey_token, answer_token, **post): - """ TDE NOTE: original comment: # AJAX submission of a page -> AJAX / http ?? """ + """ Submit a page from the survey. + This will take into account the validation errors and store the answers to the questions. + If the time limit is reached, errors will be skipped, answers wil be ignored and + survey state will be forced to 'done' + + TDE NOTE: original comment: # AJAX submission of a page -> AJAX / http ?? """ access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: return {} survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + if not survey_sudo._has_attempts_left(answer_sudo.partner_id, answer_sudo.email): + # prevent cheating with users creating multiple 'user_input' before their last attempt + return {} page_id = int(post['page_id']) questions = request.env['survey.question'].sudo().search([ @@ -298,32 +363,41 @@ class Survey(http.Controller): ('page_id', '=', page_id) ]) - # Answer validation errors = {} - for question in questions: - answer_tag = "%s_%s_%s" % (survey_sudo.id, page_id, question.id) - errors.update(question.validate_question(post, answer_tag)) + # Answer validation + if not answer_sudo.is_time_limit_reached: + for question in questions: + answer_tag = "%s_%s_%s" % (survey_sudo.id, page_id, question.id) + errors.update(question.validate_question(post, answer_tag)) ret = {} if len(errors): # Return errors messages to webpage ret['errors'] = errors else: - for question in questions: - answer_tag = "%s_%s_%s" % (survey_sudo.id, page_id, question.id) - request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, post, answer_tag) - - go_back = post['button_submit'] == 'previous' - next_page, _, last = request.env['survey.survey'].next_page(answer_sudo, page_id, go_back=go_back) - vals = {'last_displayed_page_id': page_id} - if next_page is None and not go_back: - vals.update({'state': 'done'}) + if answer_sudo.is_time_limit_reached: + go_back = False + answer_sudo._send_certification() + vals = {'state': 'done'} else: - vals.update({'state': 'skip'}) + for question in questions: + answer_tag = "%s_%s_%s" % (survey_sudo.id, page_id, question.id) + request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, post, answer_tag) + + go_back = post['button_submit'] == 'previous' + next_page, last = request.env['survey.survey'].next_page(answer_sudo, page_id, go_back=go_back) + vals = {'last_displayed_page_id': page_id} + if next_page is None and not go_back: + answer_sudo._send_certification() + vals.update({'state': 'done'}) + else: + vals.update({'state': 'skip'}) + answer_sudo.write(vals) ret['redirect'] = '/survey/fill/%s/%s' % (survey_sudo.access_token, answer_token) if go_back: ret['redirect'] += '?prev=prev' + return json.dumps(ret) @http.route('/survey/print/<string:survey_token>', type='http', auth='public', website=True) @@ -342,7 +416,7 @@ class Survey(http.Controller): 'survey': survey_sudo, 'answer': answer_sudo, 'page_nr': 0, - }) + 'quizz_correction': survey_sudo.scoring_type != 'scoring_without_answers' and answer_sudo}) @http.route('/survey/results/<model("survey.survey"):survey>', type='http', auth='user', website=True) def survey_report(self, survey, answer_token=None, **post): @@ -424,6 +498,12 @@ class Survey(http.Controller): page_dict['question_ids'].append(question_dict) result['page_ids'].append(page_dict) + + if survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers']: + scoring_data = self._get_scoring_data(survey) + result['success_rate'] = scoring_data['success_rate'] + result['scoring_graph_data'] = json.dumps(scoring_data['graph_data']) + return result def _get_filter_data(self, post): @@ -463,3 +543,36 @@ class Survey(http.Controller): values.append({'text': data['rows'].get(row), 'count': data['result'].get((row, answer))}) result.append({'key': data['answers'].get(answer), 'values': values}) return json.dumps(result) + + def _get_scoring_data(self, survey): + """Performs a read_group to fetch the count of failed/passed tests in a single query.""" + + count_data = request.env['survey.user_input'].read_group( + [('survey_id', '=', survey.id), ('state', '=', 'done'), ('test_entry', '=', False)], + ['quizz_passed', 'id:count_distinct'], + ['quizz_passed'] + ) + + quizz_passed_count = 0 + quizz_failed_count = 0 + for count_data_item in count_data: + if count_data_item['quizz_passed']: + quizz_passed_count = count_data_item['quizz_passed_count'] + else: + quizz_failed_count = count_data_item['quizz_passed_count'] + + graph_data = [{ + 'text': _('Passed'), + 'count': quizz_passed_count, + 'color': '#2E7D32' + }, { + 'text': _('Missed'), + 'count': quizz_failed_count, + 'color': '#C62828' + }] + + total_quizz_passed = quizz_passed_count + quizz_failed_count + return { + 'success_rate': round((quizz_passed_count / total_quizz_passed) * 100, 1) if total_quizz_passed > 0 else 0, + 'graph_data': graph_data + } diff --git a/addons/survey/data/mail_template_data.xml b/addons/survey/data/mail_template_data.xml index ededcfb9bcc..de05a6a22d9 100644 --- a/addons/survey/data/mail_template_data.xml +++ b/addons/survey/data/mail_template_data.xml @@ -27,5 +27,43 @@ <field name="user_signature" eval="False"/> </record> + <!-- Certification Email template --> + <record id="mail_template_certification" model="mail.template"> + <field name="name">Certification: Send by email</field> + <field name="model_id" ref="survey.model_survey_user_input"/> + <field name="email_to">${(object.partner_id.email_formatted or object.email) |safe}</field> + <field name="partner_to">${object.partner_id.id}</field> + <field name="subject">Certification: ${object.survey_id.display_name}</field> + <field name="report_template" ref="certification_report"/> + <field name="report_name">Certification Document</field> + <field name="lang">${object.partner_id.lang}</field> + <field name="auto_delete" eval="True"/> + <field name="body_html" type="xml"> + <div style="background:#F0F0F0;color:#515166;padding:10px 0px;font-family:Arial,Helvetica,sans-serif;font-size:14px;"> + <table style="width:600px;margin:5px auto;"> + <tbody> + <tr><td> + <!-- We use the logo of the company that created the survey (to handle multi company cases) --> + <a href="/"><img src="/logo.png?company=${object.survey_id.create_uid.company_id.id}" style="vertical-align:baseline;max-width:100px;" /></a> + </td><td style="text-align:right;vertical-align:middle;"> + Certification: ${object.survey_id.display_name} + </td></tr> + </tbody> + </table> + <table style="width:600px;margin:0px auto;background:white;border:1px solid #e1e1e1;"> + <tbody> + <tr><td style="padding:15px 20px 10px 20px;"> + <p>Dear <span>${object.partner_id.name or 'participant'}</span></p> + <p> + Here is, in attachment, your certification document for + <strong>${object.survey_id.display_name}</strong> + </p> + <p>Congratulations for succeeding the test!</p> + </td></tr> + </tbody> + </table> + </div> + </field> + </record> </data> </odoo>
\ No newline at end of file diff --git a/addons/survey/data/survey_data.xml b/addons/survey/data/survey_data.xml index 87cefb946c9..a900d03ce8a 100644 --- a/addons/survey/data/survey_data.xml +++ b/addons/survey/data/survey_data.xml @@ -5,7 +5,7 @@ <record id="stage_draft" model="survey.stage"> <field name="name">Draft</field> <field name="sequence" eval="1" /> - <field name="closed" eval="False" /> + <field name="closed" eval="True" /> <field name="fold" eval="False" /> </record> <record id="stage_in_progress" model="survey.stage"> @@ -20,11 +20,5 @@ <field name="closed" eval="True" /> <field name="fold" eval="True" /> </record> - <record id="stage_permanent" model="survey.stage"> - <field name="name">Permanent</field> - <field name="sequence" eval="4" /> - <field name="closed" eval="False" /> - <field name="fold" eval="True" /> - </record> </data> </odoo> diff --git a/addons/survey/data/survey_demo_certification.xml b/addons/survey/data/survey_demo_certification.xml new file mode 100644 index 00000000000..edbb8c561c5 --- /dev/null +++ b/addons/survey/data/survey_demo_certification.xml @@ -0,0 +1,302 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data noupdate="1"> + <!-- Odoo Vendor Certification --> + <record model="survey.survey" id="vendor_certification"> + <field name="title">MyCompany Vendor Certification</field> + <field name="access_token">4ead4bc8-b8f2-4760-a682-1fde8ddb95ac</field> + <field name="stage_id" ref="survey.stage_in_progress" /> + <field name="access_mode">public</field> + <field name="users_can_go_back" eval="True" /> + <field name="users_login_required" eval="True" /> + <field name="scoring_type" >scoring_with_answers</field> + <field name="certificate" eval="True"></field> + <field name="certification_mail_template_id" ref="mail_template_certification"></field> + <field name="is_time_limited" >limited</field> + <field name="time_limit" >10.0</field> + <field name="is_attempts_limited" eval="True" /> + <field name="attempts_limit">2</field> + <field name="description"><p>Test your vendor skills!.</p></field> + <field name="thank_you_message"><p></p></field> + </record> + <!-- Page 1 --> + <record model="survey.question" id="vendor_certification_page_1"> + <field name="title">Products</field> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">1</field> + <field name="is_page" eval="True"/> + <field name="description"><p>Test your knowledge of your products!</p></field> + </record> + <!-- Question and predefined answer 1 --> + <record model="survey.question" id="vendor_certification_page_1_question_1"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">2</field> + <field name="title">Do we sell Acoustic Bloc Screens?</field> + <field name="question_type">simple_choice</field> + <field name="display_mode">dropdown</field> + <field name="constr_mandatory" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_1_choice_1"> + <field name="question_id" ref="vendor_certification_page_1_question_1"/> + <field name="sequence">1</field> + <field name="value">No</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_1_choice_2"> + <field name="question_id" ref="vendor_certification_page_1_question_1"/> + <field name="sequence">2</field> + <field name="value">Yes</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">2.0</field> + </record> + <!-- Question and predefined answer 2 --> + <record model="survey.question" id="vendor_certification_page_1_question_2"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">3</field> + <field name="title">Select all the existing products</field> + <field name="question_type">multiple_choice</field> + <field name="column_nb">4</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_2_choice_1"> + <field name="question_id" ref="vendor_certification_page_1_question_2"/> + <field name="sequence">1</field> + <field name="value">Chair floor protection</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_2_choice_2"> + <field name="question_id" ref="vendor_certification_page_1_question_2"/> + <field name="sequence">2</field> + <field name="value">Fanta</field> + <field name="answer_score">-1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_2_choice_3"> + <field name="question_id" ref="vendor_certification_page_1_question_2"/> + <field name="sequence">3</field> + <field name="value">Conference chair</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_2_choice_4"> + <field name="question_id" ref="vendor_certification_page_1_question_2"/> + <field name="sequence">4</field> + <field name="value">Drawer</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_2_choice_5"> + <field name="question_id" ref="vendor_certification_page_1_question_2"/> + <field name="sequence">5</field> + <field name="value">Customizable Lamp</field> + <field name="answer_score">-1.0</field> + </record> + <!-- Question and predefined answer 3 --> + <record model="survey.question" id="vendor_certification_page_1_question_3"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">4</field> + <field name="title">Select all the available customizations for our Customizable Desk</field> + <field name="question_type">multiple_choice</field> + <field name="column_nb">4</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_3_choice_1"> + <field name="question_id" ref="vendor_certification_page_1_question_3"/> + <field name="sequence">1</field> + <field name="value">Color</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_3_choice_2"> + <field name="question_id" ref="vendor_certification_page_1_question_3"/> + <field name="sequence">2</field> + <field name="value">Height</field> + <field name="answer_score">-1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_3_choice_3"> + <field name="question_id" ref="vendor_certification_page_1_question_3"/> + <field name="sequence">3</field> + <field name="value">Width</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">-1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_3_choice_4"> + <field name="question_id" ref="vendor_certification_page_1_question_3"/> + <field name="sequence">4</field> + <field name="value">Legs</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_3_choice_5"> + <field name="question_id" ref="vendor_certification_page_1_question_3"/> + <field name="sequence">5</field> + <field name="value">Number of drawers</field> + <field name="answer_score">-1.0</field> + </record> + <!-- Question and predefined answer 4 --> + <record model="survey.question" id="vendor_certification_page_1_question_4"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">5</field> + <field name="title">How many versions of the Corner Desk do we have?</field> + <field name="question_type">simple_choice</field> + <field name="display_mode">dropdown</field> + <field name="constr_mandatory" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_4_choice_1"> + <field name="question_id" ref="vendor_certification_page_1_question_4"/> + <field name="sequence">1</field> + <field name="value">1</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_4_choice_2"> + <field name="question_id" ref="vendor_certification_page_1_question_4"/> + <field name="sequence">2</field> + <field name="value">2</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">2.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_4_choice_3"> + <field name="question_id" ref="vendor_certification_page_1_question_4"/> + <field name="sequence">3</field> + <field name="value">3</field> + </record> + <record model="survey.label" id="vendor_certification_page_1_question_4_choice_4"> + <field name="question_id" ref="vendor_certification_page_1_question_4"/> + <field name="sequence">4</field> + <field name="value">4</field> + </record> + <!-- Question and predefined answer 5 --> + <record model="survey.question" id="vendor_certification_page_1_question_5"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">6</field> + <field name="title">Do you think we have missing products in our catalog? (not rated)</field> + <field name="question_type">free_text</field> + </record> + <!-- Page 2 --> + <record model="survey.question" id="vendor_certification_page_2"> + <field name="title">Prices</field> + <field name="survey_id" ref="vendor_certification" /> + <field name="is_page" eval="True"/> + <field name="sequence">7</field> + <field name="description"><p>Test your knowledge of our prices.</p></field> + </record> + <!-- Question and predefined answer 6 --> + <record model="survey.question" id="vendor_certification_page_2_question_1"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">8</field> + <field name="title">How much do we sell our Cable Management Box?</field> + <field name="question_type">simple_choice</field> + <field name="display_mode">dropdown</field> + <field name="constr_mandatory" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_1_choice_1"> + <field name="question_id" ref="vendor_certification_page_2_question_1"/> + <field name="sequence">1</field> + <field name="value">20$</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_1_choice_2"> + <field name="question_id" ref="vendor_certification_page_2_question_1"/> + <field name="sequence">2</field> + <field name="value">50$</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_1_choice_3"> + <field name="question_id" ref="vendor_certification_page_2_question_1"/> + <field name="sequence">3</field> + <field name="value">80$</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_1_choice_4"> + <field name="question_id" ref="vendor_certification_page_2_question_1"/> + <field name="sequence">4</field> + <field name="value">100$</field> + <field name="is_correct" eval="True" /> + <field name="answer_score">2.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_1_choice_5"> + <field name="question_id" ref="vendor_certification_page_2_question_1"/> + <field name="sequence">5</field> + <field name="value">200$</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_1_choice_6"> + <field name="question_id" ref="vendor_certification_page_2_question_1"/> + <field name="sequence">6</field> + <field name="value">300$</field> + </record> + <!-- Question and predefined answer 7 --> + <record model="survey.question" id="vendor_certification_page_2_question_2"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">9</field> + <field name="title">Select all the the products that sell for 100$ or more</field> + <field name="question_type">multiple_choice</field> + <field name="column_nb">2</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_2_choice_1"> + <field name="question_id" ref="vendor_certification_page_2_question_2"/> + <field name="sequence">1</field> + <field name="value">Corner Desk Right Sit</field> + <field name="answer_score">1.0</field> + <field name="is_correct" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_2_choice_2"> + <field name="question_id" ref="vendor_certification_page_2_question_2"/> + <field name="sequence">2</field> + <field name="value">Desk Combination</field> + <field name="answer_score">1.0</field> + <field name="is_correct" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_2_choice_3"> + <field name="question_id" ref="vendor_certification_page_2_question_2"/> + <field name="sequence">3</field> + <field name="value">Cabinet with Doors</field> + <field name="answer_score">-1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_2_choice_4"> + <field name="question_id" ref="vendor_certification_page_2_question_2"/> + <field name="sequence">4</field> + <field name="value">Large Desk</field> + <field name="answer_score">1.0</field> + <field name="is_correct" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_2_choice_5"> + <field name="question_id" ref="vendor_certification_page_2_question_2"/> + <field name="sequence">5</field> + <field name="value">Letter Tray</field> + <field name="answer_score">-1.0</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_2_choice_5"> + <field name="question_id" ref="vendor_certification_page_2_question_2"/> + <field name="sequence">6</field> + <field name="value">Office Chair Black</field> + <field name="answer_score">-1.0</field> + </record> + <!-- Question and predefined answer 8 --> + <record model="survey.question" id="vendor_certification_page_2_question_3"> + <field name="survey_id" ref="vendor_certification" /> + <field name="sequence">10</field> + <field name="title">What do you think about our prices (not rated)?</field> + <field name="question_type">simple_choice</field> + <field name="display_mode">dropdown</field> + <field name="constr_mandatory" eval="True" /> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_3_choice_1"> + <field name="question_id" ref="vendor_certification_page_2_question_3"/> + <field name="sequence">1</field> + <field name="value">Very underpriced</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_3_choice_2"> + <field name="question_id" ref="vendor_certification_page_2_question_3"/> + <field name="sequence">2</field> + <field name="value">Underpriced</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_3_choice_3"> + <field name="question_id" ref="vendor_certification_page_2_question_3"/> + <field name="sequence">3</field> + <field name="value">Correctly priced</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_3_choice_4"> + <field name="question_id" ref="vendor_certification_page_2_question_3"/> + <field name="sequence">4</field> + <field name="value">A little bit overpriced</field> + </record> + <record model="survey.label" id="vendor_certification_page_2_question_3_choice_5"> + <field name="question_id" ref="vendor_certification_page_2_question_3"/> + <field name="sequence">5</field> + <field name="value">A lot overpriced</field> + </record> + </data> +</odoo>
\ No newline at end of file diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index 21efbc2a8e6..47ff57329f3 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -54,6 +54,7 @@ class SurveyQuestion(models.Model): survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade') page_id = fields.Many2one('survey.question', string='Page', compute="_compute_page_id", store=True) question_ids = fields.One2many('survey.question', string='Questions', compute="_compute_question_ids") + scoring_type = fields.Selection(related='survey_id.scoring_type', string='Scoring Type', readonly=True) sequence = fields.Integer('Sequence', default=10) # Question is_page = fields.Boolean('Is a page?') @@ -350,6 +351,11 @@ class SurveyQuestion(models.Model): self.ensure_one() return list(self.survey_id.question_and_page_ids).index(self) + def get_correct_answer_ids(self): + self.ensure_one() + + return self.labels_ids.filtered(lambda label: label.is_correct) + class SurveyLabel(models.Model): """ A suggested answer for a question """ _name = 'survey.label' @@ -361,7 +367,9 @@ class SurveyLabel(models.Model): question_id_2 = fields.Many2one('survey.question', string='Question 2', ondelete='cascade') sequence = fields.Integer('Label Sequence order', default=10) value = fields.Char('Suggested value', translate=True, required=True) - quizz_mark = fields.Float('Score for this choice', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer") + is_correct = fields.Boolean('Is a correct answer') + answer_score = fields.Float('Score for this choice', + help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer") @api.one @api.constrains('question_id', 'question_id_2') diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index ac194d8f452..3e01d287a8b 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -31,7 +31,6 @@ class Survey(models.Model): description = fields.Html("Description", translate=True, help="A long description of the purpose of the survey") color = fields.Integer('Color Index', default=0) thank_you_message = fields.Html("Thanks Message", translate=True, help="This message will be displayed when survey is completed") - quizz_mode = fields.Boolean("Quizz Mode") active = fields.Boolean("Active", default=True) question_and_page_ids = fields.One2many('survey.question', 'survey_id', string='Pages and Questions', copy=True) page_ids = fields.One2many('survey.question', string='Pages', compute="_compute_page_and_question_ids") @@ -60,8 +59,29 @@ class Survey(models.Model): answer_count = fields.Integer("Started", compute="_compute_survey_statistic") answer_done_count = fields.Integer("Completed", compute="_compute_survey_statistic") + # scoring and certification fields + scoring_type = fields.Selection([ + ('no_scoring', 'No scoring'), + ('scoring_with_answers', 'Scoring with answers at the end'), + ('scoring_without_answers', 'Scoring without answers at the end')], + string="Scoring", required=True, default='no_scoring') + passing_score = fields.Float('Passing score (%)', required=True, default=80.0) + is_attempts_limited = fields.Boolean('Limited number of attempts', + help="Check this option if you want to limit the number of attempts per user") + attempts_limit = fields.Integer('Number of attempts', default=1) + is_time_limited = fields.Boolean('The survey is limited in time') + time_limit = fields.Float("Time limit (minutes)") + certificate = fields.Boolean('Certificate') + certification_mail_template_id = fields.Many2one( + 'mail.template', 'Certification Email Template', + domain="[('model', '=', 'survey.user_input')]", + help="Automated email sent to the user when he succeeds the certification, containing his certification document.") + _sql_constraints = [ - ('access_token_unique', 'unique(access_token)', 'Access token should be unique') + ('access_token_unique', 'unique(access_token)', 'Access token should be unique'), + ('certificate_check', "CHECK( scoring_type!='no_scoring' OR certificate=False )", 'You can only create certifications for surveys that have a scoring mechanism.'), + ('time_limit_check', "CHECK( (is_time_limited=False) OR (time_limit is not null AND time_limit > 0) )", 'The time limit needs to be a positive number if the survey is time limited.'), + ('attempts_limit_check', "CHECK( (is_attempts_limited=False) OR (attempts_limit is not null AND attempts_limit > 0) )", 'The attempts limit needs to be a positive number if the survey has a limited number of attempts.') ] @api.multi @@ -93,12 +113,45 @@ class Survey(models.Model): for survey in self: survey.public_url = urls.url_join(base_url, "survey/start/%s" % (survey.access_token)) + @api.multi + @api.depends('question_and_page_ids.labels_ids.answer_score') + def _compute_total_possible_score(self): + for survey in self: + survey.total_possible_score = sum([ + answer_score if answer_score > 0 else 0 + for answer_score in survey.question_and_page_ids.mapped('labels_ids.answer_score')]) + @api.depends('question_and_page_ids') def _compute_page_and_question_ids(self): for survey in self: survey.page_ids = survey.question_and_page_ids.filtered(lambda question: question.is_page) survey.question_ids = survey.question_and_page_ids - survey.page_ids + @api.onchange('passing_score') + def _onchange_passing_score(self): + if self.passing_score < 0 or self.passing_score > 100: + self.passing_score = 80.0 + + @api.onchange('scoring_type') + def _onchange_scoring_type(self): + if self.scoring_type != 'no_scoring': + self.certificate = False + + @api.onchange('users_login_required', 'access_mode') + def _onchange_access_mode(self): + if self.access_mode == 'public' and not self.users_login_required: + self.is_attempts_limited = False + + @api.onchange('attempts_limit') + def _onchange_attempts_limit(self): + if self.attempts_limit <= 0: + self.attempts_limit = 1 + + @api.onchange('is_time_limited', 'time_limit') + def _onchange_time_limit(self): + if self.is_time_limited and (not self.time_limit or self.time_limit <= 0): + self.time_limit = 10 + @api.model def _read_group_stage_ids(self, stages, domain, order): """ Read group customization in order to display all the stages in the @@ -126,7 +179,7 @@ class Survey(models.Model): self.check_access_rights('read') self.check_access_rule('read') - tokens = self.env['survey.user_input'] + answers = self.env['survey.user_input'] for survey in self: if partner and not user and partner.user_ids: user = partner.user_ids[0] @@ -146,9 +199,9 @@ class Survey(models.Model): answer_vals['email'] = email answer_vals.update(additional_vals) - tokens += tokens.create(answer_vals) + answers += answers.create(answer_vals) - return tokens + return answers @api.multi def _check_answer_creation(self, user, partner, email, test_entry=False): @@ -171,6 +224,8 @@ class Survey(models.Model): raise UserError(_('Creating token for external people is not allowed for surveys requesting authentication.')) if self.access_mode == 'internal' and (not user or not user.has_group('base.group_user')): raise UserError(_('Creating token for anybody else than employees is not allowed for internal surveys.')) + if not self._has_attempts_left(partner or (user and user.partner_id), email): + raise UserError(_('No attempts left.')) @api.model def next_page(self, user_input, page_id, go_back=False): @@ -195,23 +250,23 @@ class Survey(models.Model): # First page if page_id == 0: - return (pages[0][1], 0, len(pages) == 1) + return (pages[0][1], len(pages) == 1) current_page_index = pages.index(next(p for p in pages if p[1].id == page_id)) # All the pages have been displayed if current_page_index == len(pages) - 1 and not go_back: - return (None, -1, False) + return (None, False) # Let's get back, baby! elif go_back and survey.users_can_go_back: - return (pages[current_page_index - 1][1], current_page_index - 1, False) + return (pages[current_page_index - 1][1], False) else: # This will show the last page if current_page_index == len(pages) - 2: - return (pages[current_page_index + 1][1], current_page_index + 1, True) + return (pages[current_page_index + 1][1], True) # This will show a regular page else: - return (pages[current_page_index + 1][1], current_page_index + 1, False) + return (pages[current_page_index + 1][1], False) @api.multi def filter_input_ids(self, filters, finished=False): @@ -278,7 +333,7 @@ class Survey(models.Model): # Calculate and return statistics for choice if question.question_type in ['simple_choice', 'multiple_choice']: comments = [] - answers = OrderedDict((label.id, {'text': label.value, 'count': 0, 'answer_id': label.id}) for label in question.labels_ids) + answers = OrderedDict((label.id, {'text': label.value, 'count': 0, 'answer_id': label.id, 'answer_score': label.answer_score}) for label in question.labels_ids) for input_line in input_lines: if input_line.answer_type == 'suggestion' and answers.get(input_line.value_suggested.id) and (not(current_filters) or input_line.user_input_id.id in current_filters): answers[input_line.value_suggested.id]['count'] += 1 @@ -435,3 +490,30 @@ class Survey(models.Model): 'search_default_invite': 1}) action['context'] = ctx return action + + @api.multi + def _has_attempts_left(self, partner, email): + self.ensure_one() + + if (self.access_mode != 'public' or self.users_login_required) and self.is_attempts_limited: + return self._get_number_of_attempts_lefts(partner, email) > 0 + + return True + + @api.multi + def _get_number_of_attempts_lefts(self, partner, email): + """ Returns the number of attempts left. """ + self.ensure_one() + + domain = [ + ('survey_id', '=', self.id), + ('test_entry', '=', False), + ('state', '=', 'done') + ] + + if partner: + domain = expression.AND([domain, [('partner_id', '=', partner.id)]]) + else: + domain = expression.AND([domain, [('email', '=', email)]]) + + return self.attempts_limit - self.env['survey.user_input'].search_count(domain) diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index 90ed34e2497..d7300eecb06 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -9,6 +9,8 @@ import uuid from odoo import api, fields, models, _ from odoo.exceptions import ValidationError +from dateutil.relativedelta import relativedelta + email_validator = re.compile(r"[^@]+@[^@]+\.[^@]+") _logger = logging.getLogger(__name__) @@ -30,6 +32,8 @@ class SurveyUserInput(models.Model): # description survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='cascade') + start_datetime = fields.Datetime('Start date and time', readonly=True) + is_time_limit_reached = fields.Boolean("Is time limit reached?", compute='_compute_is_time_limit_reached') input_type = fields.Selection([ ('manually', 'Manual'), ('link', 'Invitation')], string='Answer Type', default='manually', required=True, readonly=True, @@ -49,12 +53,27 @@ class SurveyUserInput(models.Model): # answers user_input_line_ids = fields.One2many('survey.user_input_line', 'user_input_id', string='Answers', copy=True) deadline = fields.Datetime('Deadline', help="Datetime until customer can open the survey and submit answers") - quizz_score = fields.Float("Score for the quiz", compute="_compute_quizz_score", default=0.0) - @api.depends('user_input_line_ids.quizz_mark') + quizz_score = fields.Float("Score for the quiz (%)", compute="_compute_quizz_score", default=0.0) + # Stored for performance reasons while displaying results page + quizz_passed = fields.Boolean('Quizz Passed', compute='_compute_quizz_passed', store=True) + + @api.multi + @api.depends('user_input_line_ids.answer_score', 'survey_id.total_possible_score') def _compute_quizz_score(self): for user_input in self: - user_input.quizz_score = sum(user_input.user_input_line_ids.mapped('quizz_mark')) + total_possible_score = user_input.survey_id.total_possible_score + if total_possible_score == 0: + user_input.quizz_score = 0 + else: + score = (sum(user_input.user_input_line_ids.mapped('answer_score')) / total_possible_score) * 100 + user_input.quizz_score = round(score, 2) if score > 0 else 0 + + @api.multi + @api.depends('quizz_score', 'survey_id.passing_score') + def _compute_quizz_passed(self): + for user_input in self: + user_input.quizz_passed = user_input.quizz_score >= user_input.survey_id.passing_score _sql_constraints = [ ('unique_token', 'UNIQUE (token)', 'A token must be unique!'), @@ -97,6 +116,23 @@ class SurveyUserInput(models.Model): 'url': '/survey/print/%s?answer_token=%s' % (self.survey_id.access_token, self.token) } + @api.depends('start_datetime', 'survey_id.is_time_limited', 'survey_id.time_limit') + def _compute_is_time_limit_reached(self): + """ Checks that the user_input is not exceeding the survey's time limit. """ + for user_input in self: + user_input.is_time_limit_reached = user_input.survey_id.is_time_limited and fields.Datetime.now() \ + > user_input.start_datetime + relativedelta(minutes=user_input.survey_id.time_limit) + + @api.multi + def _send_certification(self): + """ Will send the certification email with attached document if + - The survey is a certification + - It has a certification_mail_template_id set + - The user succeeded the test """ + for user_input in self: + if user_input.survey_id.certificate and user_input.quizz_passed and user_input.survey_id.certification_mail_template_id: + user_input.survey_id.certification_mail_template_id.send_mail(user_input.id, notif_layout="mail.mail_notification_light") + class SurveyUserInputLine(models.Model): _name = 'survey.user_input_line' @@ -122,7 +158,7 @@ class SurveyUserInputLine(models.Model): value_free_text = fields.Text('Free Text answer') value_suggested = fields.Many2one('survey.label', string="Suggested answer") value_suggested_row = fields.Many2one('survey.label', string="Row answer") - quizz_mark = fields.Float('Score given for this choice') + answer_score = fields.Float('Score given for this choice') @api.constrains('skipped', 'answer_type') def _answered_or_skipped(self): @@ -145,7 +181,7 @@ class SurveyUserInputLine(models.Model): def _get_mark(self, value_suggested): label = self.env['survey.label'].browse(int(value_suggested)) - mark = label.quizz_mark if label.exists() else 0.0 + mark = label.answer_score if label.exists() else 0.0 return mark @api.model_create_multi @@ -153,14 +189,14 @@ class SurveyUserInputLine(models.Model): for vals in vals_list: value_suggested = vals.get('value_suggested') if value_suggested: - vals.update({'quizz_mark': self._get_mark(value_suggested)}) + vals.update({'answer_score': self._get_mark(value_suggested)}) return super(SurveyUserInputLine, self).create(vals_list) @api.multi def write(self, vals): value_suggested = vals.get('value_suggested') if value_suggested: - vals.update({'quizz_mark': self._get_mark(value_suggested)}) + vals.update({'answer_score': self._get_mark(value_suggested)}) return super(SurveyUserInputLine, self).write(vals) @api.model diff --git a/addons/survey/static/src/img/certification_background.png b/addons/survey/static/src/img/certification_background.png Binary files differnew file mode 100644 index 00000000000..966d05cf903 --- /dev/null +++ b/addons/survey/static/src/img/certification_background.png diff --git a/addons/survey/static/src/js/survey_result.js b/addons/survey/static/src/js/survey_result.js index 1cc571ed78c..f70aefe9a1f 100644 --- a/addons/survey/static/src/js/survey_result.js +++ b/addons/survey/static/src/js/survey_result.js @@ -67,7 +67,7 @@ if(!$('.js_surveyresult').length) { } //load chart to svg element chart:initialized chart, response:AJAX response, quistion_id:if of survey question, tick_limit:text length limit - function load_chart(chart, response, question_id, tick_limit, graph_type){ + function load_chart(chart, response, svgSelector, tick_limit, graph_type){ // Custom Tick fuction for replacing long text with '...' var customtick_function = function(d){ if(! this || d.length <= tick_limit){ @@ -83,7 +83,7 @@ if(!$('.js_surveyresult').length) { chart.yAxis .tickFormat(d3.format('d')); } - d3.select('#graph_question_' + question_id + ' svg') + d3.select(svgSelector) .datum(response) .transition().duration(500).call(chart); nv.utils.windowResize(chart.update); @@ -95,26 +95,35 @@ if(!$('.js_surveyresult').length) { var question_id = $(graph).attr("data-question_id"); var graph_type = $(graph).attr("data-graph_type"); var graph_data = JSON.parse($(graph).attr("graph-data")); + var svgSelector = '#graph_question_' + question_id + ' svg'; if(graph_type == 'multi_bar'){ nv.addGraph(function(){ var chart = init_multibar_chart(); - return load_chart(chart, graph_data, question_id, 25); + return load_chart(chart, graph_data, svgSelector, 25); }); } else if(graph_type == 'bar'){ nv.addGraph(function() { var chart = init_bar_chart(); - return load_chart(chart, graph_data, question_id, 35); + return load_chart(chart, graph_data, svgSelector, 35); }); } else if(graph_type == 'pie'){ nv.addGraph(function() { var chart = init_pie_chart(); - return load_chart(chart, graph_data, question_id, 25, 'pie'); + return load_chart(chart, graph_data, svgSelector, 25, 'pie'); }); } }); + var $scoringResultsChart = $('#scoring_results_chart'); + if ($scoringResultsChart.length > 0) { + nv.addGraph(function () { + var chart = init_pie_chart(); + return load_chart(chart, $scoringResultsChart.data('graph_data'), '#scoring_results_chart svg', 25, 'pie'); + }); + } + // Script for filter $('td.survey_answer').hover(function(){ $(this).find('i.fa-filter').removeClass('invisible'); diff --git a/addons/survey/static/src/js/survey_timer.js b/addons/survey/static/src/js/survey_timer.js new file mode 100644 index 00000000000..bb7af08104f --- /dev/null +++ b/addons/survey/static/src/js/survey_timer.js @@ -0,0 +1,45 @@ +odoo.define('survey.timer', function (require) { +'use strict'; + +require('web.dom_ready'); + +if (!$('.js_survey_timer').length) { + return $.Deferred().reject("DOM doesn't contain '.js_survey_timer'"); +} + +var $parent = $('.js_survey_timer'); +var timeLimitMinutes = parseInt($parent.data('time_limit_minutes')); + +if (timeLimitMinutes <= 0) { + return $.Deferred().reject("Timer is not positive"); +} + +var countDownDate = moment.utc($parent.data('timer')).add(timeLimitMinutes, 'minutes'); + +if (countDownDate.diff(moment.utc(), 'seconds') < 0) { + return $.Deferred().reject("Timer is already finished"); +} + +var formatTime = function (time) { + return time > 9 ? time : '0' + time; +}; + +var $timer = $parent.find('.timer'); +var interval = null; +var updateTimer = function () { + var timeLeft = countDownDate.diff(moment.utc(), 'seconds'); + + if (timeLeft >= 0) { + var timeLeftMinutes = parseInt(timeLeft / 60); + var timeLeftSeconds = timeLeft - (timeLeftMinutes * 60); + $timer.html(formatTime(timeLeftMinutes) + ':' + formatTime(timeLeftSeconds)); + } else { + clearInterval(interval); + $('.js_surveyform').submit(); + } +}; + +updateTimer(); +interval = setInterval(updateTimer, 1000); + +}); diff --git a/addons/survey/static/src/scss/survey_reports.scss b/addons/survey/static/src/scss/survey_reports.scss new file mode 100644 index 00000000000..ac31d56abed --- /dev/null +++ b/addons/survey/static/src/scss/survey_reports.scss @@ -0,0 +1,17 @@ +.certification-wrapper { + background: url(/survey/static/src/img/certification_background.png) no-repeat; + background-size: 100% 100%; + height: 610px; + width: 860px; + + .certification { + margin-left: 350px; + padding-top: 140px; + width: 510px; + text-align: center; + + h2 { + color: #876B81; + } + } +} diff --git a/addons/survey/static/src/scss/survey_templates.scss b/addons/survey/static/src/scss/survey_templates.scss new file mode 100644 index 00000000000..d54c3853625 --- /dev/null +++ b/addons/survey/static/src/scss/survey_templates.scss @@ -0,0 +1,8 @@ +.o_survey_header { + padding-top: 15px; + padding-bottom: 25px; +} + +.js_question-wrapper { + padding-bottom: 25px; +} diff --git a/addons/survey/static/src/scss/survey_views.scss b/addons/survey/static/src/scss/survey_views.scss new file mode 100644 index 00000000000..92f4dd73a31 --- /dev/null +++ b/addons/survey/static/src/scss/survey_views.scss @@ -0,0 +1,5 @@ +.o_kanban_card_survey { + .certification_icon { + font-size: 20px; + } +} diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index a37b7cd6b91..13b988f9ec0 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import re + from collections import Counter from contextlib import contextmanager from functools import partial @@ -156,17 +158,17 @@ class SurveyCase(common.SavepointCase): } if qtype in ('simple_choice', 'multiple_choice'): base_qvalues['labels_ids'] = [ - (0, 0, {'value': label['value'], 'quizz_mark': label.get('quizz_mark', 0)}) + (0, 0, {'value': label['value'], 'answer_score': label.get('answer_score', 0)}) for label in kwargs.pop('labels') ] elif qtype == 'matrix': base_qvalues['matrix_subtype'] = kwargs.pop('matrix_subtype', 'simple') base_qvalues['labels_ids'] = [ - (0, 0, {'value': label['value'], 'quizz_mark': label.get('quizz_mark', 0)}) + (0, 0, {'value': label['value'], 'answer_score': label.get('answer_score', 0)}) for label in kwargs.pop('labels') ] base_qvalues['labels_ids_2'] = [ - (0, 0, {'value': label['value'], 'quizz_mark': label.get('quizz_mark', 0)}) + (0, 0, {'value': label['value'], 'answer_score': label.get('answer_score', 0)}) for label in kwargs.pop('labels_2') ] else: @@ -199,3 +201,7 @@ class SurveyCase(common.SavepointCase): base_alvals[answer_fname] = answer_value base_alvals.update(kwargs) return self.env['survey.user_input_line'].create(base_alvals) + + def _find_csrf_token(self, text): + csrf_token_re = re.compile("(input.+csrf_token.+value=\")([_a-zA-Z0-9]{51})", re.MULTILINE) + return csrf_token_re.search(text).groups()[1] diff --git a/addons/survey/tests/test_certification_flow.py b/addons/survey/tests/test_certification_flow.py new file mode 100644 index 00000000000..e596e34fa7d --- /dev/null +++ b/addons/survey/tests/test_certification_flow.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from odoo.addons.base.models.ir_mail_server import IrMailServer +from odoo.addons.survey.tests import common +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged('functional') +class TestCertificationFlow(common.SurveyCase, HttpCase): + def _answer_question(self, question, answer, answer_token, csrf_token, button_submit='next'): + # Employee submits the question answer + post_data = self._format_submission_data(question, answer, {'csrf_token': csrf_token, 'token': answer_token, 'button_submit': button_submit}) + response = self._access_submit(question.survey_id, answer_token, post_data) + self.assertResponse(response, 200) + + # Employee is redirected on next question + response = self._access_page(question.survey_id, answer_token) + self.assertResponse(response, 200) + + def _format_submission_data(self, question, answer, additional_post_data): + post_data = {} + post_data['question_id'] = question.id + if question.question_type == 'multiple_choice': + values = answer + for value in values: + key = "%s_%s_%s" % (question.survey_id.id, question.id, value) + post_data[key] = value + else: + key = "%s_%s" % (question.survey_id.id, question.id) + post_data[key] = answer + post_data.update(**additional_post_data) + return post_data + + def test_flow_certificate(self): + # Step: survey user creates the certification + # -------------------------------------------------- + with self.sudo(self.survey_user): + certification = self.env['survey.survey'].create({ + 'title': 'User Certification for SO lines', + 'access_mode': 'public', + 'users_login_required': True, + 'questions_layout': 'page_per_question', + 'users_can_go_back': True, + 'scoring_type': 'scoring_with_answers', + 'passing_score': 85.0, + 'certificate': True, + 'certification_mail_template_id': self.env.ref('survey.mail_template_certification').id, + 'is_time_limited': True, + 'time_limit': 10, + 'stage_id': self.env['survey.stage'].search([('closed', '=', False)]).id + }) + + q01 = self._add_question( + None, 'When do you know it\'s the right time to use the SO line model?', 'simple_choice', + sequence=1, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=certification.id, + labels=[ + {'value': 'Please stop'}, + {'value': 'Only on the SO form'}, + {'value': 'Only on the Survey form'}, + {'value': 'Easy, all the time!!!', 'is_correct': True, 'answer_score': 2.0} + ]) + + q02 = self._add_question( + None, 'On average, how many lines of code do you need when you use SO line widgets?', 'simple_choice', + sequence=2, + constr_mandatory=True, constr_error_msg='Please select an answer', survey_id=certification.id, + labels=[ + {'value': '1'}, + {'value': '5', 'is_correct': True, 'answer_score': 2.0}, + {'value': '100'}, + {'value': '1000'} + ]) + + q03 = self._add_question( + None, 'What do you think about SO line widgets (not rated)?', 'free_text', + sequence=3, + constr_mandatory=True, constr_error_msg='Please tell us what you think', survey_id=certification.id) + + q04 = self._add_question( + None, 'On a scale of 1 to 10, how much do you like SO line widgets (not rated)?', 'simple_choice', + sequence=4, + constr_mandatory=True, constr_error_msg='Please tell us what you think', survey_id=certification.id, + labels=[ + {'value': '-1'}, + {'value': '0'}, + {'value': '100'} + ]) + + q05 = self._add_question( + None, 'Select all the correct "types" of SO lines', 'multiple_choice', + sequence=5, + constr_mandatory=False, survey_id=certification.id, + labels=[ + {'value': 'sale_order', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'survey_page', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'survey_question', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'a_future_and_yet_unknown_model', 'is_correct': True, 'answer_score': 1.0}, + {'value': 'none', 'answer_score': -1.0} + ]) + + # Step: employee takes the certification + # -------------------------------------------------- + self.authenticate('user_emp', 'user_emp') + + # Employee opens start page + response = self._access_start(certification) + self.assertResponse(response, 200, [certification.title, 'Time limit for this survey', '10 minutes']) + + # -> this should have generated a new user_input with a token + user_inputs = self.env['survey.user_input'].search([('survey_id', '=', certification.id)]) + self.assertEqual(len(user_inputs), 1) + self.assertEqual(user_inputs.partner_id, self.user_emp.partner_id) + answer_token = user_inputs.token + + # Employee begins survey with first page + response = self._access_page(certification, answer_token) + self.assertResponse(response, 200) + csrf_token = self._find_csrf_token(response.text) + + with patch.object(IrMailServer, 'connect'): + self._answer_question(q01, q01.labels_ids.ids[3], answer_token, csrf_token) + self._answer_question(q02, q02.labels_ids.ids[1], answer_token, csrf_token) + self._answer_question(q03, "I think they're great!", answer_token, csrf_token) + self._answer_question(q04, q04.labels_ids.ids[0], answer_token, csrf_token, button_submit='previous') + self._answer_question(q03, "Just kidding, I don't like it...", answer_token, csrf_token) + self._answer_question(q04, q04.labels_ids.ids[0], answer_token, csrf_token) + self._answer_question(q05, [q05.labels_ids.ids[0], q05.labels_ids.ids[1], q05.labels_ids.ids[3]], answer_token, csrf_token) + + user_inputs.invalidate_cache() + # Check that certification is successfully passed + self.assertEqual(user_inputs.quizz_score, 87.5) + self.assertTrue(user_inputs.quizz_passed) + + # Check answer correction is taken into account + self.assertNotIn("I think they're great!", user_inputs.mapped('user_input_line_ids.value_free_text')) + self.assertIn("Just kidding, I don't like it...", user_inputs.mapped('user_input_line_ids.value_free_text')) + + certification_email = self.env['mail.mail'].search([], limit=1, order="create_date desc") + # Check certification email correctly sent and contains document + self.assertIn("User Certification for SO lines", certification_email.subject) + self.assertIn("employee@example.com", certification_email.email_to) + self.assertEqual(len(certification_email.attachment_ids), 1) + self.assertEqual(certification_email.attachment_ids[0].name, 'Certification Document.html') diff --git a/addons/survey/tests/test_survey_flow.py b/addons/survey/tests/test_survey_flow.py index f83352800da..bda59666e3d 100644 --- a/addons/survey/tests/test_survey_flow.py +++ b/addons/survey/tests/test_survey_flow.py @@ -20,10 +20,6 @@ class TestSurveyFlow(common.SurveyCase, HttpCase): def _access_submit(self, survey, token, post_data): return self.url_open('/survey/submit/%s/%s' % (survey.access_token, token), data=post_data) - def _find_csrf_token(self, text): - csrf_token_re = re.compile("(input.+csrf_token.+value=\")([_a-zA-Z0-9]{51})", re.MULTILINE) - return csrf_token_re.search(text).groups()[1] - def _format_submission_data(self, page, answer_data, additional_post_data): post_data = {} post_data['page_id'] = page.id diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index ae93c5d3380..91a99c535b6 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -11,6 +11,7 @@ <script type="text/javascript" src="/survey/static/src/js/survey_result.js" /> <script type="text/javascript" src="/survey/static/src/js/survey.js" /> + <script type="text/javascript" src="/survey/static/src/js/survey_timer.js" /> <script type="text/javascript" src="/survey/static/src/js/tour_test_survey.js"></script> <t t-call="web._assets_helpers"> @@ -25,10 +26,22 @@ <link href="/survey/static/src/css/survey_result.css" rel="stylesheet" type="text/css"></link> </template> + <template id="survey_report_assets_pdf" inherit_id="web.report_assets_pdf"> + <xpath expr="link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_reports.scss"/> + </xpath> + </template> + <template id="assets_backend" name="survey assets" inherit_id="web.assets_backend"> <xpath expr="." position="inside"> <link rel="stylesheet" href="/survey/static/src/css/survey_result.css"/> </xpath> </template> + + <template id="assets_backend_inherit_survey" inherit_id="web.assets_backend" name="Survey backend assets"> + <xpath expr="link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_views.scss"/> + </xpath> + </template> </data> </odoo> diff --git a/addons/survey/views/survey_question_views.xml b/addons/survey/views/survey_question_views.xml index eec66365a20..4521926791e 100644 --- a/addons/survey/views/survey_question_views.xml +++ b/addons/survey/views/survey_question_views.xml @@ -95,12 +95,16 @@ <page string="Answers"> <field name="validation_email" attrs="{'invisible': [('question_type', '!=', 'textbox')]}"/> <label for="validation_email" attrs="{'invisible': [('question_type', '!=', 'textbox')]}"/> + <field name="page_id" invisible="1" required="0"/> + <field name="survey_id" invisible="1" readonly="1"/> + <field name="scoring_type" invisible="1"/> <separator /> <field name="labels_ids" string="Type of answers" context="{'default_question_id': active_id}" attrs="{'invisible': [('question_type', 'not in', ['simple_choice', 'multiple_choice', 'matrix'])]}"> <tree editable="bottom"> <field name="sequence" widget="handle"/> <field name="value" string="Choices"/> - <field name="quizz_mark" groups="base.group_no_one"/> + <field name="is_correct" attrs="{'column_invisible': ['|', ('parent.scoring_type', '=', 'no_scoring'), ('parent.question_type', '=', 'matrix')]}"/> + <field name="answer_score" attrs="{'column_invisible': ['|', ('parent.scoring_type', '=', 'no_scoring'), ('parent.question_type', '=', 'matrix')]}"/> </tree> </field> <separator /> @@ -204,7 +208,7 @@ <field name="question_id"/> <field name="question_id_2"/> <field name="value"/> - <field name="quizz_mark" groups="base.group_no_one"/> + <field name="answer_score" groups="base.group_no_one"/> </tree> </field> </record> diff --git a/addons/survey/views/survey_report_templates.xml b/addons/survey/views/survey_report_templates.xml new file mode 100644 index 00000000000..55b209079f7 --- /dev/null +++ b/addons/survey/views/survey_report_templates.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <template id="certification_report_view"> + <t t-set="data_report_landscape" t-value="True"/> + <t t-set="full_width" t-value="True"/> + <t t-call="web.html_container"> + <t t-foreach="docs" t-as="user_input"> + <div t-att-data-oe-model="user_input._name" t-att-data-oe-id="user_input.id" class="article certification-wrapper"> + <div class="certification"> + <h2> + <span t-field="user_input.survey_id.create_uid.company_id.display_name"/> + </h2> + <p>Certifies that</p> + <h2> + <span t-esc="user_input.partner_id.name or user_input.email"/> + </h2> + <p>Successfully achieved</p> + <h3> + <span t-field="user_input.survey_id.display_name"/> + </h3> + <p> + <strong>Date of Certification:</strong> + <span t-field="user_input.create_date"/> + </p> + </div> + </div> + </t> + </t> + </template> + </data> +</odoo> diff --git a/addons/survey/views/survey_reports.xml b/addons/survey/views/survey_reports.xml new file mode 100644 index 00000000000..fb8783b5136 --- /dev/null +++ b/addons/survey/views/survey_reports.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <data> + <!-- QWeb Reports --> + <report + id="certification_report" + model="survey.user_input" + string="Certifications" + report_type="qweb-pdf" + name="survey.certification_report_view" + file="survey.certification_report_view" + attachment="'certification.pdf'" + print_report_name="'Certification - %s' % (object.survey_id.name)" + /> + </data> +</odoo> diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index 16c600a00d0..7425c201db8 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -61,17 +61,27 @@ <field name="question_ids"/> </tree> </field> + <group string="Description" name="description" > + <field name="description" nolabel="1"></field> + </group> </page> - <page string="Options"> + <page string="Options"> <group name="options"> <group string="Candidates" name="candidates"> <field name="users_login_required"/> + <field name="is_attempts_limited" attrs="{'invisible': [('access_mode', '=', 'public'), ('users_login_required', '=', False)]}" /> + <field name="attempts_limit" attrs="{'invisible': [('is_attempts_limited', '=', False)]}" /> </group> <group string="Questions" name="questions"> <field name="users_can_go_back" string="Enable back button"/> + <field name="is_time_limited" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}" string="Time limit" /> + <field name="time_limit" widget="float_time" nolabel="1" attrs="{'invisible': [('is_time_limited', '=', False)]}" /><span attrs="{'invisible': [('is_time_limited', '=', False)]}"> minutes</span> </group> <group string="Scoring Informations" name="scoring"> - <field name="quizz_mode"/> + <field name="scoring_type" widget="radio" /> + <field name="passing_score" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}" /> + <field name="certificate" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}" /> + <field name="certification_mail_template_id" attrs="{'invisible': [('certificate', '=', False)]}" /> </group> </group> </page> @@ -107,12 +117,13 @@ <field name="stage_id" /> <field name="title" /> <field name="answer_done_count" /> + <field name="certificate" /> <field name="color" /> <field name="access_mode"/> <field name="activity_ids" /> <field name="activity_state" /> <templates> - <div t-name="kanban-box" t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click"> + <div t-name="kanban-box" t-attf-class="oe_kanban_color_#{kanban_getcolor(record.color.raw_value)} oe_kanban_card oe_kanban_global_click o_kanban_card_survey"> <div class="o_dropdown_kanban dropdown" t-if="widget.editable"> <a role="button" class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#" aria-label="Dropdown menu" title="Dropdown menu"> @@ -127,7 +138,10 @@ </div> </div> <div class="o_kanban_record_top"> - <h4 class="o_kanban_record_title mb4"><field name="title" /></h4> + <div class="col-10 p-0"> + <h4 class="o_kanban_record_title mb4"><field name="title" /></h4> + </div> + <div class="col-2"><i t-if="record.certificate.raw_value === true" title="Certification" class="certification_icon fa fa-trophy"></i></div> </div> <div class="o_kanban_record_bottom"> <ul class="list-unstyled"> @@ -160,6 +174,7 @@ <field name="arch" type="xml"> <search string="Survey"> <field string="Title" name="title"/> + <filter string="Is a certification?" name="certificate" domain="[('certificate','=',True)]"/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/> <separator/> <filter string="My Activities" name="activities_my" diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 884151f3ba0..2cf28b6dce0 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -21,15 +21,39 @@ <t t-call="survey.layout"> <div class="wrap"> <div class="container"> - <div class="alert alert-danger text-center no_print" t-if="user_input.test_entry"> - <span>This is a test survey. Your answer will not be registered in the answers. </span> - </div> - <t t-call="survey.back" /> + <t t-if="answer.test_entry" t-call="survey.back" /> <div class="jumbotron mt32"> <h1>Thank you!</h1> <div t-field="survey.thank_you_message" class="oe_no_empty" /> - <div t-if='survey.quizz_mode'>You scored <t t-esc="user_input.quizz_score" /> points.</div> - <div>If you wish, you can <a t-att-href="'/survey/print/%s?answer_token=%s' % (survey.access_token, user_input.token)">review your answers</a>.</div> + <t t-if="survey.scoring_type != 'no_scoring'"> + <div>You scored <t t-esc="answer.quizz_score" />%</div> + <t t-if="answer.quizz_passed"> + <div>Congratulations, you have passed the test!</div> + </t> + <t t-else=""> + <div>Unfortunately, you have failed the test.</div> + <t t-if="survey.is_attempts_limited"> + <t t-set="attempts_left" t-value="survey._get_number_of_attempts_lefts(answer.partner_id, answer.email)" /> + <t t-if="attempts_left > 0"> + <p><span>Number of attemps left</span>: <span t-esc="attempts_left"></span></p> + <p> + <a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/retry/%s/%s' % (survey.access_token, answer.token)"> + Retry + </a> + </p> + </t> + </t> + <t t-else=""> + <br/> + <p> + <a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/retry/%s/%s' % (survey.access_token, answer.token)"> + Retry + </a> + </p> + </t> + </t> + </t> + <div t-if="survey.scoring_type != 'scoring_without_answers'">If you wish, you can <a t-att-href="'/survey/print/%s?answer_token=%s' % (survey.access_token, answer.token)">review your answers</a>.</div> </div> </div> </div> @@ -71,7 +95,7 @@ <t t-call="survey.layout"> <div class="wrap"> <div class="container"> - <t t-call="survey.back" /> + <t t-if="answer.test_entry" t-call="survey.back" /> <div class="jumbotron mt32"> <h1><span t-field="survey.title"/> survey is void</h1> <p>This survey has no question defined currently.<br /> @@ -87,8 +111,11 @@ <!-- Back Button to redirect in form view of survey --> <template id="back" name="Back"> - <div groups="website.group_website_publisher" t-ignore="true" class="float-right"> - <a role="button" t-attf-href="/web#view_type=form&model=survey.survey&id=#{survey.id}&action=survey.action_survey_form" class="btn btn-secondary">Back to Survey</a> + <div groups="survey.group_survey_manager" t-ignore="true" class="alert alert-info alert-dismissible rounded-0 fade show d-print-none css_editable_mode_hidden"> + <div t-ignore="true" class="text-center"> + <a t-attf-href="/web#view_type=form&model=survey.survey&id=#{survey.id}&action=survey.action_survey_form"><i class="fa fa-fw fa-arrow-right"/><span t-if="answer and answer.test_entry">This is a test survey. </span>Edit Survey.</a> + </div> + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> × </button> </div> </template> @@ -140,13 +167,16 @@ <div class="wrap"> <div class="oe_structure" id="oe_structure_survey_init_1"/> <div class="container"> - <div class="alert alert-danger text-center no_print" t-if="answer.test_entry"> - <span>This is a test survey. Your answer will not be registered in the answers. </span> - </div> - <t t-call="survey.back" /> + <t t-if="answer.test_entry" t-call="survey.back" /> <div class='jumbotron mt32'> <h1 t-field='survey.title' /> <div t-field='survey.description' class="oe_no_empty"/> + <div t-if="survey.is_time_limited"> + <p> + <span>Time limit for this survey: </span> + <span class="font-weight-bold text-danger" t-field="survey.time_limit" t-options="{'widget': 'duration', 'unit': 'minute'}"></span> + </p> + </div> <a role="button" class="btn btn-primary btn-lg" t-att-href="'/survey/fill/%s/%s' % (survey.access_token, answer.token)"> Start Survey </a> @@ -163,7 +193,7 @@ <div class="wrap"> <div class="oe_structure" id="oe_structure_survey_survey_1"/> <div class="container"> - <t t-call="survey.back" /> + <t t-if="answer.test_entry" t-call="survey.back" /> <t t-call="survey.page" /> </div> <div class="oe_structure" id="oe_structure_survey_survey_2"/> @@ -173,11 +203,39 @@ <!-- A page --> <template id="page" name="Page"> - <div class="o_page_header"> - <div class="alert alert-danger text-center no_print" t-if="answer.test_entry"> - <span>This is a test survey. Your answer will not be registered in the answers. </span> + <div class="o_page_header o_survey_header"> + <div class="container"> + <div class="row"> + <div class="col-lg-8"><h1 t-esc="survey.title"></h1></div> + <div + t-if="survey.is_time_limited" + class="js_survey_timer col-lg-4" + t-att-data-timer="answer.start_datetime.isoformat()" + t-att-data-time_limit_minutes="survey.time_limit"> + <h1 class="timer text-right">00:00</h1> + </div> + </div> </div> - <p class="float-right">Page <span t-raw='page_nr + 1'/> of <span t-raw="len(survey.page_ids)"/></p> + <ol class="breadcrumb mt8 justify-content-end"> + <t t-set="can_go_back" t-value="survey.users_can_go_back" /> + <t t-foreach='survey.page_ids' t-as='breadcrumb_page'> + <li t-attf-class="breadcrumb-item #{'active' if breadcrumb_page == page else ''}"> + <t t-if="page == breadcrumb_page"> + <!-- Users can only go back and not forward --> + <!-- As soon as we reach the current page, set "can_go_back" to False --> + <t t-set="can_go_back" t-value="False" /> + </t> + <t t-if="can_go_back"> + <a t-att-href="'/survey/page/%s/%s/%s' % (survey.access_token, answer.token, breadcrumb_page.id)"> + <span t-field='breadcrumb_page.title' /> + </a> + </t> + <t t-else=""> + <span t-field='breadcrumb_page.title' /> + </t> + </li> + </t> + </ol> <h1 t-field='page.title' /> <div t-field='page.description' class="oe_no_empty"/> </div> @@ -210,7 +268,7 @@ </div> </t> <div class="text-center mt16 mb16"> - <button t-if="survey.users_can_go_back and page_nr > 0" type="submit" class="btn btn-secondary" name="button_submit" value="previous">Previous page</button> + <button t-if="survey.users_can_go_back and page != survey.page_ids[0]" type="submit" class="btn btn-secondary" name="button_submit" value="previous">Previous page</button> <button t-if="not last" type="submit" class="btn btn-primary" name="button_submit" value="next">Next page</button> <button t-if="last" type="submit" class="btn btn-primary" name="button_submit" value="finish">Submit survey</button> </div> @@ -295,7 +353,7 @@ </div> <div t-if="question.display_mode == 'columns' or not answer.token" class="row js_radio"> <div t-foreach='question.labels_ids' t-as='label' t-attf-class="col-lg-#{question.column_nb}"> - <label t-att-class="' bg-success ' if quizz_correction and label.quizz_mark > 0.0 else None"> + <label t-att-class="' bg-success ' if quizz_correction and label.answer_score > 0.0 else None"> <input type="radio" t-att-name="prefix" t-att-value='label.id' /> <span t-field='label.value'/> </label> @@ -317,7 +375,7 @@ <template id="multiple_choice" name="Multiple choice"> <div class="row"> <div t-foreach='question.labels_ids' t-as='label' t-attf-class="col-lg-#{question.column_nb}"> - <label t-att-class="' bg-success ' if quizz_correction and label.quizz_mark > 0.0 else None"> + <label t-att-class="' bg-success ' if quizz_correction and label.answer_score > 0.0 else None"> <input type="checkbox" t-att-name="'%s_%s' % (prefix, label.id)" t-att-value='label.id' /> <span t-field='label.value'/> </label> @@ -365,10 +423,7 @@ <t t-call="survey.layout"> <div class="wrap"> <div class="container"> - <div class="alert alert-danger text-center no_print" t-if="answer and answer.test_entry"> - <span>This is a test survey. Your answer will not be registered in the answers. </span> - </div> - <t t-call="survey.back" /> + <t t-if="answer.test_entry" t-call="survey.back" /> <div class='jumbotron mt32'> <h1><span t-field='survey.title'/></h1> <t t-if="survey.description"><div t-field='survey.description' class="oe_no_empty"/></t> @@ -413,7 +468,8 @@ <div class="container js_surveyresult"> <t t-call="survey.back" /> <div class="jumbotron mt32"> - <h1><span t-field="survey.title" /> <span style="font-size:1.5em;" class="fa fa-bar-chart-o float-right " role="img" aria-label="Chart" title="Chart"/></h1> + <h1><span t-field="survey.title" /> + <span style="font-size:1.5em;" t-attf-class="fa fa-bar-chart-o #{'fa-bar-chart-o' if survey.scoring_type == 'no_scoring' else 'fa-trophy' if survey.certificate else 'fa-question-circle-o'} float-right " role="img" aria-label="Chart" title="Chart"/></h1> <div t-field="survey.description" class="oe_no_empty" /> <h2 t-if="not answers"> Sorry, no one answered this survey yet. @@ -433,6 +489,15 @@ </span> </div> </div> + <div t-if="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers']"> + <h1 class="mt16">Results Overview</h1> + <div>Success rate: <mark class="font-weight-bold"><t t-esc="survey_dict['success_rate']"></t>%</mark></div> + <div id="scoring_results_chart" t-att-data-graph_data="survey_dict['scoring_graph_data']"> + <!-- svg element for drawing pie chart --> + <svg style="height:20em"></svg> + </div> + <hr/> + </div> <div t-if="answers" t-foreach="survey_dict['page_ids']" t-as='page_ids'> <t t-set="page" t-value="page_ids['page']"/> <h1 class="mt16" t-field='page.title'></h1> @@ -443,6 +508,7 @@ <t t-set="question" t-value="question_ids['question']"/> <t t-set="graph_data" t-value="question_ids['graph_data']"/> <t t-set="prepare_result" t-value="question_ids['prepare_result']"/> + <t t-set="question_with_correct_answers" t-value="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers'] and question.question_type in ['simple_choice', 'multiple_choice']" /> <h4> <b>Question </b> <span t-field='question.question'></span> @@ -454,9 +520,25 @@ </t> <span class="float-right"> <span class="badge badge-success"><span t-esc="input_summary['answered']"></span> Answered</span> - <span class="badge badge-danger"><span t-esc="input_summary['skipped']"></span> Skipped</span> + <span class="badge badge-warning"><span t-esc="input_summary['skipped']"></span> Skipped</span> </span> </h4> + <t t-if="question_with_correct_answers"> + <t t-set="correct_answers" t-value="question.get_correct_answer_ids()" /> + <div t-if="correct_answers"> + <t t-if="question.question_type == 'simple_choice'"> + <p><span>Correct answer</span>: <span class="font-weight-bold" t-esc="correct_answers[0].value"></span></p> + </t> + <t t-if="question.question_type == 'multiple_choice'"> + <span>Correct answers</span>: + <ul> + <li t-foreach="correct_answers" t-as="correct_answer"> + <t t-esc="correct_answer.value" /> + </li> + </ul> + </t> + </div> + </t> <t t-if="input_summary['answered'] != 0"> <t t-if="question.description"> <div class="text-muted oe_no_empty" t-field="question.description" /> @@ -575,6 +657,7 @@ <tr> <th>Answer Choices</th> <th>User Responses</th> + <th t-if="question_with_correct_answers">Answer Score</th> </tr> </thead> <tbody> @@ -587,6 +670,8 @@ <span t-esc="user_input['count']" class="badge badge-primary">Vote</span> <i class="fa fa-filter text-primary invisible survey_filter" t-att-data-question_id="question.id" t-att-data-answer_id="user_input['answer_id']" role="img" aria-label="Filter question" title="Filter question"/> </td> + <td t-if="question_with_correct_answers" t-esc="user_input['answer_score']"> + </td> </tr> </tbody> </table> diff --git a/addons/survey/views/survey_user_views.xml b/addons/survey/views/survey_user_views.xml index 03fe5ca84e9..f7a58249125 100644 --- a/addons/survey/views/survey_user_views.xml +++ b/addons/survey/views/survey_user_views.xml @@ -59,7 +59,7 @@ <field name="answer_type"/> <field name="skipped"/> <field name="create_date"/> - <field name="quizz_mark" groups="base.group_no_one"/> + <field name="answer_score" groups="base.group_no_one"/> </tree> </field> </sheet> @@ -160,7 +160,7 @@ <field name="create_date"/> <field name="answer_type"/> <field name="skipped" /> - <field name="quizz_mark" groups="base.group_no_one"/> + <field name="answer_score" groups="base.group_no_one"/> </group> <group> <field name="value_text" colspan='2' attrs="{'invisible': [('answer_type','!=','text')]}"/> @@ -185,7 +185,7 @@ <field name="create_date"/> <field name="answer_type"/> <field name="skipped"/> - <field name="quizz_mark" groups="base.group_no_one"/> + <field name="answer_score" groups="base.group_no_one"/> </tree> </field> </record> |
