[IMP] survey: add scoring after each page

Purpose
=======
Add a scoring option displaying the correct
answers after each page when taking the survey.

Specifications
==============
When the user submits the page:
- highlight in green the correct answer(s)
- highlight in red the wrong answer(s)
When the correction is displayed, prevent
the user from changing their answers.

Handling all question types that can specify
a correct answer:
- Numerical box
- Date
- Datetime
- Simple choice
- Multiple choice

Task-3374998

closes odoo/odoo#129329

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
amdi-odoo
2023-10-19 17:01:08 +00:00
parent aebe30a91b
commit 7109f480ec
11 changed files with 371 additions and 76 deletions
+25 -17
View File
@@ -460,17 +460,18 @@ class Survey(http.Controller):
@http.route('/survey/begin/<string:survey_token>/<string:answer_token>', 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/<string:survey_token>/<string:answer_token>', 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/<model("survey.survey"):survey>/certification_preview', type="http", auth="user", website=True)
+35
View File
@@ -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
+16 -5
View File
@@ -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
+8 -5
View File
@@ -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)
+96 -19
View File
@@ -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
// -------------------------------------------------------------------------
+1
View File
@@ -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
+35
View File
@@ -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):
+15 -1
View File
@@ -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
@@ -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
+19 -19
View File
@@ -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):
+16 -10
View File
@@ -103,6 +103,7 @@
if survey.background_image_url else False"/>
<form role="form" method="post" t-att-name="survey.id"
class="d-flex flex-grow-1 align-items-center"
t-att-data-scoring-type="survey.scoring_type"
t-att-data-answer-token="answer.access_token"
t-att-data-survey-token="survey.access_token"
t-att-data-users-can-go-back="survey.users_can_go_back and not answer.is_session_answer"
@@ -191,6 +192,7 @@
</t>
<div class="text-center mt16 mb256">
<button id="next_page" t-attf-class="btn #{'btn-secondary' if survey_last else 'btn-primary'} d-none">Next</button>
<button type="submit" value="finish" class="btn btn-secondary disabled">Submit</button>
<span class="fw-bold text-muted ms-2 d-none d-md-inline">
<span id="enter-tooltip">or press Enter</span>
@@ -211,6 +213,7 @@
<div class="col-12 text-center mt16">
<t t-set="submit_value" t-value="'finish' if survey_last or answer.is_session_answer else 'next_skipped'
if answer.survey_first_submitted and skipped_questions.page_id and page in skipped_questions.page_id else 'next'"/>
<button id="next_page" t-attf-class="btn #{'btn-secondary' if survey_last else 'btn-primary'} d-none">Next</button>
<button type="submit" t-att-value="submit_value" t-attf-class="btn #{'btn-secondary' if survey_last else 'btn-primary'} disabled">
<t t-if="submit_value == 'finish'">Submit</t>
<t t-elif="submit_value == 'next_skipped'">Next Skipped</t>
@@ -250,6 +253,7 @@
<div class="col-12 text-center mt16">
<t t-set="submit_value" t-value="'finish' if survey_last or answer.is_session_answer else
'next_skipped' if answer.survey_first_submitted and skipped_questions and question in skipped_questions else 'next'"/>
<button id="next_page" t-attf-class="btn #{'btn-secondary' if survey_last else 'btn-primary'} d-none">Next</button>
<button type="submit" t-att-value="submit_value" t-attf-class="btn #{'btn-secondary' if survey_last else 'btn-primary'} disabled">
<t t-if="submit_value == 'finish'">Submit</t>
<t t-elif="submit_value == 'next_skipped'">Next Skipped</t>
@@ -377,16 +381,18 @@
</template>
<template id="question_numerical_box" name="Question: numerical box">
<input type="number" step="any" class="form-control o_survey_question_numerical_box bg-transparent text-dark rounded-0 p-0"
t-att-name="question.id" t-att-placeholder="question.question_placeholder"
t-att-value="answer_lines[0].value_numerical_box if answer_lines else None"
t-att-data-question-type="question.question_type"
t-att-data-validation-float-min="question.validation_min_float_value if question.validation_required else False"
t-att-data-validation-float-max="question.validation_max_float_value if question.validation_required else False"/>
<div class="o_survey_answer_wrapper p-1 rounded">
<input type="number" step="any" class="form-control o_survey_question_numerical_box bg-transparent text-dark rounded-0 p-0"
t-att-name="question.id" t-att-placeholder="question.question_placeholder"
t-att-value="answer_lines[0].value_numerical_box if answer_lines else None"
t-att-data-question-type="question.question_type"
t-att-data-validation-float-min="question.validation_min_float_value if question.validation_required else False"
t-att-data-validation-float-max="question.validation_max_float_value if question.validation_required else False"/>
</div>
</template>
<template id="question_date" name="Question: date box">
<div class="input-group o_survey_form_date">
<div class="input-group o_survey_form_date o_survey_answer_wrapper p-1 rounded">
<input type="text" class="form-control datetimepicker-input o_survey_question_date bg-transparent text-dark rounded-0 p-0"
t-att-name="question.id" t-att-placeholder="question.question_placeholder"
t-att-value="format_date(answer_lines[0].value_date) if answer_lines else None"
@@ -399,7 +405,7 @@
</template>
<template id="question_datetime" name="Question: datetime box">
<div class="input-group o_survey_form_date">
<div class="input-group o_survey_form_date o_survey_answer_wrapper p-1 rounded">
<input type="text" class="form-control datetimepicker-input o_survey_question_datetime bg-transparent text-dark rounded-0 p-0"
t-att-name="question.id" t-att-placeholder="question.question_placeholder"
t-att-value="format_datetime(answer_lines[0].value_datetime) if answer_lines else None"
@@ -427,7 +433,7 @@
<template id="question_simple_choice" name="Question: simple choice">
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.suggested_answer_id)"/>
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_char_box)"/>
<div class="row g-2 o_survey_form_choice"
<div class="row g-2 o_survey_answer_wrapper o_survey_form_choice"
t-att-data-name="question.id"
t-att-data-is-skipped-question="is_skipped_question or None"
data-question-type="simple_choice_radio">
@@ -496,7 +502,7 @@
<template id="question_multiple_choice" name="Question: multiple choice">
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_char_box)"/>
<div class="row g-2 o_survey_form_choice o_survey_question_multiple_choice"
<div class="row g-2 o_survey_answer_wrapper o_survey_form_choice o_survey_question_multiple_choice"
t-att-data-name="question.id"
t-att-data-question-type="question.question_type">
<t t-set="item_idx" t-value="0"/>