[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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user