diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index d12348a33a7..0d0a32ee9a2 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -40,16 +40,6 @@ class Survey(http.Controller): ], limit=1) return survey_sudo, answer_sudo - def _fetch_from_access_code(self, survey_short_token): - if survey_short_token and len(survey_short_token) == 6: - matching_survey = request.env['survey.survey'].sudo().search([ - ('state', '=', 'open'), - ('access_token', 'like', f'{survey_short_token}%'), - ]) - if len(matching_survey) == 1: - return matching_survey - return False - def _check_validity(self, survey_token, answer_token, ensure_token=True): """ Check survey is open and can be taken. This does not checks for security rules, only functional / business rules. It returns a string key @@ -150,30 +140,6 @@ class Survey(http.Controller): return werkzeug.utils.redirect("/") - # ------------------------------------------------------------ - # QUICK ACCESS SURVEY ROUTES - # ------------------------------------------------------------ - - @http.route('/s', type='http', auth='public', website=True, sitemap=False) - def survey_access_code(self, **post): - """ Renders the survey access code page route. - This page allows the user to enter the short code of the survey. - It's mainly used in 'session mode' when attendees have to manually type the - URL, to ease survey access. """ - return request.render("survey.survey_access_code") - - @http.route('/s/', type='http', auth='public', website=True) - def survey_start_short(self, survey_short_token): - """" Redirects to 'survey_start' route using a shortened link & token. - We match the 6 first characters of the token for open surveys. - This route is mostly used in survey sessions where we need short links for people to type. """ - - survey = self._fetch_from_access_code(survey_short_token) - if survey: - return werkzeug.utils.redirect("/survey/start/%s" % survey.access_token) - - return werkzeug.utils.redirect("/") - # ------------------------------------------------------------ # TEST / RETRY SURVEY ROUTES # ------------------------------------------------------------ @@ -302,10 +268,12 @@ class Survey(http.Controller): if 'previous_page_id' in post: previous_page_or_question_id = int(post['previous_page_id']) new_previous_id = survey_sudo._get_next_page_or_question(answer_sudo, previous_page_or_question_id, go_back=True).id + page_or_question = request.env['survey.question'].sudo().browse(previous_page_or_question_id) data.update({ - page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id), + page_or_question_key: page_or_question, 'previous_page_id': new_previous_id, - 'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id.id == new_previous_id) + 'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id.id == new_previous_id), + 'can_go_back': survey_sudo._can_go_back(answer_sudo, page_or_question), }) return data @@ -318,9 +286,9 @@ class Survey(http.Controller): answer_sudo.last_displayed_page_id.id if answer_sudo.last_displayed_page_id else 0) 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}) + data.update({ + 'survey_last': survey_sudo._is_last_page_or_question(answer_sudo, next_page_or_question) + }) if answer_sudo.is_session_answer and next_page_or_question.is_time_limited: data.update({ @@ -330,7 +298,8 @@ class Survey(http.Controller): data.update({ 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) + 'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id == next_page_or_question), + 'can_go_back': survey_sudo._can_go_back(answer_sudo, next_page_or_question), }) if survey_sudo.questions_layout != 'one_page': data.update({ @@ -345,10 +314,36 @@ class Survey(http.Controller): def _prepare_question_html(self, survey_sudo, answer_sudo, **post): """ Survey page navigation is done in AJAX. This function prepare the 'next page' to display in html and send back this html to the survey_form widget that will inject it into the page.""" - data = self._prepare_survey_data(survey_sudo, answer_sudo, **post) + survey_data = self._prepare_survey_data(survey_sudo, answer_sudo, **post) + + survey_content = False if answer_sudo.state == 'done': - return request.env.ref('survey.survey_fill_form_done').render(data) - return request.env.ref('survey.survey_fill_form_in_progress').render(data) + survey_content = request.env.ref('survey.survey_fill_form_done').render(survey_data) + else: + survey_content = request.env.ref('survey.survey_fill_form_in_progress').render(survey_data) + + survey_progress = False + if answer_sudo.state == 'in_progress' and not survey_data.get('question', request.env['survey.question']).is_page: + if survey_sudo.questions_layout == 'page_per_section': + page_ids = survey_sudo.page_ids.ids + survey_progress = request.env.ref('survey.survey_progression').render({ + 'survey': survey_sudo, + 'page_ids': page_ids, + 'page_number': page_ids.index(survey_data['page'].id) + (1 if survey_sudo.progression_mode == 'number' else 0) + }) + elif survey_sudo.questions_layout == 'page_per_question': + page_ids = survey_sudo.question_ids.ids + survey_progress = request.env.ref('survey.survey_progression').render({ + 'survey': survey_sudo, + 'page_ids': page_ids, + 'page_number': page_ids.index(survey_data['question'].id) + }) + + return { + 'survey_content': survey_content, + 'survey_progress': survey_progress, + 'survey_navigation': request.env.ref('survey.survey_navigation').render(survey_data), + } @http.route('/survey//', type='http', auth='public', website=True) def survey_display_page(self, survey_token, answer_token, **post): @@ -492,17 +487,6 @@ class Survey(http.Controller): return self._prepare_question_html(survey_sudo, answer_sudo) - @http.route('/survey/check_access_code/', type='json', auth='public', website=True) - def survey_access_code_check(self, access_code): - """ Checks if the given code is matching a survey access_token. - If yes, redirect to /s/code route. - If not, return error. The user is invited to type again the code. """ - survey = self._fetch_from_access_code(access_code) - if survey: - return {"survey_url": "/survey/start/%s" % survey.access_token} - - return {"error": "survey_wrong"} - def _extract_comment_from_answers(self, question, answers): """ Answers is a custom structure depending of the question type that can contain question answers but also comments that need to be diff --git a/addons/survey/controllers/survey_session_manage.py b/addons/survey/controllers/survey_session_manage.py index b93a31d94ff..e81626ce95b 100644 --- a/addons/survey/controllers/survey_session_manage.py +++ b/addons/survey/controllers/survey_session_manage.py @@ -3,10 +3,10 @@ import datetime import json +import werkzeug from dateutil.relativedelta import relativedelta from werkzeug.exceptions import NotFound -from werkzeug.urls import url_join from odoo import fields, http from odoo.http import request @@ -19,6 +19,27 @@ class UserInputSession(http.Controller): Unlike the regular survey controller, user trying to access the survey must have full access rights! """ return request.env['survey.survey'].search([('access_token', '=', survey_token)]) + def _fetch_from_session_code(self, session_code): + """ Matches a survey against a passed session_code. + We force the session_state to be reachable (ready / in_progress) to avoid people + using this route to access other (private) surveys. + We limit to sessions opened within the last 7 days to avoid potential abuses. """ + if session_code: + matching_survey = request.env['survey.survey'].sudo().search([ + ('state', '=', 'open'), + ('session_state', 'in', ['ready', 'in_progress']), + ('session_start_time', '>', fields.Datetime.now() - relativedelta(days=7)), + ('session_code', '=', session_code), + ], limit=1) + if matching_survey: + return matching_survey + + return False + + # ------------------------------------------------------------ + # SURVEY SESSION MANAGEMENT + # ------------------------------------------------------------ + @http.route('/survey/session/manage/', type='http', auth='user', website=True) def survey_session_manage(self, survey_token, **kwargs): """ Main route used by the host to 'manager' the session. @@ -37,8 +58,7 @@ class UserInputSession(http.Controller): if survey.session_state == 'ready': return request.render('survey.user_input_session_open', { - 'survey': survey, - 'survey_url': url_join(survey.get_base_url(), survey.get_start_short_url()) + 'survey': survey }) else: template_values = self._prepare_manage_session_values(survey) @@ -49,7 +69,19 @@ class UserInputSession(http.Controller): """ This route is called when the host goes to the next question of the session. It's not a regular 'request.render' route because we handle the transition between - questions using a AJAX call to be able to display a bioutiful fade in/out effect. """ + questions using a AJAX call to be able to display a bioutiful fade in/out effect. + + It triggers the next question of the session. + + We artificially add 1 second to the 'current_question_start_time' to account for server delay. + As the timing can influence the attendees score, we try to be fair with everyone by giving them + an extra second before we start counting down. + + Frontend should take the delay into account by displaying the appropriate animations. + + Writing the next question on the survey is sudo'ed to avoid potential access right issues. + e.g: a survey user can create a live session from any survey but he can only write + on its own survey. """ survey = self._fetch_from_token(survey_token) @@ -67,7 +99,7 @@ class UserInputSession(http.Controller): now = datetime.datetime.now() survey.sudo().write({ 'session_question_id': next_question.id, - 'session_question_start_time': fields.Datetime.now() + relativedelta(seconds=2) + 'session_question_start_time': fields.Datetime.now() + relativedelta(seconds=1) }) request.env['bus.bus'].sendone(survey.access_token, { 'question_start': now.timestamp(), @@ -119,15 +151,49 @@ class UserInputSession(http.Controller): 'leaderboard': survey._prepare_leaderboard_values() }) + # ------------------------------------------------------------ + # QUICK ACCESS SURVEY ROUTES + # ------------------------------------------------------------ + + @http.route('/s', type='http', auth='public', website=True, sitemap=False) + def survey_session_code(self, **post): + """ Renders the survey session code page route. + This page allows the user to enter the session code of the survey. + It is mainly used to ease survey access for attendees in session mode. """ + return request.render("survey.survey_session_code") + + @http.route('/s/', type='http', auth='public', website=True) + def survey_start_short(self, session_code): + """" Redirects to 'survey_start' route using a shortened link & token. + We match the session_code for open surveys. + This route is used in survey sessions where we need short links for people to type. """ + + survey = self._fetch_from_session_code(session_code) + if survey: + return werkzeug.utils.redirect("/survey/start/%s" % survey.access_token) + + return werkzeug.utils.redirect("/s") + + @http.route('/survey/check_session_code/', type='json', auth='public', website=True) + def survey_check_session_code(self, session_code): + """ Checks if the given code is matching a survey session_code. + If yes, redirect to /s/code route. + If not, return error. The user is invited to type again the code. """ + survey = self._fetch_from_session_code(session_code) + if survey: + return {"survey_url": "/survey/start/%s" % survey.access_token} + + return {"error": "survey_wrong"} + def _prepare_manage_session_values(self, survey): is_last_question = False if survey.question_ids: - is_last_question = survey.session_question_id == survey.question_ids[-1] + most_voted_answers = survey._get_session_most_voted_answers() + is_last_question = survey._is_last_page_or_question(most_voted_answers, survey.session_question_id) values = { 'survey': survey, 'is_last_question': is_last_question, - 'survey_url': url_join(survey.get_base_url(), survey.get_start_short_url()), } values.update(self._prepare_question_results_values(survey, request.env['survey.user_input.line'])) diff --git a/addons/survey/data/survey_demo_certification.xml b/addons/survey/data/survey_demo_certification.xml index 6b85585deb7..e2cf3e0938b 100644 --- a/addons/survey/data/survey_demo_certification.xml +++ b/addons/survey/data/survey_demo_certification.xml @@ -14,6 +14,7 @@ 4ead4bc8-b8f2-4760-a682-1fde8ddb95ac open public + page_per_question scoring_with_answers diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 41022901c9e..60558a94924 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -23,6 +23,27 @@ class Survey(models.Model): def _get_default_access_token(self): return str(uuid.uuid4()) + def _get_default_session_code(self): + """ Attempt to generate a session code for our survey. + The method will first try to generate 20 codes with 4 digits each and check if any are colliding. + If we have at least one non-colliding code, we use it. + If all 20 generated codes are colliding, we try with 20 codes of 5 digits, + then 6, ... up to 10 digits. """ + + for digits_count in range(4, 10): + range_lower_bound = 1 * (10 ** (digits_count - 1)) + range_upper_bound = (range_lower_bound * 10) - 1 + code_candidates = set([str(random.randint(range_lower_bound, range_upper_bound)) for i in range(20)]) + colliding_codes = self.sudo().search_read( + [('session_code', 'in', list(code_candidates))], + ['session_code'] + ) + code_candidates -= set([colliding_code['session_code'] for colliding_code in colliding_codes]) + if code_candidates: + return list(code_candidates)[0] + + return False # could not generate a code + # description title = fields.Char('Survey Title', required=True, translate=True) color = fields.Integer('Color Index', default=0) @@ -113,6 +134,9 @@ class Survey(models.Model): ('ready', 'Ready'), ('in_progress', 'In Progress'), ], string="Session State", copy=False) + session_code = fields.Char('Session Code', default=lambda self: self._get_default_session_code(), copy=False, + help="This code will be used by your attendees to reach your session. Feel free to customize it however you like!") + session_link = fields.Char('Session Link', compute='_compute_session_link') # live sessions - current question fields session_question_id = fields.Many2one('survey.question', string="Current Question", copy=False, help="The current question of the survey session.") @@ -130,6 +154,7 @@ class Survey(models.Model): _sql_constraints = [ ('access_token_unique', 'unique(access_token)', 'Access token should be unique'), + ('session_code_unique', 'unique(session_code)', 'Session code should be unique'), ('certification_check', "CHECK( scoring_type!='no_scoring' OR certification=False )", 'You can only create certifications for surveys that have a scoring mechanism.'), ('time_limit_check', "CHECK( (is_time_limited=False) OR (time_limit is not null AND time_limit > 0) )", @@ -192,10 +217,14 @@ class Survey(models.Model): """ We have to loop since our result is dependent of the survey.session_start_time. This field is currently used to display the count about a single survey, in the context of sessions, so it should not matter too much. """ + for survey in self: answer_count = 0 input_count = self.env['survey.user_input'].read_group( - [('survey_id', '=', survey.id), ('create_date', '>=', survey.session_start_time)], + [('survey_id', '=', survey.id), + ('is_session_answer', '=', True), + ('state', '!=', 'done'), + ('create_date', '>=', survey.session_start_time)], ['create_uid:count'], ['survey_id'], ) @@ -224,6 +253,18 @@ class Survey(models.Model): survey.session_question_answer_count = answer_count + @api.depends('session_code') + def _compute_session_link(self): + for survey in self: + if survey.session_code: + survey.session_link = werkzeug.urls.url_join( + survey.get_base_url(), + '/s/%s' % survey.session_code) + else: + survey.session_link = werkzeug.urls.url_join( + survey.get_base_url(), + survey.get_start_url()) + @api.depends('scoring_type', 'question_and_page_ids.save_as_nickname') def _compute_session_show_leaderboard(self): for survey in self: @@ -404,6 +445,30 @@ class Survey(models.Model): return questions + def _can_go_back(self, answer, page_or_question): + """ Check if the user can go back to the previous question/page for the currently + viewed question/page. + Back button needs to be configured on survey and, depending on the layout: + - In 'page_per_section', we can go back if we're not on the first page + - In 'page_per_question', we can go back if: + - It is not a session answer (doesn't make sense to go back in session context) + - We are not on the first question + - The survey does not have pages OR this is not the first page of the survey + (pages are displayed in 'page_per_question' layout when they have a description, see PR#44271) + """ + self.ensure_one() + + if self.users_can_go_back and answer.state == 'in_progress': + if self.questions_layout == 'page_per_section' and page_or_question != self.page_ids[0]: + return True + elif self.questions_layout == 'page_per_question' and \ + not answer.is_session_answer and \ + page_or_question != answer.predefined_question_ids[0] \ + and (not self.page_ids or page_or_question != self.page_ids[0]): + return True + + return False + def _has_attempts_left(self, partner, email, invite_token): self.ensure_one() @@ -484,7 +549,7 @@ class Survey(models.Model): if page_or_question_id == 0: return pages_or_questions[0] - current_page_index = pages_or_questions.ids.index(next(p.id for p in pages_or_questions if p.id == page_or_question_id)) + current_page_index = pages_or_questions.ids.index(page_or_question_id) # 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 (not go_back and current_page_index == len(pages_or_questions) - 1): @@ -517,6 +582,27 @@ class Survey(models.Model): return Question + def _is_last_page_or_question(self, user_input, page_or_question): + """ This method checks if the given question or page is the last one. + This includes conditional questions configuration. If the given question is normally not the last one but + every following questions are inactive due to conditional questions configurations (and user choices), + the given question will be the last one. + For section, we check in each following section if there is an active question. + If yes, the given page is not the last one. + """ + pages_or_questions = self._get_pages_or_questions(user_input) + current_page_index = pages_or_questions.ids.index(page_or_question.id) + next_page_or_question_candidates = pages_or_questions[current_page_index + 1:] + if next_page_or_question_candidates: + inactive_questions = user_input._get_inactive_conditional_questions() + if self.questions_layout == 'page_per_question': + return not any(next_question not in inactive_questions for next_question in next_page_or_question_candidates) + elif self.questions_layout == 'page_per_section': + for section in next_page_or_question_candidates: + return not any(next_question not in inactive_questions for next_question in section.question_ids) + + return True + 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 based on the question_layout and the fact that it's a session or not. @@ -591,18 +677,6 @@ class Survey(models.Model): self.sudo().flush(['session_state']) 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. - As the timing can influence the attendees score, we try to be fair with everyone by giving them - an extra few seconds before we start counting down. - - Frontend should take the delay into account by displaying the appropriate animations. - - Writing the next question on the survey is sudo'ed to avoid potential access right issues. - e.g: a survey user can create a live session from any survey but he can only write - on its own survey. """ - self.ensure_one() if not self.question_ids or not self.env.user.has_group('survey.group_survey_user'): diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index c9e9d5942a3..7d77dcab579 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -48,13 +48,19 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ self._focusOnFirstInput(); // Init event listener if (!self.readonly) { - $(document).on('keypress', self._onKeyPress.bind(self)); + $(document).on('keydown', self._onKeyDown.bind(self)); } if (self.options.sessionInProgress && (self.options.isStartScreen || self.options.hasAnswered || self.options.isPageDescription)) { self.preventEnterSubmit = true; } self._initSessionManagement(); + + // Needs global selector as progress/navigation are not within the survey form, but need + //to be updated at the same time + self.$surveyProgress = $('.o_survey_progress_wrapper'); + self.$surveyNavigation = $('.o_survey_navigation_wrapper'); + self.$surveyNavigation.find('.o_survey_navigation_submit').on('click', self._onSubmit.bind(self)); }); }, @@ -65,9 +71,18 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ // Handlers // ------------------------------------------------------------------------- - _onKeyPress: function (event) { - // If user is answering a textarea, do not handle keyPress - if (this.$("textarea").is(":focus")) { + /** + * Handle keyboard navigation: + * - 'enter' or 'arrow-right' => submit form + * - 'arrow-left' => submit form (but go back backwards) + * - other alphabetical character ('a', 'b', ...) + * Select the related option in the form (if available) + * + * @param {Event} event + */ + _onKeyDown: function (event) { + // If user is answering a text input, do not handle keydown + if (this.$("textarea").is(":focus") || this.$('input').is(':focus')) { return; } @@ -76,12 +91,17 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ var letter = String.fromCharCode(keyCode).toUpperCase(); // Handle Start / Next / Submit - if (keyCode === 13) { // Enter : go Next + if (keyCode === 13 || keyCode === 39) { // Enter or arrow-right: go Next event.preventDefault(); if (!this.preventEnterSubmit) { var isFinish = this.$('button[value="finish"]').length !== 0; this._submitForm({isFinish: isFinish}); } + } else if (keyCode === 37) { // arrow-left: previous (if available) + // It's easier to actually click on the button (if in the DOM) as it contains necessary + // data that are used in the event handler. + // Again, global selector necessary since the navigation is outside of the form. + $('.o_survey_navigation_submit[value="previous"]').click(); } else if (self.options.questionsLayout === 'page_per_question' && letter.match(/[a-z]/i)) { var $choiceInput = this.$(`input[data-selection-key=${letter}]`); @@ -91,6 +111,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } else { $choiceInput.prop("checked", !$choiceInput.prop("checked")).trigger('change'); } + // Avoid selection key to be typed into the textbox if 'other' is selected by key event.preventDefault(); } @@ -99,8 +120,8 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ /** * Checks, if the 'other' choice is checked. Applies only if the comment count as answer. - * If not checked : Clear the comment textarea and disable it - * If checked : enable the comment textarea and focus on it + * If not checked : Clear the comment textarea, hide and disable it + * If checked : enable the comment textarea, show and focus on it * * @private * @param {Event} event @@ -114,19 +135,32 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ if ($otherItem.prop('checked') || $commentInput.hasClass('o_survey_comment')) { $commentInput.enable(); + $commentInput.closest('.o_survey_comment_container').removeClass('d-none'); if ($otherItem.prop('checked')) { $commentInput.focus(); } } else { $commentInput.val(''); + $commentInput.closest('.o_survey_comment_container').addClass('d-none'); $commentInput.enable(false); } var $matrixBtn = $target.closest('.o_survey_matrix_btn'); if ($target.attr('type') === 'radio') { + var isQuestionComplete = false; if ($matrixBtn.length > 0) { $matrixBtn.closest('tr').find('td').removeClass('o_survey_selected'); $matrixBtn.addClass('o_survey_selected'); + if (this.options.questionsLayout === 'page_per_question') { + var subQuestionsIds = $matrixBtn.closest('table').data('subQuestions'); + var completedQuestions = []; + subQuestionsIds.forEach(function (id) { + if (self.$('tr#' + id).find('input:checked').length !== 0) { + completedQuestions.push(id); + } + }); + isQuestionComplete = completedQuestions.length === subQuestionsIds.length; + } } else { var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected'); previouslySelectedAnswer.removeClass('o_survey_selected'); @@ -134,6 +168,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ var newlySelectedAnswer = $target.closest('label'); if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) { newlySelectedAnswer.addClass('o_survey_selected'); + isQuestionComplete = this.options.questionsLayout === 'page_per_question'; } // Conditional display @@ -165,6 +200,11 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } } } + // Submit Form + var isLastQuestion = this.$('button[value="finish"]').length !== 0; + if (!isLastQuestion && this.options.usersCanGoBack && isQuestionComplete) { + this._submitForm({}); + } } else { // $target.attr('type') === 'checkbox' if ($matrixBtn.length > 0) { $matrixBtn.toggleClass('o_survey_selected', !$matrixBtn.hasClass('o_survey_selected')); @@ -234,18 +274,18 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ * If the trigger is 'next_question', we handle some extra computation to find * a suitable "fadeInOutDelay" based on the delay between the time of the question * change by the host and the time of reception of the event. - * This will allow us to account for a little bit of server lag (up to 2 seconds) + * This will allow us to account for a little bit of server lag (up to 1 second) * while giving everyone a fair experience on the quiz. * * e.g 1: * - The host switches the question - * - We receive the event 500 ms later due to server lag - * - -> The fadeInOutDelay will be 750 ms (500ms delay + 750ms * 2 fade in fade out) + * - We receive the event 200 ms later due to server lag + * - -> The fadeInOutDelay will be 400 ms (200ms delay + 400ms * 2 fade in fade out) * * e.g 2: * - The host switches the question - * - We receive the event 1500 ms later due to bigger server lag - * - -> The fadeInOutDelay will be 250ms (1500ms delay + 250ms * 2 fade in fade out) + * - We receive the event 600 ms later due to bigger server lag + * - -> The fadeInOutDelay will be 200ms (600ms delay + 200ms * 2 fade in fade out) * * @private * @param {Array[]} notifications structured as specified by the bus feature @@ -275,14 +315,16 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ var serverDelayMS = moment.utc().valueOf() - moment.unix(nextPageEvent.question_start).utc().valueOf(); if (serverDelayMS < 0) { serverDelayMS = 0; - } else if (serverDelayMS > 2000) { - serverDelayMS = 2000; + } else if (serverDelayMS > 1000) { + serverDelayMS = 1000; } - this.fadeInOutDelay = (2000 - serverDelayMS) / 2; + this.fadeInOutDelay = (1000 - serverDelayMS) / 2; } else { this.fadeInOutDelay = 400; } + this.$('.o_survey_main_title:visible').fadeOut(400); + this.preventEnterSubmit = false; this.readonly = false; this._nextScreen( @@ -324,6 +366,10 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ if (this.options.isStartScreen) { route = "/survey/begin"; + // Hide survey title in 'page_per_question' layout: it takes too much space + if (this.options.questionsLayout === 'page_per_question') { + this.$('.o_survey_main_title').fadeOut(400); + } } else { var $form = this.$('form'); var formData = new FormData($form[0]); @@ -396,7 +442,18 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ if (result && !result.error) { this.$(".o_survey_form_content").empty(); - this.$(".o_survey_form_content").html(result); + this.$(".o_survey_form_content").html(result.survey_content); + + if (result.survey_progress && this.$surveyProgress.length !== 0) { + this.$surveyProgress.html(result.survey_progress); + } else if (options.isFinish && this.$surveyProgress.length !== 0) { + this.$surveyProgress.remove(); + } + + if (result.survey_navigation && this.$surveyNavigation.length !== 0) { + this.$surveyNavigation.html(result.survey_navigation); + this.$surveyNavigation.find('.o_survey_navigation_submit').on('click', self._onSubmit.bind(self)); + } // 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) { diff --git a/addons/survey/static/src/js/survey_quick_access.js b/addons/survey/static/src/js/survey_quick_access.js index 0534aa41878..ff0f3e88473 100644 --- a/addons/survey/static/src/js/survey_quick_access.js +++ b/addons/survey/static/src/js/survey_quick_access.js @@ -23,6 +23,8 @@ publicWidget.registry.SurveyQuickAccessWidget = publicWidget.Widget.extend({ if (!self.readonly) { $(document).on('keypress', self._onKeyPress.bind(self)); } + + self.$('input').focus(); }); }, @@ -48,9 +50,9 @@ publicWidget.registry.SurveyQuickAccessWidget = publicWidget.Widget.extend({ _submitCode: function () { var self = this; this.$('.o_survey_error').addClass("d-none"); - var $accessCodeInput = this.$('input#access_code'); + var $sessionCodeInput = this.$('input#session_code'); this._rpc({ - route: `/survey/check_access_code/${$accessCodeInput.val()}`, + route: `/survey/check_session_code/${$sessionCodeInput.val()}`, }).then(function (response) { if (response.survey_url) { window.location = response.survey_url; diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js index 4a1fdf13798..897aab6d7b3 100644 --- a/addons/survey/static/src/js/survey_session_manage.js +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -304,7 +304,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ var resolveFadeOut; var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); - this.$el.fadeOut(1000, function () { + this.$el.fadeOut(500, function () { resolveFadeOut(); }); @@ -323,7 +323,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ var $renderedTemplate = $(results[1]); self.$el.replaceWith($renderedTemplate); self.attachTo($renderedTemplate); - self.$el.fadeIn(1000, function () { + self.$el.fadeIn(500, function () { self._startTimer(); }); } else { @@ -376,7 +376,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ _refreshResults: function () { var self = this; - this._rpc({ + return this._rpc({ route: _.str.sprintf('/survey/session/results/%s', self.surveyAccessToken) }).then(function (questionResults) { if (questionResults) { @@ -398,6 +398,8 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ ); } } + + return Promise.resolve(); }, function () { // on failure, stop refreshing clearInterval(self.resultsRefreshInterval); @@ -412,7 +414,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ _refreshAttendeesCount: function () { var self = this; - self._rpc({ + return self._rpc({ model: 'survey.survey', method: 'read', args: [[self.surveyId], ['session_answer_count']], diff --git a/addons/survey/static/src/scss/survey_form.scss b/addons/survey/static/src/scss/survey_form.scss index aaa62d7f38f..97b1343b2fe 100644 --- a/addons/survey/static/src/scss/survey_form.scss +++ b/addons/survey/static/src/scss/survey_form.scss @@ -18,7 +18,24 @@ nav#oe_main_menu_navbar { min-height: 100%; } -.o_survey_form, .o_survey_print, .o_survey_quick_access { +.o_survey_progress_wrapper { + min-width: 11rem; + max-width: 15rem; + + .o_survey_progress { + height:0.5em; + } +} + +.o_survey_navigation_wrapper .o_survey_navigation_submit { + cursor: pointer; +} + +.o_survey_brand_message { + background-color: rgba(255,255,255,0.7); +} + +.o_survey_form, .o_survey_print, .o_survey_session_manage, .o_survey_quick_access { .o_survey_question_error { height: 0px; transition: height .5s ease; @@ -42,27 +59,27 @@ nav#oe_main_menu_navbar { .o_survey_question_datetime, .o_survey_question_numerical_box { border: 0px; - border-bottom: 2px solid $primary; + border-bottom: 1px solid $primary; &:disabled { color: black !important; border-color: $gray-600; - border-bottom: 2px solid $gray-600; + border-bottom: 1px solid $gray-600; } &:focus { - border-color: $primary; + box-shadow: none; } } .o_survey_form_date .input-group-append { right: 0; - .input-group-text i { - font-size: large; - } + bottom: 5px; + top: auto; } .o_survey_choice_btn { transition: background-color 0.3s ease; flex: 1 0 300px; + color: $primary; span { line-height: 25px; @@ -83,6 +100,24 @@ nav#oe_main_menu_navbar { } } + input::placeholder, textarea::placeholder { + font-weight: 300; + } + + .o_survey_page_per_question.o_survey_simple_choice.o_survey_minimized_display, + .o_survey_page_per_question.o_survey_multiple_choice.o_survey_minimized_display, + .o_survey_page_per_question.o_survey_numerical_box, + .o_survey_page_per_question.o_survey_date, + .o_survey_page_per_question.o_survey_datetime { + // 'pixel perfect' layouting for choice questions having less than 5 choices in page_per_question mode + // we use media queries instead of bootstrap classes because they don't provide everything needed here + @media (min-width: 768px) { + width: 50%; + position: relative; + left: 25%; + } + } + .o_survey_question_matrix { td { min-width: 100px; @@ -93,8 +128,13 @@ nav#oe_main_menu_navbar { display: inline; } } - .o_survey_choice_key > span > span { - top: 0px; + .o_survey_choice_key { + left: 10px; + right: auto; + top: 12px; + > span > span { + top: 0px; + } } &.o_survey_selected { @@ -106,6 +146,33 @@ nav#oe_main_menu_navbar { } } } + thead { + th:first-child { + border-top-left-radius: .25rem; + } + th:last-child { + border-top-right-radius: .25rem; + } + } + tbody tr:last-child { + th { + border-bottom-left-radius: .25rem; + } + td:last-child { + border-bottom-right-radius: .25rem; + } + } + } +} + +.o_survey_form, .o_survey_session_manage { + .o_survey_question_matrix { + th { + background-color: $primary; + } + td { + background-color: rgba($primary, 0.2); + } } } @@ -114,23 +181,19 @@ nav#oe_main_menu_navbar { **********************************************************/ .o_survey_form { - min-height: 100%; - - .o_survey_progress { - height:0.5em; - } + min-height: 25rem; .o_survey_choice_btn { cursor: pointer; - background-color: rgba($primary, 0.5); - border: 2px solid $primary; + background-color: rgba($primary, 0.1); + box-shadow: $primary 0px 0px 0px 1px; &.o_survey_selected { - background-color: $primary; + box-shadow: $primary 0px 0px 0px 2px; } &:hover { - background-color: rgba($primary, 0.8); + background-color: rgba($primary, 0.3); .o_survey_choice_key span.o_survey_key { opacity: 1; } @@ -140,36 +203,31 @@ nav#oe_main_menu_navbar { .o_survey_choice_key { width: 25px; height: 25px; - border: 2px solid $primary; + border: 1px solid $primary; span { font-size: smaller; - top: -2px; + top: -1px; &.o_survey_key { width: inherit; right: 10px; - border: 2px solid $primary; + border: 1px solid $primary; border-right: 0px; height: 25px; transition: opacity 0.4s ease; white-space: nowrap; opacity: 0; + span { + top: -2px; + } } } } - .o_survey_question_matrix { - th { - background-color: $primary; - } - td { - background-color: rgba($primary, 0.2); - &:hover { - background-color: rgba($primary, 0.5); - cursor: pointer; - .o_survey_choice_key span.o_survey_key { - opacity: 1; - } - } + .o_survey_question_matrix td:hover { + background-color: rgba($primary, 0.5); + cursor: pointer; + .o_survey_choice_key span.o_survey_key { + opacity: 1; } } } @@ -216,8 +274,8 @@ nav#oe_main_menu_navbar { height: 100vh; width: 85vw; - .js_question-wrapper { - padding-bottom: 0px; + .o_survey_choice_key { + display: none; } &.pt-6 { @@ -315,6 +373,8 @@ nav#oe_main_menu_navbar { background-color: $gray-500; border-color: transparent; cursor: default; + color: white; // not bootstrap to customize for survey_print only + font-weight: bold; // not bootstrap to customize for survey_print only &.bg-success, &.bg-danger { opacity: 0.6; @@ -323,7 +383,7 @@ nav#oe_main_menu_navbar { background-color: $gray-600; opacity: 1; } - i.fa-square, i.fa-circle { + i.fa-square-o, i.fa-circle-thin { display: none; } } diff --git a/addons/survey/static/tests/tours/survey_session_manage_test.js b/addons/survey/static/tests/tours/survey_session_manage_test.js new file mode 100644 index 00000000000..acf3d99c945 --- /dev/null +++ b/addons/survey/static/tests/tours/survey_session_manage_test.js @@ -0,0 +1,19 @@ +odoo.define('survey.session_manage_test', function (require) { +"use strict"; + +var SessionManager = require('survey.session_manage'); +/** + * Small override for test/tour purposes. + * We trigger the fetch of answer results immediately at the start. + * (Instead of wasting 2 seconds waiting after the start). + */ +SessionManager.include({ + start: function () { + return this._super.apply(this, arguments) + .then(this._refreshResults.bind(this)); + } +}); + +return SessionManager; + +}); diff --git a/addons/survey/static/tests/tours/survey_tour_session_create.js b/addons/survey/static/tests/tours/survey_tour_session_create.js new file mode 100644 index 00000000000..ac0c2f275d9 --- /dev/null +++ b/addons/survey/static/tests/tours/survey_tour_session_create.js @@ -0,0 +1,20 @@ +odoo.define('survey.test_survey_session_create_tour', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); +var surveySessionTools = require('survey.session_tour_tools'); + +/** + * Small tour that will simply start the session and wait for attendees. + */ +tour.register('test_survey_session_create_tour', { + url: "/web", + test: true, +}, [].concat(surveySessionTools.accessSurveySteps, [{ + trigger: 'button[name="action_start_session"]', +}, { + trigger: '.o_survey_session_attendees_count:contains("0")', + run: function () {} // check session is correctly started +}])); + +}); diff --git a/addons/survey/static/tests/tours/survey_tour_session_manage.js b/addons/survey/static/tests/tours/survey_tour_session_manage.js new file mode 100644 index 00000000000..36b5d8dfeca --- /dev/null +++ b/addons/survey/static/tests/tours/survey_tour_session_manage.js @@ -0,0 +1,440 @@ +odoo.define('survey.test_survey_session_manage_tour', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); +var surveySessionTools = require('survey.session_tour_tools'); + +/** + * Since the chart is rendered using SVG, we can't use jQuery triggers to check if everything + * is correctly rendered. + * This helper method returns the chart data (Chartjs framework specific) in the following structure: + * [{ value, backgroundColor, labelColor }] + */ +var getChartData = function () { + var chartData = []; + var rootWidget = odoo.__DEBUG__.services['root.widget']; + var surveyManagePublicWidget = rootWidget.publicWidgets.find(function (widget) { + return widget.$el.hasClass('o_survey_session_manage'); + }); + + if (!surveyManagePublicWidget) { + return chartData; + } + + surveyManagePublicWidget.resultsChart.chart.data.datasets[0].data.forEach(function (value, index) { + chartData.push({ + value: value, + backgroundColor: surveyManagePublicWidget.resultsChart._getBackgroundColor({dataIndex: index}), + labelColor: surveyManagePublicWidget.resultsChart._getLabelColor({dataIndex: index}), + }); + }); + + return chartData; +}; + +var nextScreen = function () { + var e = $.Event('keydown'); + e.keyCode = 39; // arrow-right + $(document).trigger(e); +}; + +var previousScreen = function () { + var e = $.Event('keydown'); + e.keyCode = 37; // arrow-left + $(document).trigger(e); +}; + +var REGULAR_ANSWER_COLOR = '#212529'; +var CORRECT_ANSWER_COLOR = '#2CBB70'; +var WRONG_ANSWER_COLOR = '#D9534F'; + +/** + * A 'regular' answer is an answer that is nor correct, nor incorrect. + * The check is based on the specific opacity (0.8) and color of those answers. + */ +var isRegularAnswer = function (answer) { + return answer.backgroundColor.includes('0.8') && + answer.labelColor === REGULAR_ANSWER_COLOR; +}; + +/** + * The check is based on the specific opacity (0.8) and color of correct answers. + */ +var isCorrectAnswer = function (answer) { + return answer.backgroundColor.includes('0.8') && + answer.labelColor === CORRECT_ANSWER_COLOR; +}; + +/** + * The check is based on the specific opacity (0.3) and color of incorrect answers. + */ +var isIncorrectAnswer = function (answer) { + return answer.backgroundColor.includes('0.3') && + answer.labelColor === WRONG_ANSWER_COLOR; +}; + +/** + * Tour that will test the whole survey session from the host point of view. + * + * Break down of the main points: + * - Open the 'session manager' (the session was already created by a previous tour) + * - Display the nickname question, and move to the next one (as answers are not displayed) + * - Check answers are correctly displayed for the 3 'simple' question types (text, date, datetime) + * - Move to the choice question and check that answers are displayed + * (The check is rather complex, see 'getChartData' for details) + * - If everything is correctly displayed, move to the next question + * - On the scored choice question, check that the screens are correctly chained: + * no results displayed -> results displayed -> correct/incorrect answers -> leaderboard + * - On the scored + timed multiple choice question, check the same than previous question, + * except that the results are supposed to be displayed automatically when the question timer runs out + * - Test the 'back' behavior and check that screens are reversed correctly + * - Check that our final leaderboard is correct based on attendees answers + * - Close the survey session + */ +tour.register('test_survey_session_manage_tour', { + url: "/web", + test: true, +}, [].concat(surveySessionTools.accessSurveySteps, [{ + trigger: 'button[name="action_open_session_manager"]', +}, { + trigger: 'h1:contains("Nickname")', + run: function () {} // check nickname question is displayed +}, { + trigger: 'h1', + run: nextScreen +}, { + trigger: 'h1:contains("Text Question")', + run: function () {} // check text question is displayed +}, { + trigger: '.o_survey_session_progress_small:contains("3 / 3")', + run: function () {} // check we have 3 answers +}, { + trigger: '.o_survey_session_text_answer_container:contains("Attendee 1 is the best")', + run: function () {} // check attendee 1 answer is displayed +}, { + trigger: '.o_survey_session_text_answer_container:contains("Attendee 2 rulez")', + run: function () {} // check attendee 2 answer is displayed +}, { + trigger: '.o_survey_session_text_answer_container:contains("Attendee 3 will crush you")', + run: function () {} // check attendee 3 answer is displayed +}, { + trigger: 'h1', + run: nextScreen +}, { + trigger: '.o_survey_session_progress_small:contains("2 / 3")', + run: function () {} // check we have 2 answers +}, { + trigger: '.o_survey_session_text_answer_container:contains("10/10/2010")', + run: function () {} // check attendee 1 answer is displayed +}, { + trigger: '.o_survey_session_text_answer_container:contains("11/11/2011")', + run: function () {} // check attendee 2 answer is displayed +}, { + trigger: 'h1', + run: nextScreen +}, { + trigger: '.o_survey_session_progress_small:contains("2 / 3")', + run: function () {} // check we have 2 answers +}, { + trigger: '.o_survey_session_text_answer_container:contains("10/10/2010 10:00:00")', + run: function () {} // check attendee 2 answer is displayed +}, { + trigger: '.o_survey_session_text_answer_container:contains("11/11/2011 15:55:55")', + run: function () {} // check attendee 3 answer is displayed +}, { + trigger: 'h1', + run: nextScreen +}, { + trigger: 'h1:contains("Regular Simple Choice")', + run: function () { + var chartData = getChartData(); + if (chartData.length !== 3) { + console.error('Chart data should contain 3 records!'); + return; + } + + var firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 2 || !isRegularAnswer(firstAnswerData)) { + console.error('First answer should be picked by 2 users!'); + return; + } + + var secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 1 || !isRegularAnswer(secondAnswerData)) { + console.error('Second answer should be picked by 1 user!'); + return; + } + + var thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 0 || !isRegularAnswer(thirdAnswerData)) { + console.error('Third answer should be picked by no users!'); + return; + } + + nextScreen(); + } +}, { + trigger: 'h1:contains("Scored Simple Choice")', + run: function () { + var chartData = getChartData(); + if (chartData.length !== 4) { + console.error('Chart data should contain 4 records!'); + return; + } + + for (var i = 0; i < chartData.length; i++) { + if (chartData[i].value !== 0) { + console.error( + 'Chart data should all be 0 because "next screen" that shows ' + + 'answers values is not triggered yet!'); + return; + } + } + + nextScreen(); + chartData = getChartData(); + + var firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 1 || !isRegularAnswer(firstAnswerData)) { + console.error( + 'First answer should be picked by 1 user and its correctness should not be shown yet!' + ); + return; + } + + var secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 1 || !isRegularAnswer(secondAnswerData)) { + console.error( + 'Second answer should be picked by 1 user and its correctness should not be shown yet!' + ); + return; + } + + var thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 1 || !isRegularAnswer(thirdAnswerData)) { + console.error( + 'Third answer should be picked by 1 user and its correctness should not be shown yet!' + ); + return; + } + + var fourthAnswerData = chartData[3]; + if (fourthAnswerData.value !== 0 || !isRegularAnswer(fourthAnswerData)) { + console.error( + 'Fourth answer should be picked by no users and its correctness should not be shown yet!' + ); + return; + } + + nextScreen(); + chartData = getChartData(); + + firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 1 || !isCorrectAnswer(firstAnswerData)) { + console.error( + 'First answer should be picked by 1 user and it should be correct!' + ); + return; + } + + secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 1 || !isIncorrectAnswer(secondAnswerData)) { + console.error( + 'Second answer should be picked by 1 user and it should be incorrect!' + ); + return; + } + + thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 1 || !isIncorrectAnswer(thirdAnswerData)) { + console.error( + 'Third answer should be picked by 1 user and it should be incorrect!' + ); + return; + } + + fourthAnswerData = chartData[3]; + if (fourthAnswerData.value !== 0 || !isIncorrectAnswer(fourthAnswerData)) { + console.error( + 'Fourth answer should be picked by no users and it should be incorrect!' + ); + return; + } + + nextScreen(); + nextScreen(); + } +}, { + trigger: 'h1:contains("Timed Scored Multiple Choice")', + run: function () { + var chartData = getChartData(); + if (chartData.length !== 3) { + console.error('Chart data should contain 4 records!'); + return; + } + + for (var i = 0; i < chartData.length; i++) { + if (chartData[i].value !== 0) { + console.error( + 'Chart data should all be 0 because "next screen" that shows ' + + 'answers values is not triggered yet!'); + return; + } + } + + // after 1 second, results are displayed automatically because question timer + // runs out + setTimeout(function () { + chartData = getChartData(); + var firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 2 || !isRegularAnswer(firstAnswerData)) { + console.error( + 'First answer should be picked by 2 users and its correctness should not be shown yet!' + ); + return; + } + + var secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 2 || !isRegularAnswer(secondAnswerData)) { + console.error( + 'Second answer should be picked by 2 users and its correctness should not be shown yet!' + ); + return; + } + + var thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 1 || !isRegularAnswer(thirdAnswerData)) { + console.error( + 'Third answer should be picked by 1 user and its correctness should not be shown yet!' + ); + return; + } + + nextScreen(); + chartData = getChartData(); + + firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 2 || !isCorrectAnswer(firstAnswerData)) { + console.error( + 'First answer should be picked by 2 users and it should be correct!' + ); + return; + } + + secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 2 || !isCorrectAnswer(secondAnswerData)) { + console.error( + 'Second answer should be picked by 2 users and it should be correct!' + ); + return; + } + + thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 1 || !isIncorrectAnswer(thirdAnswerData)) { + console.error( + 'Third answer should be picked by 1 user and it should be incorrect!' + ); + return; + } + + nextScreen(); + }, 1500); + } +}, { + trigger: 'h1:contains("Final Leaderboard")', + run: function () {} // Final Leaderboard is displayed +}, { + trigger: 'h1', + run: function () { + // previous screen testing + previousScreen(); + var chartData = getChartData(); + + var firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 2 || !isCorrectAnswer(firstAnswerData)) { + console.error( + 'First answer should be picked by 2 users and it should be correct!' + ); + return; + } + + var secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 2 || !isCorrectAnswer(secondAnswerData)) { + console.error( + 'Second answer should be picked by 2 users and it should be correct!' + ); + return; + } + + var thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 1 || !isIncorrectAnswer(thirdAnswerData)) { + console.error( + 'Third answer should be picked by 1 user and it should be incorrect!' + ); + return; + } + + previousScreen(); + chartData = getChartData(); + + firstAnswerData = chartData[0]; + if (firstAnswerData.value !== 2 || !isRegularAnswer(firstAnswerData)) { + console.error( + 'First answer should be picked by 2 users and its correctness should not be shown!' + ); + return; + } + + secondAnswerData = chartData[1]; + if (secondAnswerData.value !== 2 || !isRegularAnswer(secondAnswerData)) { + console.error( + 'Second answer should be picked by 2 users and its correctness should not be shown!' + ); + return; + } + + thirdAnswerData = chartData[2]; + if (thirdAnswerData.value !== 1 || !isRegularAnswer(thirdAnswerData)) { + console.error( + 'Third answer should be picked by 1 user and its correctness should not be shown!' + ); + return; + } + + previousScreen(); + chartData = getChartData(); + + for (var i = 0; i < chartData.length; i++) { + if (chartData[i].value !== 0) { + console.error( + 'Chart data should all be 0 because "next screen" that shows ' + + 'answers values is not triggered yet!'); + return; + } + } + + // Now we go forward to the "Final Leaderboard" again (4 times) + for (i = 0; i < 4; i++) { + nextScreen(); + } + } +}, { + trigger: 'h1:contains("Final Leaderboard")', + run: function () {} // Final Leaderboard is displayed +}, { + trigger: '.o_survey_session_leaderboard_item:contains("azerty"):contains("1."):contains("40")', + run: function () {} // 'azerty' is first with 40 points +}, { + trigger: '.o_survey_session_leaderboard_item:contains("xxxTheB"):contains("2."):contains("20")', + run: function () {} // 'xxxTheBestxxx' is second with 20 points +}, { + trigger: '.o_survey_session_leaderboard_item:contains("nicktalope"):contains("3."):contains("10")', + run: function () {} // 'nicktalope' is third with 10 points +}, { + trigger: '.o_survey_session_close:has("i.fa-close")' +}, { + trigger: 'button[name="action_start_session"]', + run: function () {} // check that we can start another session +}])); + +}); diff --git a/addons/survey/static/tests/tours/survey_tour_session_start.js b/addons/survey/static/tests/tours/survey_tour_session_start.js new file mode 100644 index 00000000000..309ce0afaeb --- /dev/null +++ b/addons/survey/static/tests/tours/survey_tour_session_start.js @@ -0,0 +1,32 @@ +odoo.define('survey.test_survey_session_start_tour', function (require) { +"use strict"; + +var tour = require('web_tour.tour'); +var surveySessionTools = require('survey.session_tour_tools'); + +/** + * Small tour that will open the session manager and check + * that the attendees are accounted for, then start the session + * by going to the first question. + */ +tour.register('test_survey_session_start_tour', { + url: "/web", + test: true, +}, [].concat(surveySessionTools.accessSurveySteps, [{ + trigger: 'button[name="action_open_session_manager"]', +}, { + trigger: '.o_survey_session_attendees_count:contains("3")', + run: function () {} // check attendees count +}, { + trigger: 'h1', + run: function () { + var e = $.Event('keydown'); + e.keyCode = 39; // arrow-right + $(document).trigger(e); // start session + } +}, { + trigger: 'h1:contains("Nickname")', + run: function () {} // check first question is displayed +}])); + +}); diff --git a/addons/survey/static/tests/tours/survey_tour_session_tools.js b/addons/survey/static/tests/tours/survey_tour_session_tools.js new file mode 100644 index 00000000000..a42d72e26cf --- /dev/null +++ b/addons/survey/static/tests/tours/survey_tour_session_tools.js @@ -0,0 +1,21 @@ +odoo.define('survey.session_tour_tools', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); + +/** + * Tool that gathers common steps to every 'survey session' tours. + */ +return { + accessSurveySteps: [tour.stepUtils.showAppsMenuItem(), { + trigger: '.o_app[data-menu-xmlid="survey.menu_surveys"]', + edition: 'community' + }, { + trigger: '.o_app[data-menu-xmlid="survey.menu_surveys"]', + edition: 'enterprise' + }, { + trigger: '.oe_kanban_card:contains("User Session Survey")', + }] +}; + +}); diff --git a/addons/survey/tests/__init__.py b/addons/survey/tests/__init__.py index 76df1c8efa8..a7d6dc8d60a 100644 --- a/addons/survey/tests/__init__.py +++ b/addons/survey/tests/__init__.py @@ -10,5 +10,6 @@ from . import test_survey_security from . import test_survey_randomize from . import test_survey_ui_certification from . import test_survey_ui_feedback +from . import test_survey_ui_session from . import test_survey_compute_pages_questions from . import test_certification_badge diff --git a/addons/survey/tests/test_survey_ui_session.py b/addons/survey/tests/test_survey_ui_session.py new file mode 100644 index 00000000000..53dd81ea62b --- /dev/null +++ b/addons/survey/tests/test_survey_ui_session.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.tests.common import tagged, HttpCase + + +@tagged('post_install', '-at_install') +class TestUiSession(HttpCase): + def test_admin_survey_session(self): + """ This method tests a full 'survey session' flow. + Break down of different steps: + - Create the test data + - A scored survey + - A nickname question + - "Simple" type questions (text, date, datetime) + - A regular simple choice + - A scored simple choice + - A scored AND timed multiple choice + - Create a new survey session + - Register 3 attendees to it + - Open the session manager to check that our attendees are accounted for + - Create some answers to our survey questions. + - Then run the 'big' manage session tour (see JS doc for details) + - And finally check that our session and attendees inputs are correctly closed. """ + + # ======================= + # CREATE SURVEY TEST DATA + # ======================= + + test_start_time = fields.Datetime.now() + + survey_session = self.env['survey.survey'].create({ + 'title': 'User Session Survey', + 'access_token': 'b137640d-14d4-4748-9ef6-344caaaaafe', + 'state': 'open', + 'access_mode': 'public', + 'users_can_go_back': False, + 'questions_layout': 'page_per_question', + 'scoring_type': 'scoring_without_answers' + }) + + nickname_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Nickname', + 'save_as_nickname': True, + 'sequence': 1, + 'question_type': 'char_box', + }) + text_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Text Question', + 'sequence': 2, + 'question_type': 'char_box', + }) + date_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Date Question', + 'sequence': 3, + 'question_type': 'date', + }) + datetime_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Datetime Question', + 'sequence': 4, + 'question_type': 'datetime', + }) + simple_choice_answer_1 = self.env['survey.question.answer'].create({ + 'value': 'First' + }) + simple_choice_answer_2 = self.env['survey.question.answer'].create({ + 'value': 'Second' + }) + simple_choice_answer_3 = self.env['survey.question.answer'].create({ + 'value': 'Third' + }) + simple_choice_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Regular Simple Choice', + 'sequence': 5, + 'question_type': 'simple_choice', + 'suggested_answer_ids': [ + (4, simple_choice_answer_1.id), + (4, simple_choice_answer_2.id), + (4, simple_choice_answer_3.id)], + }) + scored_choice_answer_1 = self.env['survey.question.answer'].create({ + 'value': 'Correct', + 'is_correct': True, + 'answer_score': 30 + }) + scored_choice_answer_2 = self.env['survey.question.answer'].create({ + 'value': 'Incorrect 1' + }) + scored_choice_answer_3 = self.env['survey.question.answer'].create({ + 'value': 'Incorrect 2' + }) + scored_choice_answer_4 = self.env['survey.question.answer'].create({ + 'value': 'Incorrect 3' + }) + scored_choice_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Scored Simple Choice', + 'sequence': 6, + 'question_type': 'simple_choice', + 'suggested_answer_ids': [ + (4, scored_choice_answer_1.id), + (4, scored_choice_answer_2.id), + (4, scored_choice_answer_3.id), + (4, scored_choice_answer_4.id)], + }) + timed_scored_choice_answer_1 = self.env['survey.question.answer'].create({ + 'value': 'Correct', + 'is_correct': True, + 'answer_score': 30 + }) + timed_scored_choice_answer_2 = self.env['survey.question.answer'].create({ + 'value': 'Also correct but less points', + 'is_correct': True, + 'answer_score': 10 + }) + timed_scored_choice_answer_3 = self.env['survey.question.answer'].create({ + 'value': 'Incorrect', + 'answer_score': -40 + }) + timed_scored_choice_question = self.env['survey.question'].create({ + 'survey_id': survey_session.id, + 'title': 'Timed Scored Multiple Choice', + 'sequence': 6, + 'question_type': 'multiple_choice', + 'is_time_limited': True, + 'time_limit': 1, + 'suggested_answer_ids': [ + (4, timed_scored_choice_answer_1.id), + (4, timed_scored_choice_answer_2.id), + (4, timed_scored_choice_answer_3.id)], + }) + + # ======================= + # PART 1 : CREATE SESSION + # ======================= + + self.start_tour('/web', 'test_survey_session_create_tour', login='admin') + + # tricky part: we only take into account answers created after the session_start_time + # the create_date of the answers we just saved is set to the beginning of the test. + # but the session_start_time is set after that. + # So we cheat on the session start date to be able to count answers properly. + survey_session.write({'session_start_time': test_start_time - relativedelta(minutes=10)}) + + attendee_1 = survey_session._create_answer() + attendee_2 = survey_session._create_answer() + attendee_3 = survey_session._create_answer() + all_attendees = [attendee_1, attendee_2, attendee_3] + + self.assertEqual('ready', survey_session.session_state) + self.assertTrue(all(attendee.is_session_answer for attendee in all_attendees), + "Created answers should be within the session.") + self.assertTrue(all(attendee.state == 'new' for attendee in all_attendees), + "Created answers should be in the 'new' state.") + + # ========================================= + # PART 2 : OPEN SESSION AND CHECK ATTENDEES + # ========================================= + + self.start_tour('/web', 'test_survey_session_start_tour', login='admin') + + self.assertEqual('in_progress', survey_session.session_state) + self.assertTrue(bool(survey_session.session_start_time)) + + # ======================================== + # PART 3 : CREATE ANSWERS & MANAGE SESSION + # ======================================== + + # create a few answers beforehand to avoid having to back and forth too + # many times between the tours and the python test + + attendee_1.save_lines(nickname_question, 'xxxTheBestxxx') + attendee_2.save_lines(nickname_question, 'azerty') + attendee_3.save_lines(nickname_question, 'nicktalope') + self.assertEqual('xxxTheBestxxx', attendee_1.nickname) + self.assertEqual('azerty', attendee_2.nickname) + self.assertEqual('nicktalope', attendee_3.nickname) + + attendee_1.save_lines(text_question, 'Attendee 1 is the best') + attendee_2.save_lines(text_question, 'Attendee 2 rulez') + attendee_3.save_lines(text_question, 'Attendee 3 will crush you') + attendee_1.save_lines(date_question, '2010-10-10') + attendee_2.save_lines(date_question, '2011-11-11') + attendee_2.save_lines(datetime_question, '2010-10-10 10:00:00') + attendee_3.save_lines(datetime_question, '2011-11-11 15:55:55') + attendee_1.save_lines(simple_choice_question, simple_choice_answer_1.id) + attendee_2.save_lines(simple_choice_question, simple_choice_answer_1.id) + attendee_3.save_lines(simple_choice_question, simple_choice_answer_2.id) + attendee_1.save_lines(scored_choice_question, scored_choice_answer_1.id) + attendee_2.save_lines(scored_choice_question, scored_choice_answer_2.id) + attendee_3.save_lines(scored_choice_question, scored_choice_answer_3.id) + attendee_1.save_lines(timed_scored_choice_question, + [timed_scored_choice_answer_1.id, timed_scored_choice_answer_3.id]) + attendee_2.save_lines(timed_scored_choice_question, + [timed_scored_choice_answer_1.id, timed_scored_choice_answer_2.id]) + attendee_3.save_lines(timed_scored_choice_question, + [timed_scored_choice_answer_2.id]) + + self.start_tour('/web', 'test_survey_session_manage_tour', login='admin') + + self.assertFalse(bool(survey_session.session_state)) + self.assertTrue(all(answer.state == 'done' for answer in all_attendees)) diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index 19aaac1a2a0..a05c8f9340e 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -58,6 +58,11 @@ + + + + + diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index 90dde68c632..0be4d340b02 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -14,11 +14,11 @@