[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:
@@ -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:
|
||||
|
||||
@@ -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'}]
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user