Files
odoo_source/addons/survey/models/survey_user.py
T
David BeguinandAurélien Warnon 4a7c84e696 [REF] survey: use ajax instead of redirection for screen transitions
PURPOSE

To have a better UX, the transition between questions in a survey should not
be done by loading another page, but by loading next questions in AJAX.

This will allow to fade out / fade in the next question and improve the user
experience, as well as prepare the work for the complete survey redesign.

SPECIFICATIONS

General
=======

This commit refactors the way the questions are loaded.
To go to next question(s), instead of redirecting to a new page containing
the next question(s), the submit process is loading the next question(s)
using ajax. The template of the question is prepared at server side and is
given in html string to the client that display the next question in a smooth
transition (fade in / fade out).

This commit includes also the start and the end screen of the survey inside
the survey form in order to perform smooth transition between start screen
and first question(s) and between last question(s) and end screen.

Only one master template (survey form) is now used to display every part of
the survey : start + questions + end screens.

The answer_done error is now handled by the _prepare_survey_data itself.
If the user wants to display an already_done survey, the returned template
is the end screen.

This commit handles page transition for the survey results. When submitting
survey, the survey result widget is initialized and attached to the survey
result section.

A new route has been added : /survey/begin/.
If the user arrives on start screen, the state is 'New'. Once the user clicks
on Start button, an rpc call is made to survey/begin that will call the
survey_prepare_data to render the first question page and set the state to
in_progress and the start_datetime of the answer.

Validation
==========

Add the validation of all types of questions directly in the frontend.
This allows to reduce the latency before the survey is validated and was
necessary to go along with our new fade out / fade in mechanism.
The client does not need to wait the validation answer from the server to
display validation error messages.

The current questions are only faded out if the frontend validation has passed.
The server still makes this validation before submitting answers and loading
next question(s) to avoid direct rpc calls that would mess up the answers.

Error messages are now displayed with a slide down transition, for a wonderful
wow effect !

Timer
=====

As the survey form works with ajax to display start screen, question(s) and
end page, the timer has to be started only after the start screen.

This commit display and start the timer only after start survey button is
clicked.

Breadcrumb
==========

As the survey form now works in ajax to display in same page start screen,
question(s) and end page screen, the breadcrumb has to be controlled not only
on page loading but also manually between transition from a screen to another.

To ease the breadcrumb management, breadcrumb is now a widget that will reload
a js template, mainly depending of the current page id.
The js form initialize or update this widget giving this new page id.
On breadcrumb item click, the widget triggers a onclick caught by the form
that calls the submit with the given target.

This commit handles page transition for the breadcrumb. When going forward or
backward (next or previous page), the breadcrumb activates, deactivates and
links elements are reinitialized depending of the target page.

LINKS

Task ID: 2152223
PR #41453

Co-authored-by: Aurélien Warnon <awa@odoo.com>
2020-01-13 15:50:14 +00:00

393 lines
18 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import uuid
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class SurveyUserInput(models.Model):
""" Metadata for a set of one user's answers to a particular survey """
_name = "survey.user_input"
_rec_name = 'survey_id'
_description = 'Survey User Input'
# answer description
survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='cascade')
scoring_type = fields.Selection(string="Scoring", related="survey_id.scoring_type")
start_datetime = fields.Datetime('Start date and time', readonly=True)
deadline = fields.Datetime('Deadline', help="Datetime until customer can open the survey and submit answers")
state = fields.Selection([
('new', 'Not started yet'),
('in_progress', 'In Progress'),
('done', 'Completed')], string='Status', default='new', readonly=True)
test_entry = fields.Boolean(readonly=True)
last_displayed_page_id = fields.Many2one('survey.question', string='Last displayed question/page')
# attempts management
is_attempts_limited = fields.Boolean("Limited number of attempts", related='survey_id.is_attempts_limited')
attempts_limit = fields.Integer("Number of attempts", related='survey_id.attempts_limit')
attempts_number = fields.Integer("Attempt n°", compute='_compute_attempts_number')
is_time_limit_reached = fields.Boolean("Is time limit reached?", compute='_compute_is_time_limit_reached')
# identification / access
access_token = fields.Char('Identification token', default=lambda self: str(uuid.uuid4()), readonly=True, required=True, copy=False)
invite_token = fields.Char('Invite token', readonly=True, copy=False) # no unique constraint, as it identifies a pool of attempts
partner_id = fields.Many2one('res.partner', string='Partner', readonly=True)
email = fields.Char('Email', readonly=True)
# questions / answers
user_input_line_ids = fields.One2many('survey.user_input.line', 'user_input_id', string='Answers', copy=True)
predefined_question_ids = fields.Many2many('survey.question', string='Predefined Questions', readonly=True)
scoring_percentage = fields.Float("Score (%)", compute="_compute_scoring_percentage", store=True, compute_sudo=True) # stored for perf reasons
scoring_success = fields.Boolean('Quizz Passed', compute='_compute_scoring_success', store=True, compute_sudo=True) # stored for perf reasons
_sql_constraints = [
('unique_token', 'UNIQUE (access_token)', 'An access token must be unique!'),
]
@api.depends('user_input_line_ids.answer_score', 'user_input_line_ids.question_id')
def _compute_scoring_percentage(self):
for user_input in self:
total_possible_score = sum([
answer_score if answer_score > 0 else 0
for answer_score in user_input.predefined_question_ids.mapped('suggested_answer_ids.answer_score')
])
if total_possible_score == 0:
user_input.scoring_percentage = 0
else:
score = (sum(user_input.user_input_line_ids.mapped('answer_score')) / total_possible_score) * 100
user_input.scoring_percentage = round(score, 2) if score > 0 else 0
@api.depends('scoring_percentage', 'survey_id.scoring_success_min')
def _compute_scoring_success(self):
for user_input in self:
user_input.scoring_success = user_input.scoring_percentage >= user_input.survey_id.scoring_success_min
@api.depends('start_datetime', 'survey_id.is_time_limited', 'survey_id.time_limit')
def _compute_is_time_limit_reached(self):
""" Checks that the user_input is not exceeding the survey's time limit. """
for user_input in self:
if user_input.start_datetime and user_input.survey_id.is_time_limited:
datetime_limit = user_input.start_datetime + relativedelta(minutes=user_input.survey_id.time_limit)
user_input.is_time_limit_reached = fields.Datetime.now() > datetime_limit
else:
user_input.is_time_limit_reached = False
@api.depends('state', 'test_entry', 'survey_id.is_attempts_limited', 'partner_id', 'email', 'invite_token')
def _compute_attempts_number(self):
attempts_to_compute = self.filtered(
lambda user_input: user_input.state == 'done' and not user_input.test_entry and user_input.survey_id.is_attempts_limited
)
for user_input in (self - attempts_to_compute):
user_input.attempts_number = 1
if attempts_to_compute:
self.env.cr.execute("""SELECT user_input.id, (COUNT(previous_user_input.id) + 1) AS attempts_number
FROM survey_user_input user_input
LEFT OUTER JOIN survey_user_input previous_user_input
ON user_input.survey_id = previous_user_input.survey_id
AND previous_user_input.state = 'done'
AND previous_user_input.test_entry = False
AND previous_user_input.id < user_input.id
AND (user_input.invite_token IS NULL OR user_input.invite_token = previous_user_input.invite_token)
AND (user_input.partner_id = previous_user_input.partner_id OR user_input.email = previous_user_input.email)
WHERE user_input.id IN %s
GROUP BY user_input.id;
""", (tuple(attempts_to_compute.ids),))
attempts_count_results = self.env.cr.dictfetchall()
for user_input in attempts_to_compute:
attempts_number = 1
for attempts_count_result in attempts_count_results:
if attempts_count_result['id'] == user_input.id:
attempts_number = attempts_count_result['attempts_number']
break
user_input.attempts_number = attempts_number
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if 'predefined_question_ids' not in vals:
suvey_id = vals.get('survey_id', self.env.context.get('default_survey_id'))
survey = self.env['survey.survey'].browse(suvey_id)
vals['predefined_question_ids'] = [(6, 0, survey._prepare_user_input_predefined_questions().ids)]
return super(SurveyUserInput, self).create(vals_list)
# ------------------------------------------------------------
# ACTIONS / BUSINESS
# ------------------------------------------------------------
def action_resend(self):
partners = self.env['res.partner']
emails = []
for user_answer in self:
if user_answer.partner_id:
partners |= user_answer.partner_id
elif user_answer.email:
emails.append(user_answer.email)
return self.survey_id.with_context(
default_existing_mode='resend',
default_partner_ids=partners.ids,
default_emails=','.join(emails)
).action_send_survey()
def action_print_answers(self):
""" Open the website page with the survey form """
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': "View Answers",
'target': 'self',
'url': '/survey/print/%s?answer_token=%s' % (self.survey_id.access_token, self.access_token)
}
@api.model
def _generate_invite_token(self):
return str(uuid.uuid4())
def _mark_done(self):
""" This method will:
1. mark the state as 'done'
2. send the certification email with attached document if
- The survey is a certification
- It has a certification_mail_template_id set
- The user succeeded the test
Will also run challenge Cron to give the certification badge if any."""
self.write({'state': 'done'})
Challenge = self.env['gamification.challenge'].sudo()
badge_ids = []
for user_input in self:
if user_input.survey_id.certification and user_input.scoring_success:
if user_input.survey_id.certification_mail_template_id and not user_input.test_entry:
user_input.survey_id.certification_mail_template_id.send_mail(user_input.id, notif_layout="mail.mail_notification_light")
if user_input.survey_id.certification_give_badge:
badge_ids.append(user_input.survey_id.certification_badge_id.id)
if badge_ids:
challenges = Challenge.search([('reward_id', 'in', badge_ids)])
if challenges:
Challenge._cron_update(ids=challenges.ids, commit=False)
def get_start_url(self):
self.ensure_one()
return '%s?answer_token=%s' % (self.survey_id.get_start_url(), self.access_token)
def get_print_url(self):
self.ensure_one()
return '%s?answer_token=%s' % (self.survey_id.get_print_url(), self.access_token)
# ------------------------------------------------------------
# CREATE / UPDATE LINES FROM SURVEY FRONTEND INPUT
# ------------------------------------------------------------
def save_lines(self, question, answer, comment=None):
""" 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).
"""
old_answers = self.env['survey.user_input.line'].search([
('user_input_id', '=', self.id),
('question_id', '=', question.id)
])
if question.question_type in ['char_box', 'text_box', 'numerical_box', 'date', 'datetime']:
self._save_line_simple_answer(question, old_answers, answer)
if question.save_as_email and answer:
self.write({'email': answer})
elif question.question_type in ['simple_choice', 'multiple_choice']:
self._save_line_choice(question, old_answers, answer, comment)
elif question.question_type == 'matrix':
self._save_line_matrix(question, old_answers, answer, comment)
else:
raise AttributeError(question.question_type + ": This type of question has no saving function")
def _save_line_simple_answer(self, question, old_answers, answer):
vals = self._get_line_answer_values(question, answer, question.question_type)
if old_answers:
old_answers.write(vals)
return old_answers
else:
return self.env['survey.user_input.line'].create(vals)
def _save_line_choice(self, question, old_answers, answers, comment):
if not (isinstance(answers, list)):
answers = [answers]
vals_list = []
if question.question_type == 'simple_choice':
if not question.comment_count_as_answer or not question.comments_allowed or not comment:
vals_list = [self._get_line_answer_values(question, answer, 'suggestion') for answer in answers]
elif question.question_type == 'multiple_choice':
vals_list = [self._get_line_answer_values(question, answer, 'suggestion') for answer in answers]
if comment:
vals_list.append(self._get_line_comment_values(question, comment))
old_answers.sudo().unlink()
return self.env['survey.user_input.line'].create(vals_list)
def _save_line_matrix(self, question, old_answers, answers, comment):
vals_list = []
if answers:
for row_key, row_answer in answers.items():
for answer in row_answer:
vals = self._get_line_answer_values(question, answer, 'suggestion')
vals['matrix_row_id'] = row_key
vals_list.append(vals.copy())
if comment:
vals_list.append(self._get_line_comment_values(question, comment))
old_answers.sudo().unlink()
return self.env['survey.user_input.line'].create(vals_list)
def _get_line_answer_values(self, question, answer, answer_type):
vals = {
'user_input_id': self.id,
'question_id': question.id,
'skipped': False,
'answer_type': answer_type,
}
if not answer or (isinstance(answer, str) and not answer.strip()):
vals.update(answer_type=None, skipped=True)
return vals
if answer_type == 'suggestion':
vals['suggested_answer_id'] = int(answer)
elif answer_type == 'numerical_box':
vals['value_numerical_box'] = float(answer)
else:
vals['value_%s' % answer_type] = answer
return vals
def _get_line_comment_values(self, question, comment):
return {
'user_input_id': self.id,
'question_id': question.id,
'skipped': False,
'answer_type': 'char_box',
'value_char_box': comment,
}
# ------------------------------------------------------------
# STATISTICS / RESULTS
# ------------------------------------------------------------
def _prepare_statistics(self):
res = dict((user_input, {
'correct': 0,
'incorrect': 0,
'partial': 0,
'skipped': 0,
}) for user_input in self)
scored_questions = self.mapped('predefined_question_ids').filtered(
lambda question: question.question_type in ['simple_choice', 'multiple_choice']
)
for question in scored_questions:
question_answer_correct = question.suggested_answer_ids.filtered(lambda answer: answer.is_correct)
for user_input in self:
user_answer_lines_question = user_input.user_input_line_ids.filtered(lambda line: line.question_id == question)
user_answer_correct = user_answer_lines_question.filtered(lambda line: line.answer_is_correct and not line.skipped).mapped('suggested_answer_id')
user_answer_incorrect = user_answer_lines_question.filtered(lambda line: not line.answer_is_correct and not line.skipped)
if user_answer_correct == question_answer_correct:
res[user_input]['correct'] += 1
elif user_answer_correct and user_answer_correct < question_answer_correct:
res[user_input]['partial'] += 1
if not user_answer_correct and user_answer_incorrect:
res[user_input]['incorrect'] += 1
if not user_answer_correct and not user_answer_incorrect:
res[user_input]['skipped'] += 1
return [[
{'text': _("Correct"), 'count': res[user_input]['correct']},
{'text': _("Partially"), 'count': res[user_input]['partial']},
{'text': _("Incorrect"), 'count': res[user_input]['incorrect']},
{'text': _("Unanswered"), 'count': res[user_input]['skipped']}
] for user_input in self]
class SurveyUserInputLine(models.Model):
_name = 'survey.user_input.line'
_description = 'Survey User Input Line'
_rec_name = 'user_input_id'
_order = 'question_sequence, id'
# survey data
user_input_id = fields.Many2one('survey.user_input', string='User Input', ondelete='cascade', required=True)
survey_id = fields.Many2one(related='user_input_id.survey_id', string='Survey', store=True, readonly=False)
question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade', required=True)
page_id = fields.Many2one(related='question_id.page_id', string="Section", readonly=False)
question_sequence = fields.Integer('Sequence', related='question_id.sequence', store=True)
# answer
skipped = fields.Boolean('Skipped')
answer_type = fields.Selection([
('text_box', 'Free Text'),
('char_box', 'Text'),
('numerical_box', 'Number'),
('date', 'Date'),
('datetime', 'Datetime'),
('suggestion', 'Suggestion')], string='Answer Type')
value_char_box = fields.Char('Text answer')
value_numerical_box = fields.Float('Numerical answer')
value_date = fields.Date('Date answer')
value_datetime = fields.Datetime('Datetime answer')
value_text_box = fields.Text('Free Text answer')
suggested_answer_id = fields.Many2one('survey.question.answer', string="Suggested answer")
matrix_row_id = fields.Many2one('survey.question.answer', string="Row answer")
# scoring
answer_score = fields.Float('Score')
answer_is_correct = fields.Boolean('Correct', compute='_compute_answer_is_correct')
@api.depends('suggested_answer_id', 'question_id')
def _compute_answer_is_correct(self):
for answer in self:
if answer.suggested_answer_id and answer.question_id.question_type in ['simple_choice', 'multiple_choice']:
answer.answer_is_correct = answer.suggested_answer_id.is_correct
else:
answer.answer_is_correct = False
@api.constrains('skipped', 'answer_type')
def _check_answered_or_skipped(self):
if any(line.skipped == bool(line.answer_type) for line in self):
raise ValidationError(_('A question is either skipped, either answered. Not both.'))
@api.constrains('answer_type')
def _check_answer_type(self):
for line in self:
if line.answer_type == 'suggestion':
field_name = 'suggested_answer_id'
elif line.answer_type:
field_name = 'value_%s' % line.answer_type
else:
field_name = False
if field_name and not line[field_name]:
raise ValidationError(_('The answer must be in the right type'))
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
suggested_answer_id = vals.get('suggested_answer_id')
if suggested_answer_id:
vals.update({'answer_score': self.env['survey.question.answer'].browse(int(suggested_answer_id)).answer_score})
return super(SurveyUserInputLine, self).create(vals_list)
def write(self, vals):
suggested_answer_id = vals.get('suggested_answer_id')
if suggested_answer_id:
vals.update({'answer_score': self.env['survey.question.answer'].browse(int(suggested_answer_id)).answer_score})
return super(SurveyUserInputLine, self).write(vals)