diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index a6efd4f8981..0e6f516c3dc 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -460,17 +460,18 @@ class Survey(http.Controller): @http.route('/survey/begin//', type='json', auth='public', website=True) def survey_begin(self, survey_token, answer_token, **post): - """ Route used to start the survey user input and display the first survey page. """ + """ Route used to start the survey user input and display the first survey page. + Returns an empty dict for the correct answers and the first page html. """ access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: - return {'error': access_data['validity_code']} + return {}, {'error': access_data['validity_code']} survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] if answer_sudo.state != "new": - return {'error': _("The survey has already started.")} + return {}, {'error': _("The survey has already started.")} answer_sudo._mark_in_progress() - return self._prepare_question_html(survey_sudo, answer_sudo, **post) + return {}, self._prepare_question_html(survey_sudo, answer_sudo, **post) @http.route('/survey/next_question//', type='json', auth='public', website=True) def survey_next_question(self, survey_token, answer_token, **post): @@ -491,15 +492,16 @@ class Survey(http.Controller): """ 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 will be ignored and - survey state will be forced to 'done'""" + survey state will be forced to 'done'. + Also returns the correct answers if the scoring type is 'scoring_with_answers_after_page'.""" # Survey Validation access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: - return {'error': access_data['validity_code']} + return {}, {'error': access_data['validity_code']} survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] if answer_sudo.state == 'done': - return {'error': 'unauthorized'} + return {}, {'error': 'unauthorized'} questions, page_or_question_id = survey_sudo._get_survey_questions(answer=answer_sudo, page_id=post.get('page_id'), @@ -507,7 +509,7 @@ class Survey(http.Controller): if not answer_sudo.test_entry and not survey_sudo._has_attempts_left(answer_sudo.partner_id, answer_sudo.email, answer_sudo.invite_token): # prevent cheating with users creating multiple 'user_input' before their last attempt - return {'error': 'unauthorized'} + return {}, {'error': 'unauthorized'} if answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached: if answer_sudo.question_time_limit_reached: @@ -520,7 +522,7 @@ class Survey(http.Controller): time_limit += timedelta(seconds=10) if fields.Datetime.now() > time_limit: # prevent cheating with users blocking the JS timer and taking all their time to answer - return {'error': 'unauthorized'} + return {}, {'error': 'unauthorized'} errors = {} # Prepare answers / comment by question, validate and save answers @@ -531,24 +533,30 @@ class Survey(http.Controller): answer, comment = self._extract_comment_from_answers(question, post.get(str(question.id))) errors.update(question.validate_question(answer, comment)) if not errors.get(question.id): - answer_sudo.save_lines(question, answer, comment) + answer_sudo._save_lines(question, answer, comment, overwrite_existing=survey_sudo.users_can_go_back) if errors and not (answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached): - return {'error': 'validation', 'fields': errors} + return {}, {'error': 'validation', 'fields': errors} if not answer_sudo.is_session_answer: answer_sudo._clear_inactive_conditional_answers() + # Get the page questions correct answers if scoring type is scoring after page + correct_answers = {} + if survey_sudo.scoring_type == 'scoring_with_answers_after_page': + scorable_questions = (questions - answer_sudo._get_inactive_conditional_questions()).filtered('is_scored_question') + correct_answers = scorable_questions._get_correct_answers() + if answer_sudo.survey_time_limit_reached or survey_sudo.questions_layout == 'one_page': answer_sudo._mark_done() elif 'previous_page_id' in post: # when going back, save the last displayed to reload the survey where the user left it. answer_sudo.last_displayed_page_id = post['previous_page_id'] # Go back to specific page using the breadcrumb. Lines are saved and survey continues - return self._prepare_question_html(survey_sudo, answer_sudo, **post) + return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo, **post) elif 'next_skipped_page_or_question' in post: answer_sudo.last_displayed_page_id = page_or_question_id - return self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True) + return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True) else: if not answer_sudo.is_session_answer: page_or_question = request.env['survey.question'].sudo().browse(page_or_question_id) @@ -563,13 +571,13 @@ class Survey(http.Controller): 'last_displayed_page_id': page_or_question_id, 'survey_first_submitted': True, }) - return self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True) + return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True) else: answer_sudo._mark_done() answer_sudo.last_displayed_page_id = page_or_question_id - return self._prepare_question_html(survey_sudo, answer_sudo) + return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo) def _extract_comment_from_answers(self, question, answers): """ Answers is a custom structure depending of the question type @@ -637,11 +645,11 @@ class Survey(http.Controller): 'survey': survey_sudo, 'answer': answer_sudo if survey_sudo.scoring_type != 'scoring_without_answers' else answer_sudo.browse(), 'questions_to_display': answer_sudo._get_print_questions(), - 'scoring_display_correction': survey_sudo.scoring_type == 'scoring_with_answers' and answer_sudo, + 'scoring_display_correction': survey_sudo.scoring_type in ['scoring_with_answers', 'scoring_with_answers_after_page'] and answer_sudo, 'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False), 'format_date': lambda date: format_date(request.env, date), 'graph_data': json.dumps(answer_sudo._prepare_statistics()[answer_sudo]) - if answer_sudo and survey_sudo.scoring_type == 'scoring_with_answers' else False, + if answer_sudo and survey_sudo.scoring_type in ['scoring_with_answers', 'scoring_with_answers_after_page'] else False, }) @http.route('/survey//certification_preview', type="http", auth="user", website=True) diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index de57bd88d1f..c1e11f1d7d9 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -651,6 +651,41 @@ class SurveyQuestion(models.Model): 'right_inputs_count': len(user_input_lines.filtered(lambda line: line.answer_is_correct).mapped('user_input_id')) } + # ------------------------------------------------------------ + # OTHERS + # ------------------------------------------------------------ + + def _get_correct_answers(self): + """ Return a dictionary linking the scorable question ids to their correct answers. + The questions without correct answers are not considered. + """ + correct_answers = {} + + # Simple and multiple choice + choices_questions = self.filtered(lambda q: q.question_type in ['simple_choice', 'multiple_choice']) + if choices_questions: + suggested_answers_data = self.env['survey.question.answer'].search_read( + [('question_id', 'in', choices_questions.ids), ('is_correct', '=', True)], + ['question_id', 'id'], + load='', # prevent computing display_names + ) + for data in suggested_answers_data: + if not data.get('id'): + continue + correct_answers.setdefault(data['question_id'], []).append(data['id']) + + # Numerical box, date, datetime + for question in self - choices_questions: + if question.question_type not in ['numerical_box', 'date', 'datetime']: + continue + answer = question[f'answer_{question.question_type}'] + if question.question_type == 'date': + answer = tools.format_date(self.env, answer) + elif question.question_type == 'datetime': + answer = tools.format_datetime(self.env, answer, tz='UTC', dt_format=False) + correct_answers[question.id] = answer + + return correct_answers class SurveyQuestionAnswer(models.Model): """ A preconfigured answer for a question. This model stores values used diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 167309a5201..96b96a558bf 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -9,7 +9,7 @@ from collections import defaultdict import werkzeug from odoo import api, exceptions, fields, models, _ -from odoo.exceptions import AccessError, UserError +from odoo.exceptions import AccessError, UserError, ValidationError from odoo.osv import expression from odoo.tools import is_html_empty @@ -119,8 +119,9 @@ class Survey(models.Model): # scoring scoring_type = fields.Selection([ ('no_scoring', 'No scoring'), + ('scoring_with_answers_after_page', 'Scoring with answers after each page'), ('scoring_with_answers', 'Scoring with answers at the end'), - ('scoring_without_answers', 'Scoring without answers at the end')], + ('scoring_without_answers', 'Scoring without answers')], string='Scoring', required=True, store=True, readonly=False, compute='_compute_scoring_type', precompute=True) scoring_success_min = fields.Float('Required Score (%)', default=80.0) scoring_max_obtainable = fields.Float('Maximum obtainable score', compute='_compute_scoring_max_obtainable') @@ -348,7 +349,8 @@ class Survey(models.Model): @api.depends('certification') def _compute_scoring_type(self): for survey in self: - if survey.certification and survey.scoring_type not in ['scoring_without_answers', 'scoring_with_answers']: + if survey.certification and \ + survey.scoring_type not in ['scoring_without_answers', 'scoring_with_answers', 'scoring_with_answers_after_page']: survey.scoring_type = 'scoring_without_answers' elif not survey.scoring_type: survey.scoring_type = 'no_scoring' @@ -378,6 +380,15 @@ class Survey(models.Model): 'scoring_type': 'scoring_with_answers', }) + @api.constrains('scoring_type', 'users_can_go_back') + def _check_scoring_after_page_availability(self): + failing = self.filtered(lambda survey: survey.scoring_type == 'scoring_with_answers_after_page' and survey.users_can_go_back) + if failing: + raise ValidationError( + _('Combining roaming and "Scoring with answers after each page" is not possible; please update the following surveys:\n- %(survey_names)s', + survey_names="\n- ".join(failing.mapped('title'))) + ) + # ------------------------------------------------------------ # CRUD # ------------------------------------------------------------ @@ -498,9 +509,9 @@ class Survey(models.Model): lambda q: q.question_type == 'char_box' and (q.save_as_email or q.save_as_nickname)): for user_input in user_inputs: if question.save_as_email and user_input.email: - user_input.save_lines(question, user_input.email) + user_input._save_lines(question, user_input.email) if question.save_as_nickname and user_input.nickname: - user_input.save_lines(question, user_input.nickname) + user_input._save_lines(question, user_input.nickname) return user_inputs diff --git a/addons/survey/models/survey_user_input.py b/addons/survey/models/survey_user_input.py index 4d310387a54..cc94f7343cf 100644 --- a/addons/survey/models/survey_user_input.py +++ b/addons/survey/models/survey_user_input.py @@ -8,7 +8,7 @@ import uuid from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, UserError from odoo.tools import float_is_zero _logger = logging.getLogger(__name__) @@ -272,16 +272,19 @@ class SurveyUserInput(models.Model): # CREATE / UPDATE LINES FROM SURVEY FRONTEND INPUT # ------------------------------------------------------------ - def save_lines(self, question, answer, comment=None): - """ Save answers to questions, depending on question type + def _save_lines(self, question, answer, comment=None, overwrite_existing=True): + """ Save answers to questions, depending on question type. - If an answer already exists for question and user_input_id, it will be - overwritten (or deleted for 'choice' questions) (in order to maintain data consistency). + :param bool overwrite_existing: if an answer already exists for question and user_input_id + it will be overwritten (or deleted for 'choice' questions) in order to maintain data consistency. + :raises UserError: if line exists and overwrite_existing is False """ old_answers = self.env['survey.user_input.line'].search([ ('user_input_id', '=', self.id), ('question_id', '=', question.id) ]) + if old_answers and not overwrite_existing: + raise UserError(_("This answer cannot be overwritten.")) if question.question_type in ['char_box', 'text_box', 'numerical_box', 'date', 'datetime']: self._save_line_simple_answer(question, old_answers, answer) diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index d195e93d068..318c960ee99 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -71,7 +71,8 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa self._focusOnFirstInput(); // Init event listener if (!self.readonly) { - $(document).on('keydown', self._onKeyDown.bind(self)); + self.documentKeydownListener = self._onKeyDown.bind(self); + $(document).on('keydown', self.documentKeydownListener); } if (self.options.sessionInProgress && (self.options.isStartScreen || self.options.hasAnswered || self.options.isPageDescription)) { @@ -363,8 +364,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa * @param {Boolean} [options.isFinish] fades out breadcrumb and timer * @private */ - _submitForm: function (options) { - var self = this; + _submitForm: async function (options) { var params = {}; if (options.previousPageId) { params.previous_page_id = options.previousPageId; @@ -404,10 +404,18 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa this.readonly = true; } - var submitPromise = self.rpc( - `${route}/${self.options.surveyToken}/${self.options.answerToken}`, + const submitPromise = this.rpc( + `${route}/${this.options.surveyToken}/${this.options.answerToken}`, params ); + + if (!this.options.isStartScreen && this.options.scoringType == 'scoring_with_answers_after_page') { + const [correctAnswers] = await submitPromise; + if (Object.keys(correctAnswers).length && document.querySelector('.js_question-wrapper')) { + this._showCorrectAnswers(correctAnswers, submitPromise, options); + return; + } + } this._nextScreen(submitPromise, options); }, @@ -417,18 +425,16 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa * @param {Promise} nextScreenPromise * @param {Object} options see '_submitForm' for details */ - _nextScreen: function (nextScreenPromise, options) { - var self = this; - + _nextScreen: async function (nextScreenPromise, options) { var resolveFadeOut; var fadeOutPromise = new Promise(function (resolve, reject) {resolveFadeOut = resolve;}); var selectorsToFadeout = ['.o_survey_form_content']; - if (options.isFinish && !this.nextScreenResult.has_skipped_questions) { + if (options.isFinish && !this.nextScreenResult?.has_skipped_questions) { selectorsToFadeout.push('.breadcrumb', '.o_survey_timer'); - cookie.delete('survey_' + self.options.surveyToken); + cookie.delete('survey_' + this.options.surveyToken); } - self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () { + this.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () { resolveFadeOut(); }); // Background management - Fade in / out on each transition @@ -436,20 +442,20 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa $('div.o_survey_background').addClass('o_survey_background_transition'); } - var nextScreenWithBackgroundPromise = nextScreenPromise.then(function (result) { - self.nextScreenResult = result; + const nextScreenWithBackgroundPromise = (async () => { + const [,result] = await nextScreenPromise; + this.nextScreenResult = result; // once we have the next question, wait for the preload of the background - if (self.options.refreshBackground && result.background_image_url) { - return self._preloadBackground(result.background_image_url); + if (this.options.refreshBackground && result.background_image_url) { + return this._preloadBackground(result.background_image_url); } else { return Promise.resolve(); } - }); + })(); // Wait for the fade out and the preload of the next background. The next question have already been fetched. - Promise.all([fadeOutPromise, nextScreenWithBackgroundPromise]).then(function () { - return self._onNextScreenDone(options); - }); + await Promise.all([fadeOutPromise, nextScreenWithBackgroundPromise]); + return this._onNextScreenDone(options); }, /** @@ -1127,6 +1133,77 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend(SurveyPreloa return inactiveQuestionIds; }, + // ANSWERS TOOLS + // ------------------------------------------------------------------------- + + _showCorrectAnswers: function(correctAnswers, submitPromise, options) { + // Display the correct answers + Object.keys(correctAnswers).forEach(questionId => this._showQuestionAnswer(correctAnswers, questionId)); + // Make the form completely readonly + const form = document.querySelector('form'); + form.querySelectorAll('input, textarea, label, td')?.forEach(node => { + node.classList.add("pe-none"); + }); + // Replace the Submit button by a Next button + form.querySelector('button[type="submit"]').classList.add('d-none'); + const nextPageBtn = form.querySelector('button[id="next_page"]'); + nextPageBtn.classList.remove('d-none'); + nextPageBtn.addEventListener('click', () => { + this._nextScreen(submitPromise, options); + }); + // Replacing the original onKeyDown listener to block everything except for the + // enter or arrow right key down events trigerring the next page display + const nextPageKeydownListener = (event) => { + if (event.code === 'Enter' || event.code === 'ArrowRight') { + // Restore original keydown listener + document.removeEventListener('keydown', nextPageKeydownListener); + document.addEventListener('keydown', this.documentKeydownListener); + this._nextScreen(submitPromise, options); + } + } + document.removeEventListener('keydown', this.documentKeydownListener); + document.addEventListener('keydown', nextPageKeydownListener); + }, + + _showQuestionAnswer: function(correctAnswers, questionId) { + const correctAnswer = correctAnswers[questionId]; + const questionWrapper = document.querySelector(`.js_question-wrapper[id="${questionId}"]`); + const answerWrapper = questionWrapper.querySelector('.o_survey_answer_wrapper'); + const questionType = questionWrapper.querySelector('[data-question-type]').dataset.questionType; + + if (['numerical_box', 'date', 'datetime'].includes(questionType)) { + const input = answerWrapper.querySelector('input'); + let isCorrect; + if (questionType == 'numerical_box') { + isCorrect = input.valueAsNumber === correctAnswer; + } else if (questionType == 'datetime') { + const datetime = parseDateTime(input.value); + const value = datetime ? datetime.setZone("utc").toFormat("MM/dd/yyyy HH:mm:ss", { numberingSystem: "latn" }) : ''; + isCorrect = value === correctAnswer; + } else { + isCorrect = input.value === correctAnswer; + } + answerWrapper.classList.add(`bg-${isCorrect ? 'success' : 'danger'}`); + } + else if (['simple_choice_radio', 'multiple_choice'].includes(questionType)) { + answerWrapper.querySelectorAll('.o_survey_choice_btn').forEach((button) => { + const answerId = button.querySelector('input').value; + const isCorrect = correctAnswer.includes(parseInt(answerId)); + button.classList.add(`bg-${isCorrect ? 'success' : 'danger'}`, 'text-white'); + // For the user incorrect answers, replace the empty check icon by a crossed check icon + if (!isCorrect && button.classList.contains('o_survey_selected')) { + let fromIcon = 'fa-check-circle'; + let toIcon = 'fa-times-circle'; + if (questionType == 'multiple_choice') { + fromIcon = 'fa-check-square'; + toIcon = 'fa-times-rectangle'; // fa-times-square doesn't exist in fontawesome 4.7 + } + button.querySelector(`i.${fromIcon}`)?.classList.replace(fromIcon, toIcon); + } + }); + } + }, + // ERRORS TOOLS // ------------------------------------------------------------------------- diff --git a/addons/survey/tests/__init__.py b/addons/survey/tests/__init__.py index f52049f2faa..be2dc62c676 100644 --- a/addons/survey/tests/__init__.py +++ b/addons/survey/tests/__init__.py @@ -3,6 +3,7 @@ from . import common from . import test_survey +from . import test_survey_controller from . import test_survey_flow from . import test_survey_flow_with_conditions from . import test_certification_flow diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index bcb6579b186..b7754e62fea 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import datetime import re from collections import Counter @@ -255,6 +256,40 @@ class SurveyCase(common.TransactionCase): return all_questions + def _create_one_question_per_type_with_scoring(self): + all_questions = self.env['survey.question'] + for (question_type, dummy) in self.env['survey.question']._fields['question_type'].selection: + kwargs = {} + kwargs['question_type'] = question_type + if question_type == 'numerical_box': + kwargs['answer_score'] = 1 + kwargs['answer_numerical_box'] = 5 + elif question_type == 'date': + kwargs['answer_score'] = 2 + kwargs['answer_date'] = datetime.date(2023, 10, 16) + elif question_type == 'datetime': + kwargs['answer_score'] = 3 + kwargs['answer_datetime'] = datetime.datetime(2023, 11, 17, 8, 0, 0) + elif question_type == 'multiple_choice': + kwargs['answer_score'] = 4 + kwargs['labels'] = [ + {'value': 'MChoice0', 'is_correct': True}, + {'value': 'MChoice1', 'is_correct': True}, + {'value': 'MChoice2'} + ] + elif question_type == 'simple_choice': + kwargs['answer_score'] = 5 + kwargs['labels'] = [ + {'value': 'SChoice0', 'is_correct': True}, + {'value': 'SChoice1'} + ] + elif question_type == 'matrix': + kwargs['labels'] = [{'value': 'Column0'}, {'value': 'Column1'}] + kwargs['labels_2'] = [{'value': 'Row0'}, {'value': 'Row1'}] + all_questions |= self._add_question(self.page_0, 'Q0', question_type, **kwargs) + + return all_questions + class TestSurveyCommon(SurveyCase): diff --git a/addons/survey/tests/test_survey.py b/addons/survey/tests/test_survey.py index 204c9c1ea37..8217271c183 100644 --- a/addons/survey/tests/test_survey.py +++ b/addons/survey/tests/test_survey.py @@ -330,7 +330,7 @@ class TestSurveyInternals(common.TestSurveyCommon, MailCase): for question in questions: answer = '' if question.question_type in ['char_box', 'text_box'] else None - survey_user.save_lines(question, answer) + survey_user._save_lines(question, answer) for question in questions: self._assert_skipped_question(question, survey_user) @@ -564,6 +564,20 @@ class TestSurveyInternals(common.TestSurveyCommon, MailCase): # Now it will also be always visible self.assertFalse(bool(not_veggie_question.triggering_answer_ids)) + def test_get_correct_answers(self): + questions = self._create_one_question_per_type_with_scoring() + qtype_mapping = {q.question_type: q for q in questions} + expected_correct_answer = { + qtype_mapping['numerical_box'].id: 5, + qtype_mapping['date'].id: '10/16/2023', + qtype_mapping['datetime'].id: '11/17/2023 08:00:00', + qtype_mapping['simple_choice'].id: + qtype_mapping['simple_choice'].suggested_answer_ids.filtered_domain([('value', '=', 'SChoice0')]).ids, + qtype_mapping['multiple_choice'].id: + qtype_mapping['multiple_choice'].suggested_answer_ids.filtered_domain([('value', 'in', ['MChoice0', 'MChoice1'])]).ids, + } + self.assertEqual(questions._get_correct_answers(), expected_correct_answer) + def test_get_pages_and_questions_to_show(self): """ Tests the method `_get_pages_and_questions_to_show` - it takes a recordset of diff --git a/addons/survey/tests/test_survey_controller.py b/addons/survey/tests/test_survey_controller.py new file mode 100644 index 00000000000..8f1e45dd26e --- /dev/null +++ b/addons/survey/tests/test_survey_controller.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import Command +from odoo.addons.survey.tests import common +from odoo.tests.common import HttpCase + + +class TestSurveyController(common.TestSurveyCommon, HttpCase): + + def test_submit_route_scoring_after_page(self): + """ Check that the submit route for a scoring after page survey is returning the + accurate correct answers depending on the survey layout and the active page questions. + The correct answers of the inactive conditional questions shouldn't be returned. + """ + survey = self.env['survey.survey'].create({ + 'title': 'How much do you know about words?', + 'scoring_type': 'scoring_with_answers_after_page', + }) + ( + a_q1_partial, a_q1_correct, a_q1_incorrect, + a_q2_incorrect, a_q2_correct, + a_q3_correct, a_q3_incorrect + ) = self.env['survey.question.answer'].create([ + {'value': 'A thing full of letters.', 'answer_score': 1.0}, + {'value': 'A unit of language, [...], carrying a meaning.', 'answer_score': 4.0, 'is_correct': True}, + {'value': 'A thing related to space', 'answer_score': -4.0}, + {'value': 'Yes', 'answer_score': -0.5}, + {'value': 'No', 'answer_score': 0.5, 'is_correct': True}, + {'value': 'Yes', 'answer_score': 0.5, 'is_correct': True}, + {'value': 'No', 'answer_score': 0.2}, + ]) + q1, q2, q3 = self.env['survey.question'].create([{ + 'survey_id': survey.id, + 'title': 'What is a word?', + 'sequence': 2, + 'question_type': 'simple_choice', + 'suggested_answer_ids': [Command.set((a_q1_partial | a_q1_correct | a_q1_incorrect).ids)], + 'constr_mandatory': False, + }, { + 'survey_id': survey.id, + 'title': 'Are you sure?', + 'sequence': 3, + 'question_type': 'simple_choice', + 'suggested_answer_ids': [Command.set((a_q2_incorrect | a_q2_correct).ids)], + 'triggering_answer_ids': [Command.set((a_q1_partial | a_q1_incorrect).ids)], + 'constr_mandatory': False, + }, { + 'survey_id': survey.id, + 'title': 'Are you sure?', + 'sequence': 5, + 'question_type': 'simple_choice', + 'suggested_answer_ids': [Command.set((a_q3_correct | a_q3_incorrect).ids)], + 'triggering_answer_ids': [Command.set([a_q1_correct.id])], + 'constr_mandatory': False, + }]) + pages = [ + {'is_page': True, 'question_type': False, 'sequence': 1, 'title': 'Page 0', 'survey_id': survey.id}, + {'is_page': True, 'question_type': False, 'sequence': 4, 'title': 'Page 1', 'survey_id': survey.id}, + ] + + q1_correct_answer = {str(q1.id): [a_q1_correct.id]} + cases = [ + ('page_per_question', [], q1_correct_answer), + ('page_per_question', a_q1_correct, q1_correct_answer), + ('page_per_question', a_q1_incorrect, q1_correct_answer), + ('one_page', [], q1_correct_answer), # skipping gives answers for active questions (q2 and q3 conditional questions are inactive) + ('one_page', a_q1_correct, {**q1_correct_answer, str(q3.id): [a_q3_correct.id]}), + ('one_page', a_q1_partial, {**q1_correct_answer, str(q2.id): [a_q2_correct.id]}), + # page0 contains q1 and q2, page1 contains q3 + ('page_per_section', [], q1_correct_answer), + ('page_per_section', a_q1_correct, q1_correct_answer), # no correct answers for q3 because q3 is not on the same page as q1 + ('page_per_section', a_q1_partial, {**q1_correct_answer, str(q2.id): [a_q2_correct.id]}), + ] + + for case_index, (layout, answer_q1, expected_correct_answers) in enumerate(cases): + with self.subTest(case_index=case_index, layout=layout): + survey.questions_layout = layout + if layout == 'page_per_section': + page0, _ = self.env['survey.question'].create(pages) + + response = self._access_start(survey) + user_input = self.env['survey.user_input'].search([('access_token', '=', response.url.split('/')[-1])]) + answer_token = user_input.access_token + + r = self._access_page(survey, answer_token) + self.assertResponse(r, 200) + csrf_token = self._find_csrf_token(response.text) + + r = self._access_begin(survey, answer_token) + self.assertResponse(r, 200) + + post_data = {'csrf_token': csrf_token, 'token': answer_token} + post_data[q1.id] = answer_q1.id if answer_q1 else answer_q1 + if layout == 'page_per_question': + post_data['question_id'] = q1.id + elif layout == 'page_per_section': + post_data['page_id'] = page0.id + + # Submit answers and check the submit route is returning the accurate correct answers + response = self._access_submit(survey, answer_token, post_data) + self.assertResponse(response, 200) + self.assertEqual(response.json()['result'][0], expected_correct_answers) + + user_input.invalidate_recordset() # TDE note: necessary as lots of sudo in controllers messing with cache diff --git a/addons/survey/tests/test_survey_ui_session.py b/addons/survey/tests/test_survey_ui_session.py index 41079152047..2046e8efe6d 100644 --- a/addons/survey/tests/test_survey_ui_session.py +++ b/addons/survey/tests/test_survey_ui_session.py @@ -189,31 +189,31 @@ class TestUiSession(HttpCase): # create a few answers beforehand to avoid having to back and forth too # many times between the tours and the python test - attendee_1.save_lines(nickname_question, 'xxxTheBestxxx') - attendee_2.save_lines(nickname_question, 'azerty') - attendee_3.save_lines(nickname_question, 'nicktalope') + attendee_1._save_lines(nickname_question, 'xxxTheBestxxx') + attendee_2._save_lines(nickname_question, 'azerty') + attendee_3._save_lines(nickname_question, 'nicktalope') self.assertEqual('xxxTheBestxxx', attendee_1.nickname) self.assertEqual('azerty', attendee_2.nickname) self.assertEqual('nicktalope', attendee_3.nickname) - attendee_1.save_lines(text_question, 'Attendee 1 is the best') - attendee_2.save_lines(text_question, 'Attendee 2 rulez') - attendee_3.save_lines(text_question, 'Attendee 3 will crush you') - attendee_1.save_lines(date_question, '2010-10-10') - attendee_2.save_lines(date_question, '2011-11-11') - attendee_2.save_lines(datetime_question, '2010-10-10 10:00:00') - attendee_3.save_lines(datetime_question, '2011-11-11 15:55:55') - attendee_1.save_lines(simple_choice_question, simple_choice_answer_1.id) - attendee_2.save_lines(simple_choice_question, simple_choice_answer_1.id) - attendee_3.save_lines(simple_choice_question, simple_choice_answer_2.id) - attendee_1.save_lines(scored_choice_question, scored_choice_answer_1.id) - attendee_2.save_lines(scored_choice_question, scored_choice_answer_2.id) - attendee_3.save_lines(scored_choice_question, scored_choice_answer_3.id) - attendee_1.save_lines(timed_scored_choice_question, + attendee_1._save_lines(text_question, 'Attendee 1 is the best') + attendee_2._save_lines(text_question, 'Attendee 2 rulez') + attendee_3._save_lines(text_question, 'Attendee 3 will crush you') + attendee_1._save_lines(date_question, '2010-10-10') + attendee_2._save_lines(date_question, '2011-11-11') + attendee_2._save_lines(datetime_question, '2010-10-10 10:00:00') + attendee_3._save_lines(datetime_question, '2011-11-11 15:55:55') + attendee_1._save_lines(simple_choice_question, simple_choice_answer_1.id) + attendee_2._save_lines(simple_choice_question, simple_choice_answer_1.id) + attendee_3._save_lines(simple_choice_question, simple_choice_answer_2.id) + attendee_1._save_lines(scored_choice_question, scored_choice_answer_1.id) + attendee_2._save_lines(scored_choice_question, scored_choice_answer_2.id) + attendee_3._save_lines(scored_choice_question, scored_choice_answer_3.id) + attendee_1._save_lines(timed_scored_choice_question, [timed_scored_choice_answer_1.id, timed_scored_choice_answer_3.id]) - attendee_2.save_lines(timed_scored_choice_question, + attendee_2._save_lines(timed_scored_choice_question, [timed_scored_choice_answer_1.id, timed_scored_choice_answer_2.id]) - attendee_3.save_lines(timed_scored_choice_question, + attendee_3._save_lines(timed_scored_choice_question, [timed_scored_choice_answer_2.id]) with patch('odoo.addons.survey.models.survey_survey.Survey.action_open_session_manager', action_open_session_manager_mock): diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 9cc7a7967f8..9d95b178069 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -103,6 +103,7 @@ if survey.background_image_url else False"/>
+ or press Enter @@ -211,6 +213,7 @@
+