From aa66cdc5969e321663b9d688a2e5309575992fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Thu, 20 Jan 2022 13:00:19 +0000 Subject: [PATCH] [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) --- addons/survey/models/survey_user_input.py | 106 ++++++++++++++++++---- addons/survey/tests/common.py | 9 +- addons/survey/tests/test_survey.py | 78 +++++++++++++++- addons/survey/views/survey_user_views.xml | 85 ++++++++++++----- 4 files changed, 232 insertions(+), 46 deletions(-) diff --git a/addons/survey/models/survey_user_input.py b/addons/survey/models/survey_user_input.py index 5c1ae626a1f..29faf9dc5a8 100644 --- a/addons/survey/models/survey_user_input.py +++ b/addons/survey/models/survey_user_input.py @@ -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: diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index 1103b699422..01db7d8843e 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -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'}] diff --git a/addons/survey/tests/test_survey.py b/addons/survey/tests/test_survey.py index 17d7d3ade00..9ecf4b8574e 100644 --- a/addons/survey/tests/test_survey.py +++ b/addons/survey/tests/test_survey.py @@ -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 """ diff --git a/addons/survey/views/survey_user_views.xml b/addons/survey/views/survey_user_views.xml index eb0d773bd5e..bb4df68ebac 100644 --- a/addons/survey/views/survey_user_views.xml +++ b/addons/survey/views/survey_user_views.xml @@ -6,10 +6,13 @@ survey.user_input.view.search survey.user_input - - - + + + @@ -39,11 +42,27 @@ -
+ +
+ +
+ + + + - + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
+ + + +
@@ -85,16 +117,19 @@ survey.user_input - - + - + +