[IMP] survey: rework survey.user_input views

PURPOSE

Globally improve the main survey.user_input form view to make it more user
friendly.
This includes fields move, new computed fields, a ribbon, ...

SPECIFICATIONS

- Make the Survey User Input model support mail.thread and mail.activity.mixin
  This lets us display the chatter on the form view and allows users to
  schedule activities.
  e.g: discuss a participation among colleagues, add activities to check some
  participation because you think the person has cheated, ...

- Add a ribbon on the form view that says "passed" or "failed" according to the
  user result

- Introduce a new "Answer" column for the questions list view that is a
  modified 'display_name' that displays the answer based on the question type ;
  This allows to see the answers at a quick glance without having to drill down
  every question to look at the "value_char_box", "value_datetime", ...

- Add the attempts count information in a stat-button, when clicked, the user
  is redirected to the list view of all survey attempts of that specific user
  for that specific survey

- Re-organize and move some fields

- Hide some advanced information into debug mode

Task-2729604

closes odoo/odoo#83781

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Aurélien Warnon
2022-05-12 22:13:46 +02:00
parent d90153180d
commit aa66cdc596
4 changed files with 232 additions and 46 deletions
+87 -19
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import textwrap
import uuid
from dateutil.relativedelta import relativedelta
@@ -19,6 +20,7 @@ class SurveyUserInput(models.Model):
_description = "Survey User Input"
_rec_name = "survey_id"
_order = "create_date desc"
_inherit = ['mail.thread', 'mail.activity.mixin']
# answer description
survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='cascade')
@@ -35,12 +37,13 @@ class SurveyUserInput(models.Model):
# 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')
attempts_count = fields.Integer("Attempts Count", compute='_compute_attempts_info')
attempts_number = fields.Integer("Attempt n°", compute='_compute_attempts_info')
survey_time_limit_reached = fields.Boolean("Survey Time Limit Reached", compute='_compute_survey_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)
partner_id = fields.Many2one('res.partner', string='Contact', readonly=True)
email = fields.Char('Email', readonly=True)
nickname = fields.Char('Nickname', help="Attendee nickname, mainly used to identify him in the survey session leaderboard.")
# questions / answers
@@ -116,38 +119,47 @@ class SurveyUserInput(models.Model):
user_input.question_time_limit_reached = False
@api.depends('state', 'test_entry', 'survey_id.is_attempts_limited', 'partner_id', 'email', 'invite_token')
def _compute_attempts_number(self):
def _compute_attempts_info(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_count = 1
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
self.env['survey.user_input'].flush()
self.env.cr.execute("""
SELECT user_input.id,
COUNT(all_attempts_user_input.id) AS attempts_count,
COUNT(CASE WHEN all_attempts_user_input.id < user_input.id THEN all_attempts_user_input.id END) + 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 IS NOT TRUE
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)
LEFT OUTER JOIN survey_user_input all_attempts_user_input
ON user_input.survey_id = all_attempts_user_input.survey_id
AND all_attempts_user_input.state = 'done'
AND all_attempts_user_input.test_entry IS NOT TRUE
AND (user_input.invite_token IS NULL OR user_input.invite_token = all_attempts_user_input.invite_token)
AND (user_input.partner_id = all_attempts_user_input.partner_id OR user_input.email = all_attempts_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()
attempts_number_results = self.env.cr.dictfetchall()
attempts_number_results = {
attempts_number_result['id']: {
'attempts_number': attempts_number_result['attempts_number'],
'attempts_count': attempts_number_result['attempts_count'],
}
for attempts_number_result in attempts_number_results
}
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
attempts_number_result = attempts_number_results.get(user_input.id, {})
user_input.attempts_number = attempts_number_result.get('attempts_number', 1)
user_input.attempts_count = attempts_number_result.get('attempts_count', 1)
@api.model_create_multi
def create(self, vals_list):
@@ -187,6 +199,23 @@ class SurveyUserInput(models.Model):
'url': '/survey/print/%s?answer_token=%s' % (self.survey_id.access_token, self.access_token)
}
def action_redirect_to_attempts(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('survey.action_survey_user_input')
context = dict(self.env.context or {})
context['create'] = False
context['search_default_survey_id'] = self.survey_id.id
context['search_default_group_by_survey'] = False
if self.partner_id:
context['search_default_partner_id'] = self.partner_id.id
elif self.email:
context['search_default_email'] = self.email
action['context'] = context
return action
@api.model
def _generate_invite_token(self):
return str(uuid.uuid4())
@@ -562,6 +591,21 @@ class SurveyUserInput(models.Model):
inactive_questions = self._get_inactive_conditional_questions()
return survey.question_ids - inactive_questions
# ------------------------------------------------------------
# MESSAGING
# ------------------------------------------------------------
def _message_get_suggested_recipients(self):
recipients = super()._message_get_suggested_recipients()
for user_input in self:
if user_input.partner_id:
user_input._message_add_suggested_recipient(
recipients,
partner=user_input.partner_id,
reason=_('Survey Participant')
)
return recipients
class SurveyUserInputLine(models.Model):
_name = 'survey.user_input.line'
@@ -595,6 +639,30 @@ class SurveyUserInputLine(models.Model):
answer_score = fields.Float('Score')
answer_is_correct = fields.Boolean('Correct')
@api.depends('answer_type')
def _compute_display_name(self):
for line in self:
if line.answer_type == 'char_box':
line.display_name = line.value_char_box
elif line.answer_type == 'text_box' and line.value_text_box:
line.display_name = textwrap.shorten(line.value_text_box, width=50, placeholder=" [...]")
elif line.answer_type == 'numerical_box':
line.display_name = line.value_numerical_box
elif line.answer_type == 'date':
line.display_name = fields.Date.to_string(line.value_date)
elif line.answer_type == 'datetime':
line.display_name = fields.Datetime.to_string(line.value_datetime)
elif line.answer_type == 'suggestion':
if line.matrix_row_id:
line.display_name = '%s: %s' % (
line.suggested_answer_id.value,
line.matrix_row_id.value)
else:
line.display_name = line.suggested_answer_id.value
if not line.display_name:
line.display_name = _('Skipped')
@api.constrains('skipped', 'answer_type')
def _check_answer_type_skipped(self):
for line in self:
+8 -1
View File
@@ -24,6 +24,7 @@ class SurveyCase(common.TransactionCase):
'char_box': ('char_box', 'value_char_box'),
'numerical_box': ('numerical_box', 'value_numerical_box'),
'date': ('date', 'value_date'),
'datetime': ('datetime', 'value_datetime'),
'simple_choice': ('suggestion', 'suggested_answer_id'), # TDE: still unclear
'multiple_choice': ('suggestion', 'suggested_answer_id'), # TDE: still unclear
'matrix': ('suggestion', ('suggested_answer_id', 'matrix_row_id')), # TDE: still unclear
@@ -135,6 +136,8 @@ class SurveyCase(common.TransactionCase):
qtype = self._type_match.get(question.question_type, (False, False))
answer_type = kwargs.pop('answer_type', qtype[0])
answer_fname = kwargs.pop('answer_fname', qtype[1])
if question.question_type == 'matrix':
answer_fname = qtype[1][0]
base_alvals = {
'user_input_id': answer.id,
@@ -143,6 +146,10 @@ class SurveyCase(common.TransactionCase):
'answer_type': answer_type,
}
base_alvals[answer_fname] = answer_value
if 'answer_value_row' in kwargs:
answer_value_row = kwargs.pop('answer_value_row')
base_alvals[qtype[1][1]] = answer_value_row
base_alvals.update(kwargs)
return self.env['survey.user_input.line'].create(base_alvals)
@@ -238,7 +245,7 @@ class SurveyCase(common.TransactionCase):
if question_type == 'multiple_choice':
kwargs['labels'] = [{'value': 'MChoice0'}, {'value': 'MChoice1'}]
elif question_type == 'simple_choice':
kwargs['labels'] = []
kwargs['labels'] = [{'value': 'SChoice0'}, {'value': 'SChoice1'}]
elif question_type == 'matrix':
kwargs['labels'] = [{'value': 'Column0'}, {'value': 'Column1'}]
kwargs['labels_2'] = [{'value': 'Row0'}, {'value': 'Row1'}]
+77 -1
View File
@@ -1,13 +1,89 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _
from freezegun import freeze_time
from odoo import _, fields
from odoo.addons.survey.tests import common
from odoo.tests.common import users
class TestSurveyInternals(common.TestSurveyCommon):
def test_answer_attempts_count(self):
""" As 'attempts_number' and 'attempts_count' are computed using raw SQL queries, let us
test the results. """
test_survey = self.env['survey.survey'].create({
'title': 'Test Survey',
'is_attempts_limited': True,
'attempts_limit': 4,
})
all_attempts = self.env['survey.user_input']
for _i in range(4):
all_attempts |= self._add_answer(test_survey, self.survey_user.partner_id, state='done')
# read both fields at once to allow computing their values in batch
attempts_results = all_attempts.read(['attempts_number', 'attempts_count'])
first_attempt = attempts_results[0]
second_attempt = attempts_results[1]
third_attempt = attempts_results[2]
fourth_attempt = attempts_results[3]
self.assertEqual(first_attempt['attempts_number'], 1)
self.assertEqual(first_attempt['attempts_count'], 4)
self.assertEqual(second_attempt['attempts_number'], 2)
self.assertEqual(second_attempt['attempts_count'], 4)
self.assertEqual(third_attempt['attempts_number'], 3)
self.assertEqual(third_attempt['attempts_count'], 4)
self.assertEqual(fourth_attempt['attempts_number'], 4)
self.assertEqual(fourth_attempt['attempts_count'], 4)
@freeze_time("2020-02-15 18:00")
def test_answer_display_name(self):
""" The "display_name" field in a survey.user_input.line is a computed field that will
display the answer label for any type of question.
Let us test the various question types. """
questions = self._create_one_question_per_type()
user_input = self._add_answer(self.survey, self.survey_user.partner_id)
for question in questions:
if question.question_type == 'char_box':
question_answer = self._add_answer_line(question, user_input, 'Char box answer')
self.assertEqual(question_answer.display_name, 'Char box answer')
elif question.question_type == 'text_box':
question_answer = self._add_answer_line(question, user_input, 'Text box answer')
self.assertEqual(question_answer.display_name, 'Text box answer')
elif question.question_type == 'numerical_box':
question_answer = self._add_answer_line(question, user_input, 7)
self.assertEqual(question_answer.display_name, '7.0')
elif question.question_type == 'date':
question_answer = self._add_answer_line(question, user_input, fields.Datetime.now())
self.assertEqual(question_answer.display_name, '2020-02-15')
elif question.question_type == 'datetime':
question_answer = self._add_answer_line(question, user_input, fields.Datetime.now())
self.assertEqual(question_answer.display_name, '2020-02-15 18:00:00')
elif question.question_type == 'simple_choice':
question_answer = self._add_answer_line(question, user_input, question.suggested_answer_ids[0].id)
self.assertEqual(question_answer.display_name, 'SChoice0')
elif question.question_type == 'multiple_choice':
question_answer_1 = self._add_answer_line(question, user_input, question.suggested_answer_ids[0].id)
self.assertEqual(question_answer_1.display_name, 'MChoice0')
question_answer_2 = self._add_answer_line(question, user_input, question.suggested_answer_ids[1].id)
self.assertEqual(question_answer_2.display_name, 'MChoice1')
elif question.question_type == 'matrix':
question_answer_1 = self._add_answer_line(question, user_input,
question.suggested_answer_ids[0].id, **{'answer_value_row': question.matrix_row_ids[0].id})
self.assertEqual(question_answer_1.display_name, 'Column0: Row0')
question_answer_2 = self._add_answer_line(question, user_input,
question.suggested_answer_ids[0].id, **{'answer_value_row': question.matrix_row_ids[1].id})
self.assertEqual(question_answer_2.display_name, 'Column0: Row1')
@users('survey_manager')
def test_answer_validation_mandatory(self):
""" For each type of question check that mandatory questions correctly check for complete answers """
+60 -25
View File
@@ -6,10 +6,13 @@
<field name="name">survey.user_input.view.search</field>
<field name="model">survey.user_input</field>
<field name="arch" type="xml">
<search string="Search Survey">
<field name="survey_id"/>
<field name="email"/>
<search string="Search Survey User Inputs">
<field name="email" string="Participant" filter_domain="[
'|',
('partner_id', 'ilike', self),
('email', 'ilike', self)]"/>
<field name="partner_id"/>
<field name="survey_id"/>
<filter name="completed" string="Completed" domain="[('state', '=', 'done')]"/>
<filter string="In Progress" name="in_progress" domain="[('state', '=', 'in_progress')]"/>
<filter string="New" name="new" domain="[('state', '=', 'new')]"/>
@@ -39,11 +42,27 @@
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box"/>
<field name="attempts_count" invisible="1"/>
<div class="oe_button_box" name="button_box">
<button name="action_redirect_to_attempts"
type="object"
class="oe_stat_button"
attrs="{'invisible': [('attempts_count', '=', 1)]}"
icon="fa-files-o">
<field string="Attempts" name="attempts_count" widget="statinfo"/>
</button>
</div>
<widget name="web_ribbon" title="Test Entry" bg_color="bg-info"
attrs="{'invisible': [('test_entry', '!=', True)]}"/>
<widget name="web_ribbon" title="Failed" bg_color="bg-danger"
attrs="{'invisible': ['|', '|', ('test_entry', '=', True), ('scoring_type', '=', 'no_scoring'), ('scoring_success', '=', True)]}"/>
<widget name="web_ribbon" title="Passed"
attrs="{'invisible': ['|', '|', ('test_entry', '=', True), ('scoring_type', '=', 'no_scoring'), ('scoring_success', '=', False)]}"/>
<group col="2">
<group>
<field name="survey_id"/>
<field name="create_date"/>
<field name="deadline" attrs="{'invisible': [('state', '=', 'done'), ('deadline', '=', False)]}"/>
<field name="is_attempts_limited" invisible="1"/>
<label for="attempts_number" string="Attempt n°" attrs="{'invisible': ['|', ('is_attempts_limited', '=', False), '|', ('test_entry', '=', True), ('state', '!=', 'done')]}"/>
<div attrs="{'invisible': ['|', ('is_attempts_limited', '=', False), '|', ('test_entry', '=', True), ('state', '!=', 'done')]}">
@@ -51,31 +70,44 @@
/
<field name="attempts_limit" nolabel="1" />
</div>
<field name="access_token" groups="base.group_no_one"/>
<field name="test_entry" groups="base.group_no_one"/>
</group>
<group>
<field name="deadline"/>
<field name="scoring_type" invisible="1"/>
<field name="scoring_success" invisible="1"/>
<label for="scoring_percentage" string="Score" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}"/>
<div attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}">
<field name="scoring_percentage" nolabel="1"/>
<span>%</span>
</div>
<field name="partner_id"/>
<field name="email" widget="email"/>
<field name="test_entry" groups="base.group_no_one"/>
<field name="scoring_type" invisible="1"/>
<field name="scoring_percentage" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}"/>
<field name="scoring_success" attrs="{'invisible': [('scoring_type', '=', 'no_scoring')]}"/>
<field name="access_token" groups="base.group_no_one"/>
</group>
</group>
<field name="user_input_line_ids" mode="tree" attrs="{'readonly': True}">
<tree>
<field name="question_sequence" invisible="1"/>
<field name="question_id"/>
<field name="page_id"/>
<field name="answer_type"/>
<field name="skipped"/>
<field name="create_date"/>
<field name="answer_is_correct"/>
<field name="answer_score"/>
</tree>
</field>
<notebook>
<page string="Answers">
<field name="user_input_line_ids" mode="tree" attrs="{'readonly': True}" no_label="1">
<tree decoration-muted="skipped == True">
<field name="question_sequence" invisible="1"/>
<field name="create_date" optional="hidden"/>
<field name="page_id" optional="hidden"/>
<field name="question_id"/>
<field name="answer_type" optional="hidden"/>
<field name="skipped" hide="1"/>
<field name="display_name" string="Answer"/>
<field name="answer_is_correct"/>
<field name="answer_score" sum="Score"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
@@ -85,16 +117,19 @@
<field name="model">survey.user_input</field>
<field name="arch" type="xml">
<tree string="Survey User inputs" decoration-muted="test_entry == True" create="false">
<field name="survey_id"/>
<field name="create_date"/>
<field name="deadline"/>
<field name="survey_id"/>
<field name="partner_id"/>
<field name="email"/>
<field name="attempts_number"/>
<field name="state"/>
<field name="deadline"/>
<field name="test_entry" invisible="True"/>
<field name="scoring_success"/>
<field name="scoring_percentage"/>
<field name="state" widget="badge"
decoration-success="state == 'done'"
decoration-warning="state == 'new'"
decoration-info="state == 'in_progress'"/>
</tree>
</field>
</record>