diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index 5db49bbc9c5..e21f4699d42 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -49,6 +49,7 @@ sent mails with personal token for the invitation of the survey. 'data/survey_demo_certification.xml', 'data/survey_demo_quiz.xml', 'data/survey_demo_quiz_userinput.xml', + 'data/survey_demo_conditional.xml', 'data/survey.user_input.line.csv' ], 'installable': True, diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 1c07cb9a715..3b9b29a9036 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -275,6 +275,19 @@ class Survey(http.Controller): 'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False), 'format_date': lambda date: format_date(request.env, date) } + if survey_sudo.questions_layout != 'page_per_question': + triggering_answer_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values() + data.update({ + 'triggering_answer_by_question': { + question.id: triggering_answer_by_question[question].id for question in triggering_answer_by_question.keys() + if triggering_answer_by_question[question] + }, + 'triggered_questions_by_answer': { + answer.id: [ + triggered_questions_by_answer[answer].ids + ] for answer in triggered_questions_by_answer.keys()}, + 'selected_answers': selected_answers.ids + }) if not answer_sudo.is_session_answer and survey_sudo.is_time_limited and answer_sudo.start_datetime: data.update({ @@ -287,7 +300,7 @@ class Survey(http.Controller): # Bypass all if page_id is specified (comes from breadcrumb or previous button) if 'previous_page_id' in post: previous_page_or_question_id = int(post['previous_page_id']) - new_previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, previous_page_or_question_id) + new_previous_id = survey_sudo._get_next_page_or_question(answer_sudo, previous_page_or_question_id, go_back=True).id data.update({ page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id), 'previous_page_id': new_previous_id, @@ -297,28 +310,31 @@ class Survey(http.Controller): if answer_sudo.state == 'in_progress': if answer_sudo.is_session_answer: - page_or_question_id, is_last = survey_sudo.session_question_id, False + next_page_or_question = survey_sudo.session_question_id else: - page_or_question_id, is_last = survey_sudo.next_page_or_question( + next_page_or_question = survey_sudo._get_next_page_or_question( answer_sudo, answer_sudo.last_displayed_page_id.id if answer_sudo.last_displayed_page_id else 0) - if answer_sudo.is_session_answer and page_or_question_id.is_time_limited: + if next_page_or_question: + pages_or_questions = survey_sudo._get_pages_or_questions(answer_sudo) + if pages_or_questions.ids.index(next_page_or_question.id) == len(pages_or_questions) - 1: + data.update({'survey_last': True}) + + if answer_sudo.is_session_answer and next_page_or_question.is_time_limited: data.update({ 'timer_start': survey_sudo.session_question_start_time.isoformat(), - 'time_limit_minutes': page_or_question_id.time_limit / 60 + 'time_limit_minutes': next_page_or_question.time_limit / 60 }) data.update({ - page_or_question_key: page_or_question_id, - 'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id == page_or_question_id) + page_or_question_key: next_page_or_question, + 'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id == next_page_or_question) }) if survey_sudo.questions_layout != 'one_page': data.update({ - 'previous_page_id': survey_sudo._previous_page_or_question_id(answer_sudo, page_or_question_id.id) + 'previous_page_id': survey_sudo._get_next_page_or_question(answer_sudo, next_page_or_question.id, go_back=True).id }) - if is_last: - data.update({'last': True}) elif answer_sudo.state == 'done' or answer_sudo.survey_time_limit_reached: # Display success message return self._prepare_survey_finished_values(survey_sudo, answer_sudo) @@ -444,7 +460,10 @@ class Survey(http.Controller): errors = {} # Prepare answers / comment by question, validate and save answers + inactive_questions = request.env['survey.question'] if answer_sudo.is_session_answer else answer_sudo._get_inactive_conditional_questions() for question in questions: + if question in inactive_questions: # if question is inactive, skip validation and save + continue 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): @@ -453,6 +472,9 @@ class Survey(http.Controller): if errors and not (answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached): return {'error': 'validation', 'fields': errors} + if not answer_sudo.is_session_answer: + answer_sudo._clear_inactive_conditional_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: @@ -461,8 +483,8 @@ class Survey(http.Controller): else: vals = {'last_displayed_page_id': page_or_question_id} if not answer_sudo.is_session_answer: - next_page, unused = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id) - if next_page is None: + next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id) + if not next_page: answer_sudo._mark_done() answer_sudo.write(vals) diff --git a/addons/survey/controllers/survey_session_manage.py b/addons/survey/controllers/survey_session_manage.py index 929ac7e4819..ad7154b8753 100644 --- a/addons/survey/controllers/survey_session_manage.py +++ b/addons/survey/controllers/survey_session_manage.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import datetime import json +from dateutil.relativedelta import relativedelta from werkzeug.exceptions import NotFound from werkzeug.urls import url_join -from odoo import http +from odoo import fields, http from odoo.http import request @@ -57,10 +59,25 @@ class UserInputSession(http.Controller): if survey.session_state == 'ready': survey._session_open() - survey._session_trigger_next_question() - template_values = self._prepare_manage_session_values(survey) - template_values['is_rpc_call'] = True - return request.env.ref('survey.user_input_session_manage_content').render(template_values) + next_question = survey._get_session_next_question() + + # using datetime.datetime because we want the millis portion + if next_question: + now = datetime.datetime.now() + survey.sudo().write({ + 'session_question_id': next_question.id, + 'session_question_start_time': fields.Datetime.now() + relativedelta(seconds=2) + }) + request.env['bus.bus'].sendone(survey.access_token, { + 'question_start': now.timestamp(), + 'type': 'next_question' + }) + + template_values = self._prepare_manage_session_values(survey) + template_values['is_rpc_call'] = True + return request.env.ref('survey.user_input_session_manage_content').render(template_values) + else: + return False @http.route('/survey/session/results/', type='json', auth='user', website=True) def survey_session_results(self, survey_token, **kwargs): diff --git a/addons/survey/data/survey_demo_conditional.xml b/addons/survey/data/survey_demo_conditional.xml new file mode 100644 index 00000000000..02298e082a6 --- /dev/null +++ b/addons/survey/data/survey_demo_conditional.xml @@ -0,0 +1,474 @@ + + + + + Burger Quiz + burger00-quiz-1234-abcd-344ca2tgb31e + open + public + + scoring_with_answers + 55 + limited + 10.0 + page_per_question + +

Choose your favourite subject and show how good you are. Ready ?

+ +
+ + + + Start + + 1 + + + + + + 2 + Pick a subject + multiple_choice + + + + + 1 + Geography + + + + 2 + History + + + + 3 + Sciences + + + + 4 + Art & Culture + + + + + Geography + + 100 + + + + + + 110 + How long is the White Nile river? + simple_choice + + + + + + + + 1 + 1450 km + + + + 2 + 3700 km + + 10 + + + + 3 + 6650 km + + + + + + 120 + What is the biggest city in the world ? + simple_choice + + + + + + + + 1 + Shanghai + + + + 2 + Tokyo + + 10 + + + + 3 + New York + + + + 4 + Istanbul + + + + + 130 + What is the highest volcano in Europe ? + simple_choice + + + + + + + + 1 + Mount Teide (Spain - Tenerife) + + + + 2 + Eyjafjallajökull (Iceland) + + + + 3 + Mount Etna (Italy - Sicily) + + + + 4 + Mount Elbrus (Russia) + + 10 + + + + + History + + 200 + + + + + + 210 + When did Genghis Khan die ? + simple_choice + + + + + + + + 1 + 1227 + + 10 + + + + 2 + 1324 + + + + 3 + 1055 + + + + + 220 + Who is the architect of the Great Pyramid of Giza ? + simple_choice + + + + + + + + 1 + Imhotep + + + + 2 + Amenhotep + + + + 3 + Hemiunu + + 10 + + + + 4 + Papyrus + + + + + 230 + How many years did the 100 years war last ? + simple_choice + + + + + + + + 1 + 99 years + + + + 2 + 100 years + + + + 3 + 116 years + + 10 + + + + 4 + 127 years + + + + + Sciences + + 300 + + + + + + 310 + Who received a Nobel prize in Physics for the discovery of neutrino oscillations, which shows that neutrinos have mass ? + multiple_choice + + + + + + + + 1 + Arthur B. McDonald + + 5 + + + + 2 + Peter W. Higgs + + + + 3 + Takaaki Kajita + + 5 + + + + 4 + Willard S. Boyle + + + + + 320 + What is, approximately, the critical mass of plutonium-239 ? + simple_choice + + + + + + + + 1 + 5.7 kg + + + + 2 + 10 kg + + 10 + + + + 3 + 16.2 kg + + + + 4 + 47 kg + + + + + 330 + Can Humans ever directly see a photon ? + simple_choice + + + + + + + + 1 + Yes, that's the only thing a human eye can see. + + 10 + + + + 2 + No, it's to small for the human eye. + + + + + Art & Culture + + 400 + + + + + + 410 + Which Musician is not in the 27th Club ? + multiple_choice + + + + + + + + 1 + Kurt Cobain + + + + 2 + Kim Jong-hyun + + + + 3 + Avicii + + 5 + + + + 4 + Cliff Burton + + 5 + + + + + 420 + Which painting/drawing was not made by Pablo Picasso ? + simple_choice + + + + + + + + + 1 + + + + + + 2 + + + + 10 + + + + 3 + + + + + + 4 + + + + + + + 430 + Which quote is from Jean-Claude Van Damme + simple_choice + + + + + + + + 1 + I’ve never really wanted to go to Japan. Simply because I don’t like eating fish. And I know that’s very popular out there in Africa. + + + + 2 + I am fascinated by air. If you remove the air from the sky, all the birds would fall to the ground. And all the planes, too. + + 10 + + + + 3 + I've been noticing gravity since I was very young ! + + + + 4 + I actually don't like thinking. I think people think I like to think a lot. And I don't. I do not like to think at all. + + +
diff --git a/addons/survey/data/survey_demo_quiz.xml b/addons/survey/data/survey_demo_quiz.xml index e2b11c6fc32..96aab5a4860 100644 --- a/addons/survey/data/survey_demo_quiz.xml +++ b/addons/survey/data/survey_demo_quiz.xml @@ -198,7 +198,7 @@ - 23 + 24 A "Citrus" could give you ... multiple_choice @@ -235,7 +235,7 @@ - 24 + 25 How often should you water those plants matrix simple @@ -264,7 +264,7 @@ - 25 + 26 When do you harvest those fruits Best time to do it, is the right time to do it. matrix @@ -312,7 +312,7 @@ Trees - 26 + 30 @@ -325,7 +325,7 @@ Dogwood is from which family of trees ? - 27 + 31 simple_choice 6 @@ -361,7 +361,7 @@ In which country did the bonsai technique develop ? - 28 + 32 simple_choice 6 @@ -397,7 +397,7 @@ Is the wood of a coniferous hard or soft ? - 29 + 33 simple_choice 6 @@ -424,7 +424,7 @@ From which continent is native the Scots pine (pinus sylvestris) ? - 30 + 34 simple_choice @@ -465,7 +465,7 @@ In the list below, select all the corniferous. - 31 + 35 multiple_choice 6 @@ -507,7 +507,7 @@ After watching this video, will you swear that you are not going to procrastinate to trim your hedge this year ? - 32 + 36 simple_choice 6 @@ -543,7 +543,7 @@ Your feeling - 33 + 40 @@ -551,7 +551,7 @@ - 34 + 41 What do you think about this survey ? If you don't like us, please try to be as objective as possible. simple_choice diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index 2ef1631c8ce..1e130d749c7 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -130,6 +130,23 @@ class SurveyQuestion(models.Model): 'survey.user_input.line', 'question_id', string='Answers', domain=[('skipped', '=', False)], groups='survey.group_survey_user') + # Conditional display + is_conditional = fields.Boolean( + string='Conditional Display', copy=False, help="""If checked, this question will be displayed only + if the specified conditional answer have been selected in a previous question""") + triggering_question_id = fields.Many2one( + 'survey.question', string="Triggering Question", copy=False, compute="_compute_triggering_question_id", + store=True, readonly=False, help="Question containing the triggering answer to display the current question.", + domain="""[('survey_id', '=', survey_id), + '&', ('question_type', 'in', ['simple_choice', 'multiple_choice']), + '|', + ('sequence', '<', sequence), + '&', ('sequence', '=', sequence), ('id', '<', id)]""") + triggering_answer_id = fields.Many2one( + 'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id", + store=True, readonly=False, help="Answer that will trigger the display the current question.", + domain="[('question_id', '=', triggering_question_id)]") + _sql_constraints = [ ('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'), ('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'), @@ -193,6 +210,25 @@ class SurveyQuestion(models.Model): if question.question_type != 'char_box': question.save_as_nickname = False + @api.depends('is_conditional') + def _compute_triggering_question_id(self): + """ Used as an 'onchange' : Reset the triggering question if user uncheck 'Conditional Display' + Avoid CacheMiss : set the value to False if the value is not set yet.""" + for question in self: + if not question.is_conditional or question.triggering_question_id is None: + question.triggering_question_id = False + + @api.depends('triggering_question_id') + def _compute_triggering_answer_id(self): + """ Used as an 'onchange' : Reset the triggering answer if user unset or change the triggering question + or uncheck 'Conditional Display'. + Avoid CacheMiss : set the value to False if the value is not set yet.""" + for question in self: + if not question.triggering_question_id \ + or question.triggering_question_id != question.triggering_answer_id.question_id\ + or question.triggering_answer_id is None: + question.triggering_answer_id = False + # Validation methods def validate_question(self, answer, comment=None): """ Validate question, depending on question type and parameters diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index f589629ee5d..9af1f059d08 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -1,14 +1,11 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import datetime import json import random import uuid import werkzeug -from dateutil.relativedelta import relativedelta - from odoo import api, exceptions, fields, models, _ from odoo.exceptions import AccessError from odoo.osv import expression @@ -83,7 +80,8 @@ class Survey(models.Model): string="Scoring", required=True, default='no_scoring') scoring_success_min = fields.Float('Success %', default=80.0) # attendees context: attempts and time limitation - is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user") + is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user", + compute="_compute_is_attempts_limited", store=True, readonly=False) attempts_limit = fields.Integer('Number of attempts', default=1) is_time_limited = fields.Boolean('The survey is limited in time') time_limit = fields.Float("Time limit (minutes)") @@ -126,6 +124,8 @@ class Survey(models.Model): session_show_leaderboard = fields.Boolean("Show Session Leaderboard", compute='_compute_session_show_leaderboard', help="Whether or not we want to show the attendees leaderboard for this survey.") session_speed_rating = fields.Boolean("Reward quick answers", help="Attendees get more points if they answer quickly") + # conditional questions management + has_conditional_questions = fields.Boolean("Contains conditional questions", compute="_compute_has_conditional_questions") _sql_constraints = [ ('access_token_unique', 'unique(access_token)', 'Access token should be unique'), @@ -178,6 +178,14 @@ class Survey(models.Model): survey.page_ids = survey.question_and_page_ids.filtered(lambda question: question.is_page) survey.question_ids = survey.question_and_page_ids - survey.page_ids + @api.depends('question_and_page_ids.is_conditional', 'users_login_required', 'access_mode') + def _compute_is_attempts_limited(self): + for survey in self: + if any(question.is_conditional for question in survey.question_and_page_ids)\ + or (survey.access_mode == 'public' and not survey.users_login_required)\ + or survey.is_attempts_limited is None: + survey.is_attempts_limited = False + @api.depends('session_start_time', 'user_input_ids') def _compute_session_answer_count(self): """ We have to loop since our result is dependent of the survey.session_start_time. @@ -221,6 +229,11 @@ class Survey(models.Model): survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \ any(question.save_as_nickname for question in survey.question_and_page_ids) + @api.depends('question_and_page_ids.is_conditional') + def _compute_has_conditional_questions(self): + for survey in self: + survey.has_conditional_questions = any(question.is_conditional for question in survey.question_and_page_ids) + @api.onchange('scoring_success_min') def _onchange_scoring_success_min(self): if self.scoring_success_min < 0 or self.scoring_success_min > 100: @@ -232,11 +245,6 @@ class Survey(models.Model): self.certification = False self.is_time_limited = False - @api.onchange('users_login_required', 'access_mode') - def _onchange_access_mode(self): - if self.access_mode == 'public' and not self.users_login_required: - self.is_attempts_limited = False - @api.onchange('attempts_limit') def _onchange_attempts_limit(self): if self.attempts_limit <= 0: @@ -430,63 +438,57 @@ class Survey(models.Model): @api.model def _get_pages_or_questions(self, user_input): if self.questions_layout == 'one_page': - return None + return self.env['survey.question'] elif self.questions_layout == 'page_per_question' and self.questions_selection == 'random': - return list(enumerate( - user_input.predefined_question_ids - )) + return user_input.predefined_question_ids else: - return list(enumerate( - self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids - )) + return self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids - @api.model - def _previous_page_or_question_id(self, user_input, page_or_question_id): - survey = user_input.survey_id - pages_or_questions = survey._get_pages_or_questions(user_input) - - current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id)) - # is first page - if current_page_index == 0: - return None - - previous_page_id = pages_or_questions[current_page_index - 1][1].id - - return previous_page_id - - @api.model - def next_page_or_question(self, user_input, page_or_question_id): - """ The next page to display to the user, knowing that page_id is the id - of the last displayed page. - - If page_id == 0, it will always return the first page of the survey. - - If all the pages have been displayed, it will return None - - .. note:: - It is assumed here that a careful user will not try to set go_back - to True if she knows that the page to display is the first one! - (doing this will probably cause a giant worm to eat her house) + def _get_next_page_or_question(self, user_input, page_or_question_id, go_back=False): + """ + This methods gets the next question or page to display. + In case the survey contains conditional question, when navigating to next or previous questions, + in page_per_question layout only, the next or previous question to display depends on the selected answers + and the questions hierarchy. This methods returns the next question index to display among the survey_questions. + :param user_input: user's answers + :param page_or_question_id: current page or question id + :param go_back: must be True to get the previous question + :return: next or previous question.id """ survey = user_input.survey_id - pages_or_questions = survey._get_pages_or_questions(user_input) - if not pages_or_questions: - return (None, False) + Question = self.env['survey.question'] - # First page - if page_or_question_id == 0: - return (pages_or_questions[0][1], len(pages_or_questions) == 1) + # Get Next + if not go_back: + if not pages_or_questions: + return Question + # First page + if page_or_question_id == 0: + return pages_or_questions[0] - current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id)) + current_page_index = pages_or_questions.ids.index(next(p.id for p in pages_or_questions if p.id == page_or_question_id)) - # All the pages have been displayed - if current_page_index == len(pages_or_questions) - 1: - return (None, False) + # Get previous and we are on first page OR Get Next and we are on last page + if (go_back and current_page_index == 0) or current_page_index == len(pages_or_questions) - 1: + return Question + + # Conditional Questions Management + triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values() + if survey.has_conditional_questions and survey.questions_layout == 'page_per_question' and triggered_questions_by_answer: + potential_next_questions = pages_or_questions[0:current_page_index] if go_back \ + else pages_or_questions[current_page_index+1:] + for question in sorted(potential_next_questions, key=lambda q: q.id, reverse=go_back): + triggering_answer = triggering_answer_by_question.get(question) + if not triggering_answer or triggering_answer in selected_answers: + # If next question found + return question else: - show_last = current_page_index == len(pages_or_questions) - 2 - return (pages_or_questions[current_page_index + 1][1], show_last) + return pages_or_questions[current_page_index + (1 if not go_back else -1)] + + return Question + def _get_survey_questions(self, answer=None, page_id=None, question_id=None): """ Returns a tuple containing: the survey question and the passed question_id / page_id @@ -530,6 +532,23 @@ class Survey(models.Model): questions = questions & answer.predefined_question_ids return questions, page_or_question_id + # ------------------------------------------------------------ + # CONDITIONAL QUESTIONS MANAGEMENT + # ------------------------------------------------------------ + + def _get_conditional_maps(self): + triggering_answer_by_question = {} + triggered_questions_by_answer = {} + for question in self.question_ids: + triggering_answer_by_question[question] = question.is_conditional and question.triggering_answer_id + + if question.is_conditional: + if question.triggering_answer_id in triggered_questions_by_answer: + triggered_questions_by_answer[question.triggering_answer_id] |= question + else: + triggered_questions_by_answer[question.triggering_answer_id] = question + return triggering_answer_by_question, triggered_questions_by_answer + # ------------------------------------------------------------ # SESSIONS MANAGEMENT # ------------------------------------------------------------ @@ -544,7 +563,7 @@ class Survey(models.Model): self.sudo().write({'session_state': 'in_progress'}) self.sudo().flush(['session_state']) - def _session_trigger_next_question(self): + def _get_session_next_question(self): """ Triggers the next question of the session. We artificially add 2 seconds to the 'current_question_start_time' to account for server delay. @@ -563,28 +582,52 @@ class Survey(models.Model): return if not self.session_question_id: - question = self.question_ids[0] + next_question = self.question_ids[0] else: - questions = list(enumerate(self.question_ids)) - current_question_index = questions.index( - next(question for question in questions if question[1] == self.session_question_id) - ) - if current_question_index < len(self.question_ids) - 1: - question = self.question_ids[current_question_index + 1] - else: - question = self.session_question_id + most_voted_answers = self._get_session_most_voted_answers() + next_question = self._get_next_page_or_question(most_voted_answers, self.session_question_id.id) - # using datetime.datetime because we want the millis portion - now = datetime.datetime.now() - self.sudo().write({ - 'session_question_id': question.id, - 'session_question_start_time': fields.Datetime.now() + relativedelta(seconds=2) - }) - self.env['bus.bus'].sendone(self.access_token, { - 'question_start': now.timestamp(), - 'type': 'next_question' + return next_question + + def _get_session_most_voted_answers(self): + """ In sessions of survey that has conditional questions, as the survey is passed at the same time by + many users, we need to extract the most chosen answers, to determine the next questions to display. """ + + # get user_inputs from current session + current_user_inputs = self.user_input_ids.filtered(lambda input: input.create_date > self.session_start_time) + current_user_input_lines = current_user_inputs.mapped('user_input_line_ids').filtered(lambda answer: answer.suggested_answer_id) + + # count the number of vote per answer + votes_by_answer = dict.fromkeys(current_user_input_lines.mapped('suggested_answer_id'), 0) + for answer in current_user_input_lines: + votes_by_answer[answer.suggested_answer_id] += 1 + + # extract most voted answer for each question + most_voted_answer_by_questions = dict.fromkeys(current_user_input_lines.mapped('question_id')) + for question in most_voted_answer_by_questions.keys(): + for answer in votes_by_answer.keys(): + if answer.question_id != question: + continue + most_voted_answer = most_voted_answer_by_questions[question] + if not most_voted_answer or votes_by_answer[most_voted_answer] < votes_by_answer[answer]: + most_voted_answer_by_questions[question] = answer + + # return a fake 'audiance' user_input + fake_user_input = self.env['survey.user_input'].new({ + 'survey_id': self.id, }) + fake_user_input_lines = self.env['survey.user_input.line'] + for question, answer in most_voted_answer_by_questions.items(): + fake_user_input_lines |= self.env['survey.user_input.line'].new({ + 'question_id': question.id, + 'suggested_answer_id': answer.id, + 'survey_id': self.id, + 'user_input_id': fake_user_input.id + }) + + return fake_user_input + def _prepare_leaderboard_values(self): """" The leaderboard is descending and takes the total of the attendee points up to the current question. """ self.ensure_one() diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index af5d745145c..d6ea0b1fed8 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -55,7 +55,7 @@ class SurveyUserInput(models.Model): ('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') + @api.depends('user_input_line_ids.answer_score', 'user_input_line_ids.question_id', 'predefined_question_ids') def _compute_scoring_values(self): for user_input in self: total_possible_score = sum([ @@ -209,6 +209,9 @@ class SurveyUserInput(models.Model): if user_input.survey_id.certification_give_badge: badge_ids.append(user_input.survey_id.certification_badge_id.id) + # Update predefined_question_id to remove inactive questions + user_input.predefined_question_ids -= user_input._get_inactive_conditional_questions() + if badge_ids: challenges = Challenge.search([('reward_id', 'in', badge_ids)]) if challenges: @@ -359,6 +362,90 @@ class SurveyUserInput(models.Model): {'text': _("Unanswered"), 'count': res[user_input]['skipped']} ] for user_input in self] + # ------------------------------------------------------------ + # Conditional Questions Management + # ------------------------------------------------------------ + + def _get_conditional_values(self): + """ For survey containing conditional questions, we need a triggered_questions_by_answer map that contains + {key: answer, value: the question that the answer triggers, if selected}, + The idea is to be able to verify, on every answer check, if this answer is triggering the display + of another question. + If answer is not in the conditional map: + - nothing happens. + If the answer is in the conditional map: + - If we are in ONE PAGE survey : (handled at CLIENT side) + -> display immediately the depending question + - If we are in PAGE PER SECTION : (handled at CLIENT side) + - If related question is on the same page : + -> display immediately the depending question + - If the related question is not on the same page : + -> keep the answers in memory and check at next page load if the depending question is in there and + display it, if so. + - If we are in PAGE PER QUESTION : (handled at SERVER side) + -> During submit, determine which is the next question to display getting the next question + that is the next in sequence and that is either not triggered by another question's answer, or that + is triggered by an already selected answer. + To do all this, we need to return: + - list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is + updated at client side) + - triggered_questions_by_answer: dict -> for a given answer, list of questions triggered by this answer; + Used mainly for dynamic show/hide behaviour at client side + - triggering_answer_by_question: dict -> for a given question, the answer that triggers it + Used mainly to ease template rendering + """ + triggering_answer_by_question, triggered_questions_by_answer = {}, {} + # Ignore conditional configuration if randomised questions selection + if self.survey_id.questions_selection != 'random': + triggering_answer_by_question, triggered_questions_by_answer = self.survey_id._get_conditional_maps() + selected_answers = self._get_selected_suggested_answers() + + return triggering_answer_by_question, triggered_questions_by_answer, selected_answers + + def _get_selected_suggested_answers(self): + """ + For now, only simple and multiple choices question type are handled by the conditional questions feature. + Mapping all the suggested answers selected by the user will also include answers from matrix question type, + Those ones won't be used. + Maybe someday, conditional questions feature will be extended to work with matrix question. + :return: all the suggested answer selected by the user. + """ + return self.mapped('user_input_line_ids.suggested_answer_id') + + def _clear_inactive_conditional_answers(self): + """ + Clean eventual answers on conditional questions that should not have been displayed to user. + This method is used mainly for page per question survey, a similar method does the same treatment + at client side for the other survey layouts. + E.g.: if depending answer was uncheck after answering conditional question, we need to clear answers + of that conditional question, for two reasons: + - ensure correct scoring + - if the selected answer triggers another question later in the survey, if the answer is not cleared, + a question that should not be displayed to the user will be. + + TODO DBE: Maybe this can be the only cleaning method, even for section_per_page or one_page where + conditional questions are, for now, cleared in JS directly. But this can be annoying if user typed a long + answer, changed his mind unchecking depending answer and changed again his mind by rechecking the depending + answer -> For now, the long answer will be lost. If we use this as the master cleaning method, + long answer will be cleared only during submit. + """ + inactive_questions = self._get_inactive_conditional_questions() + + # delete user.input.line on question that should not be answered. + answers_to_delete = self.user_input_line_ids.filtered(lambda answer: answer.question_id in inactive_questions) + answers_to_delete.unlink() + + def _get_inactive_conditional_questions(self): + triggering_answer_by_question, triggered_questions_by_answer, selected_answers = self._get_conditional_values() + + # get questions that should not be answered + inactive_questions = self.env['survey.question'] + for answer in triggered_questions_by_answer.keys(): + if answer not in selected_answers: + for question in triggered_questions_by_answer[answer]: + inactive_questions |= question + return inactive_questions + class SurveyUserInputLine(models.Model): _name = 'survey.user_input.line' diff --git a/addons/survey/static/src/img/burger_quiz_background.jpg b/addons/survey/static/src/img/burger_quiz_background.jpg new file mode 100644 index 00000000000..b52eac68380 Binary files /dev/null and b/addons/survey/static/src/img/burger_quiz_background.jpg differ diff --git a/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg b/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg new file mode 100644 index 00000000000..95f588041da Binary files /dev/null and b/addons/survey/static/src/img/burger_quiz_cubism_klein.jpg differ diff --git a/addons/survey/static/src/img/burger_quiz_don_quixote.jpg b/addons/survey/static/src/img/burger_quiz_don_quixote.jpg new file mode 100644 index 00000000000..616bc388aab Binary files /dev/null and b/addons/survey/static/src/img/burger_quiz_don_quixote.jpg differ diff --git a/addons/survey/static/src/img/burger_quiz_guernica.jpg b/addons/survey/static/src/img/burger_quiz_guernica.jpg new file mode 100644 index 00000000000..3301982ca2f Binary files /dev/null and b/addons/survey/static/src/img/burger_quiz_guernica.jpg differ diff --git a/addons/survey/static/src/img/burger_quiz_self_portrait.jpg b/addons/survey/static/src/img/burger_quiz_self_portrait.jpg new file mode 100644 index 00000000000..efd7e8330b2 Binary files /dev/null and b/addons/survey/static/src/img/burger_quiz_self_portrait.jpg differ diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index 21bad10c7fd..34e429eacee 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -34,6 +34,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ return this._super.apply(this, arguments).then(function () { self.options = self.$target.find('form').data(); self.readonly = self.options.readonly; + self.selectedAnswers = self.options.selectedAnswers; // Init fields if (!self.options.isStartScreen && !self.readonly) { self._initTimer(); @@ -105,6 +106,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ * @param {Event} event */ _onChangeChoiceItem: function (event) { + var self = this; var $target = $(event.currentTarget); var $choiceItemGroup = $target.closest('.o_survey_form_choice'); var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment'); @@ -126,8 +128,42 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ $matrixBtn.closest('tr').find('td').removeClass('o_survey_selected'); $matrixBtn.addClass('o_survey_selected'); } else { - $choiceItemGroup.find('label').removeClass('o_survey_selected'); - $target.closest('label').addClass('o_survey_selected'); + var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected'); + previouslySelectedAnswer.removeClass('o_survey_selected'); + + var newlySelectedAnswer = $target.closest('label'); + if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) { + newlySelectedAnswer.addClass('o_survey_selected'); + } + + // Conditional display + if (this.options.questionsLayout !== 'page_per_question') { + var treatedQuestionIds = []; // Needed to avoid show (1st 'if') then immediately hide (2nd 'if') question during conditional propagation cascade + if (Object.keys(this.options.triggeredQuestionsByAnswer).includes(previouslySelectedAnswer.find('input').val())) { + // Hide and clear depending question + this.options.triggeredQuestionsByAnswer[previouslySelectedAnswer.find('input').val()].forEach(function (questionId) { + var dependingQuestion = $('.js_question-wrapper#' + questionId); + + dependingQuestion.addClass('d-none'); + self._clearQuestionInputs(dependingQuestion); + + treatedQuestionIds.push(questionId); + }); + // Remove answer from selected answer + self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1); + } + if (Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) { + // Display depending question + this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) { + if (!treatedQuestionIds.includes(questionId)) { + var dependingQuestion = $('.js_question-wrapper#' + questionId); + dependingQuestion.removeClass('d-none'); + } + }); + // Add answer to selected answer + this.selectedAnswers.push(parseInt($target.val())); + } + } } } else { // $target.attr('type') === 'checkbox' if ($matrixBtn.length > 0) { @@ -135,6 +171,25 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } else { var $label = $target.closest('label'); $label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected')); + + // Conditional display + if (this.options.questionsLayout !== 'page_per_question' && Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) { + var isInputSelected = $label.hasClass('o_survey_selected'); + // Hide and clear or display depending question + this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) { + var dependingQuestion = $('.js_question-wrapper#' + questionId); + dependingQuestion.toggleClass('d-none', !isInputSelected); + if (!isInputSelected) { + self._clearQuestionInputs(dependingQuestion); + } + }); + // Add/remove answer to/from selected answer + if (!isInputSelected) { + self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1); + } else { + self.selectedAnswers.push(parseInt($target.val())); + } + } } } }, @@ -342,6 +397,12 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ if (result && !result.error) { this.$(".o_survey_form_content").empty(); this.$(".o_survey_form_content").html(result); + + // Hide timer if end screen (if page_per_question in case of conditional questions) + if (self.options.questionsLayout === 'page_per_question' && this.$('.o_survey_finished').length > 0) { + options.isFinish = true; + } + this.$('div.o_survey_form_date').each(function () { self._initDateTimePicker($(this)); }); @@ -408,16 +469,24 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ data[key] = value; }); + var inactiveQuestionIds = this.options.sessionInProgress ? [] : this._getInactiveConditionalQuestionIds(); + $form.find('[data-question-type]').each(function () { var $input = $(this); var $questionWrapper = $input.closest(".js_question-wrapper"); + var questionId = $questionWrapper.attr('id'); + + // If question is inactive, skip validation. + if (inactiveQuestionIds.includes(parseInt(questionId))) { + return; + } + + var questionRequired = $questionWrapper.data('required'); var constrErrorMsg = $questionWrapper.data('constrErrorMsg'); var validationErrorMsg = $questionWrapper.data('validationErrorMsg'); - var questionId = $questionWrapper.attr('id'); - var questionRequired = $questionWrapper.data('required'); switch ($input.data('questionType')) { case 'char_box': - if (questionRequired && !$input.val()) { + if (questionRequired && !questionInactive && !$input.val()) { errors[questionId] = constrErrorMsg; } else if ($input.val() && $input.attr('type') === 'email' && !self._validateEmail($input.val())) { errors[questionId] = validationEmailMsg; @@ -835,6 +904,47 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } }, + // CONDITIONAL QUESTIONS MANAGEMENT TOOLS + // ------------------------------------------------------------------------- + + /** + * Clear / Un-select all the input from the given question + * + propagate conditional hierarchy by triggering change on choice inputs. + * + * @private + */ + _clearQuestionInputs: function (question) { + question.find('input').each(function () { + if ($(this).attr('type') === 'text' || $(this).attr('type') === 'number') { + $(this).val(''); + } else if ($(this).prop('checked')) { + $(this).prop('checked', false).change(); + } + }); + question.find('textarea').val(''); + }, + + /** + * Get questions that are not supposed to be answered by the user. + * Those are the ones triggered by answers that the user did not selected. + * + * @private + */ + _getInactiveConditionalQuestionIds: function () { + var self = this; + var inactiveQuestionIds = []; + if (this.options.triggeredQuestionsByAnswer) { + Object.keys(this.options.triggeredQuestionsByAnswer).forEach(function (answerId) { + if (!self.selectedAnswers.includes(parseInt(answerId))) { + self.options.triggeredQuestionsByAnswer[answerId].forEach(function (questionId) { + inactiveQuestionIds.push(questionId); + }); + } + }); + } + return inactiveQuestionIds; + }, + // ERRORS TOOLS // ------------------------------------------------------------------------- diff --git a/addons/survey/static/src/js/survey_session_leaderboard.js b/addons/survey/static/src/js/survey_session_leaderboard.js index 0126a00f1c7..ae050ddedcf 100644 --- a/addons/survey/static/src/js/survey_session_leaderboard.js +++ b/addons/survey/static/src/js/survey_session_leaderboard.js @@ -24,14 +24,21 @@ publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({ * * The width of the progress bars is set after the rendering to enable a width css animation. */ - showLeaderboard: function () { + showLeaderboard: function (fadeOut) { var self = this; var resolveFadeOut; - var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); - self.$sessionResults.fadeOut(400, function () { - resolveFadeOut(); - }); + var fadeOutPromise; + if (fadeOut) { + fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); + self.$sessionResults.fadeOut(400, function () { + resolveFadeOut(); + }); + } else { + fadeOutPromise = Promise.resolve(); + self.$sessionResults.hide(); + self.$('.o_survey_session_leaderboard_container').empty(); + } var leaderboardPromise = this._rpc({ route: _.str.sprintf('/survey/session/leaderboard/%s', this.surveyAccessToken) diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js index 0d473cdc9b9..aec3c1f76bf 100644 --- a/addons/survey/static/src/js/survey_session_manage.js +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -161,7 +161,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ this.$('.o_survey_session_leaderboard_title').text(_('Final Leaderboard')); this.$('.o_survey_session_navigation_next').addClass('d-none'); } - this.leaderBoard.showLeaderboard(); + this.leaderBoard.showLeaderboard(true); } else { if (!this.isLastQuestion) { this._nextQuestion(); @@ -317,12 +317,23 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ } Promise.all([fadeOutPromise, nextQuestionPromise]).then(function (results) { - var $renderedTemplate = $(results[1]); - self.$el.replaceWith($renderedTemplate); - self.attachTo($renderedTemplate); - self.$el.fadeIn(1000, function () { - self._startTimer(); - }); + if (results[1]) { + var $renderedTemplate = $(results[1]); + self.$el.replaceWith($renderedTemplate); + self.attachTo($renderedTemplate); + self.$el.fadeIn(1000, function () { + self._startTimer(); + }); + } else { + // Display last screen + self.isLastQuestion = true; + self._setupLeaderboard().then(function () { + self.$('.o_survey_session_leaderboard_title').text(_('Final Leaderboard')); + self.$('.o_survey_session_navigation_next').addClass('d-none'); + self.$('.o_survey_leaderboard_buttons').removeClass('d-none'); + self.leaderBoard.showLeaderboard(false); + }); + } }); }, diff --git a/addons/survey/static/src/scss/survey_views.scss b/addons/survey/static/src/scss/survey_views.scss index f81f5a660aa..fcfc0ffc9a2 100644 --- a/addons/survey/static/src/scss/survey_views.scss +++ b/addons/survey/static/src/scss/survey_views.scss @@ -20,3 +20,7 @@ table.o_section_list_view tr.o_data_row.o_is_section { border-top: 1px solid #BBB; border-bottom: 1px solid #BBB; } + +.icon_rotates { + transform: rotate(180deg); +} diff --git a/addons/survey/views/survey_question_views.xml b/addons/survey/views/survey_question_views.xml index e273f0b1f83..64336d5743b 100644 --- a/addons/survey/views/survey_question_views.xml +++ b/addons/survey/views/survey_question_views.xml @@ -157,9 +157,19 @@ - - - + + + + + + + + + + @@ -190,6 +200,9 @@ + + - or press Enter
@@ -196,7 +198,7 @@
-