diff --git a/addons/survey/__manifest__.py b/addons/survey/__manifest__.py index 09c79a0f211..8eb338b3704 100644 --- a/addons/survey/__manifest__.py +++ b/addons/survey/__manifest__.py @@ -38,6 +38,7 @@ sent mails with personal token for the invitation of the survey. 'views/survey_templates_management.xml', 'views/survey_templates_print.xml', 'views/survey_templates_statistics.xml', + 'views/survey_templates_user_input_session.xml', 'views/gamification_badge_views.xml', 'wizard/survey_invite_views.xml', 'views/res_partner_views.xml', diff --git a/addons/survey/controllers/__init__.py b/addons/survey/controllers/__init__.py index e425c18c14b..0bbff652287 100644 --- a/addons/survey/controllers/__init__.py +++ b/addons/survey/controllers/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main +from . import survey_session_manage diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 5f47305afa0..7f512549c72 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -40,6 +40,16 @@ 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 @@ -140,6 +150,34 @@ 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 + # ------------------------------------------------------------ + @http.route('/survey/test/', type='http', auth='user', website=True) def survey_test(self, survey_token, **kwargs): """ Test mode for surveys: create a test answer, only for managers or officers @@ -237,6 +275,12 @@ class Survey(http.Controller): 'format_date': lambda date: format_date(request.env, date) } + if not answer_sudo.is_session_answer and survey_sudo.is_time_limited and answer_sudo.start_datetime: + data.update({ + 'timer_start': answer_sudo.start_datetime.isoformat(), + 'time_limit_minutes': survey_sudo.time_limit + }) + page_or_question_key = 'question' if survey_sudo.questions_layout == 'page_per_question' else 'page' # Bypass all if page_id is specified (comes from breadcrumb or previous button) @@ -245,17 +289,28 @@ class Survey(http.Controller): new_previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, previous_page_or_question_id) data.update({ page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id), - 'previous_page_id': new_previous_id + 'previous_page_id': new_previous_id, + 'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id.id == new_previous_id) }) return data if answer_sudo.state == 'in_progress': - page_or_question_id, is_last = survey_sudo.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: + page_or_question_id, is_last = survey_sudo.session_question_id, False + else: + page_or_question_id, is_last = survey_sudo.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: + data.update({ + 'timer_start': survey_sudo.session_question_start_time.isoformat(), + 'time_limit_minutes': page_or_question_id.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) }) if survey_sudo.questions_layout != 'one_page': data.update({ @@ -263,7 +318,8 @@ class Survey(http.Controller): }) if is_last: data.update({'last': True}) - elif answer_sudo.state == 'done' or answer_sudo.is_time_limit_reached: # Display success message + 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) return data @@ -332,11 +388,21 @@ class Survey(http.Controller): if answer_sudo.state != "new": return {'error': _("The survey has already started.")} - # init start date when user starts filling in the survey - answer_sudo.write({ - 'start_datetime': fields.Datetime.now(), - 'state': 'in_progress' - }) + answer_sudo._mark_in_progress() + return self._prepare_question_html(survey_sudo, answer_sudo, **post) + + @http.route('/survey/next_question//', type='json', auth='public', website=True) + def survey_next_question(self, survey_token, answer_token, **post): + """ Method used to display the next survey question in an ongoing session. + Triggered on all attendees screens when the host goes to the next question. """ + access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) + if access_data['validity_code'] is not True: + return {'error': access_data['validity_code']} + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + + if answer_sudo.state == 'new' and answer_sudo.is_session_answer: + answer_sudo._mark_in_progress() + return self._prepare_question_html(survey_sudo, answer_sudo, **post) @http.route('/survey/submit//', type='json', auth='public', website=True) @@ -362,9 +428,16 @@ class Survey(http.Controller): # prevent cheating with users creating multiple 'user_input' before their last attempt return {'error': 'unauthorized'} - if answer_sudo.is_time_limit_reached: - time_limit = answer_sudo.start_datetime + timedelta(minutes=survey_sudo.time_limit) - if fields.Datetime.now() > (time_limit + timedelta(seconds=10)): + if answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached: + if answer_sudo.question_time_limit_reached: + time_limit = survey_sudo.session_question_start_time + relativedelta( + seconds=survey_sudo.session_question_id.time_limit + ) + time_limit += timedelta(seconds=3) + else: + time_limit = answer_sudo.start_datetime + timedelta(minutes=survey_sudo.time_limit) + time_limit += timedelta(seconds=10) + if fields.Datetime.now() > time_limit: # prevent cheating with users blocking the JS timer and taking all their time to answer return {'error': 'unauthorized'} @@ -376,27 +449,36 @@ class Survey(http.Controller): if not errors.get(question.id): answer_sudo.save_lines(question, answer, comment) - if errors and not answer_sudo.is_time_limit_reached: + if errors and not (answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached): return {'error': 'validation', 'fields': errors} - if answer_sudo.is_time_limit_reached or survey_sudo.questions_layout == 'one_page': + if answer_sudo.survey_time_limit_reached or survey_sudo.questions_layout == 'one_page': answer_sudo._mark_done() elif 'previous_page_id' in post: # Go back to specific page using the breadcrumb. Lines are saved and survey continues return self._prepare_question_html(survey_sudo, answer_sudo, **post) else: - next_page, unused = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id) vals = {'last_displayed_page_id': page_or_question_id} - - if next_page is None: - answer_sudo._mark_done() - else: - vals.update({'state': 'in_progress'}) + 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: + answer_sudo._mark_done() answer_sudo.write(vals) 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 @@ -523,7 +605,7 @@ class Survey(http.Controller): survey_data = survey._prepare_statistics(user_input_lines) question_and_page_data = survey.question_and_page_ids._prepare_statistics(user_input_lines) - return request.render('survey.survey_page_statistics', { + template_values = { # survey and its statistics 'survey': survey, 'question_and_page_data': question_and_page_data, @@ -531,7 +613,12 @@ class Survey(http.Controller): # search 'search_filters': search_filters, 'search_finished': post.get('finished') == 'true', - }) + } + + if survey.session_show_ranking: + template_values['ranking'] = survey._prepare_ranking_values() + + return request.render('survey.survey_page_statistics', template_values) def _generate_report(self, user_input, download=True): report = request.env.ref('survey.certification_report').sudo().render_qweb_pdf([user_input.id], data={'report_type': 'pdf'})[0] diff --git a/addons/survey/controllers/survey_session_manage.py b/addons/survey/controllers/survey_session_manage.py new file mode 100644 index 00000000000..e245d11665a --- /dev/null +++ b/addons/survey/controllers/survey_session_manage.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.exceptions import NotFound +from werkzeug.urls import url_join + +from odoo import http +from odoo.http import request + + +class UserInputSession(http.Controller): + def _fetch_from_token(self, survey_token): + """ Check that given survey_token matches a survey 'access_token'. + 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)]) + + @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. + - If the state of the session is 'ready' + We render a template allowing the host to showcase the different options of the session + and to actually start the session. + - If the state of the session is 'in_progress' + We render a template allowing the host to show the question results, display the attendee + ranking or go to the next question of the session. """ + + survey = self._fetch_from_token(survey_token) + + if not survey or not survey.session_state: + # no open session + return NotFound() + + 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()) + }) + else: + template_values = self._prepare_manage_session_values(survey) + return request.render('survey.user_input_session_manage', template_values) + + @http.route('/survey/session/next_question/', type='json', auth='user', website=True) + def survey_session_next_question(self, survey_token, **kwargs): + """ 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. """ + + survey = self._fetch_from_token(survey_token) + + if not survey or not survey.session_state: + # no open session + return '' + + 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).decode('UTF-8') + + @http.route('/survey/session/results/', type='json', auth='user', website=True) + def survey_session_results(self, survey_token, **kwargs): + """ This route is called when the host shows the current question's results. + + It's not a regular 'request.render' route because we handle the display of results using + an AJAX request to be able to include the results in the currently displayed page. """ + + survey = self._fetch_from_token(survey_token) + + if not survey or survey.session_state != 'in_progress': + # no open session + return '' + + user_input_lines = request.env['survey.user_input.line'].search([ + ('survey_id', '=', survey.id), + ('question_id', '=', survey.session_question_id.id) + ]) + question_statistics = survey.session_question_id._prepare_statistics(user_input_lines)[0] + + return request.env.ref('survey.survey_page_statistics_question').render({ + 'page_record_limit': 10, + 'hide_question_title': True, + 'no_print_url': True, + 'survey': survey, + 'question': survey.session_question_id, + 'question_data': question_statistics, + }).decode('UTF-8') + + @http.route('/survey/session/ranking/', type='json', auth='user', website=True) + def survey_session_ranking(self, survey_token, **kwargs): + """ This route is called when the host shows the current question's attendees ranking. + + It's not a regular 'request.render' route because we handle the display of the ranking + using an AJAX request to be able to include the results in the currently displayed page. """ + + survey = self._fetch_from_token(survey_token) + + if not survey or survey.session_state != 'in_progress': + # no open session + return '' + + return request.env.ref('survey.user_input_session_ranking').render({ + 'animate_width': True, + 'ranking': survey._prepare_ranking_values() + }).decode('UTF-8') + + 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] + + return { + 'survey': survey, + 'is_last_question': is_last_question, + 'survey_url': url_join(survey.get_base_url(), survey.get_start_short_url()), + } diff --git a/addons/survey/data/survey_demo_feedback.xml b/addons/survey/data/survey_demo_feedback.xml index 8a86b8e295f..d97b4d84409 100644 --- a/addons/survey/data/survey_demo_feedback.xml +++ b/addons/survey/data/survey_demo_feedback.xml @@ -3,7 +3,7 @@ Feedback Form - b137640d-14d4-4748-9ef6-344ca256531e + b135640d-14d4-4748-9ef6-344ca256531e open public diff --git a/addons/survey/data/survey_demo_quiz.xml b/addons/survey/data/survey_demo_quiz.xml index c7ede2feab2..f97ae7844da 100644 --- a/addons/survey/data/survey_demo_quiz.xml +++ b/addons/survey/data/survey_demo_quiz.xml @@ -121,6 +121,8 @@ + + 15 @@ -148,6 +150,8 @@ + + 20 @@ -181,6 +185,8 @@ + + 20 diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index 91aa89de587..2ef1631c8ce 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -82,8 +82,11 @@ class SurveyQuestion(models.Model): ('matrix', 'Matrix')], string='Question Type') # -- char_box save_as_email = fields.Boolean( - "Save as user email", compute='_compute_save_as_email', readonly=False, store=True, + "Save as user email", compute='_compute_save_as_email', readonly=False, store=True, copy=True, help="If checked, this option will save the user's answer as its email address.") + save_as_nickname = fields.Boolean( + "Save as user nickname", compute='_compute_save_as_nickname', readonly=False, store=True, copy=True, + help="If checked, this option will save the user's answer as its nickname.") # -- simple choice / multiple choice / matrix suggested_answer_ids = fields.One2many( 'survey.question.answer', 'question_id', string='Types of answers', copy=True, @@ -96,11 +99,14 @@ class SurveyQuestion(models.Model): matrix_row_ids = fields.One2many( 'survey.question.answer', 'matrix_question_id', string='Matrix Rows', copy=True, help='Labels used for proposed choices: rows of matrix') - # -- display options + # -- display & timing options column_nb = fields.Selection([ ('12', '1'), ('6', '2'), ('4', '3'), ('3', '4'), ('2', '6')], string='Number of columns', default='12', help='These options refer to col-xx-[12|6|4|3|2] classes in Bootstrap for dropdown-based simple and multiple choice questions.') + is_time_limited = fields.Boolean("The question is limited in time", + help="Currently only supported for live sessions.") + time_limit = fields.Integer("Time limit (seconds)") # -- comments (simple choice, multiple choice, matrix (without count as an answer)) comments_allowed = fields.Boolean('Show Comments Field') comments_message = fields.Char('Comment Message', translate=True, default=lambda self: _("If other, please specify:")) @@ -181,6 +187,12 @@ class SurveyQuestion(models.Model): if question.question_type != 'char_box' or not question.validation_email: question.save_as_email = False + @api.depends('question_type') + def _compute_save_as_nickname(self): + for question in self: + if question.question_type != 'char_box': + question.save_as_nickname = 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 6af65c35f6b..cd8f002d47c 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -1,12 +1,16 @@ # -*- 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 @@ -105,6 +109,21 @@ class Survey(models.Model): certification_give_badge = fields.Boolean('Give Badge') certification_badge_id = fields.Many2one('gamification.badge', 'Certification Badge') certification_badge_id_dummy = fields.Many2one(related='certification_badge_id', string='Certification Badge ') + # live sessions + session_state = fields.Selection([ + ('ready', 'Ready'), + ('in_progress', 'In Progress'), + ], string="Session State", copy=False) + # 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.") + session_question_start_time = fields.Datetime("Current Question Start Time", copy=False, + help="The time at which the current question has started, used to handle the timer for attendees.") + session_question_answer_count = fields.Integer("Answers Count", compute='_compute_session_question_answer_count') + # live sessions - settings + session_show_ranking = fields.Boolean("Show Session Ranking", compute='_compute_session_show_ranking', + help="This mode will display a ranking chart of all attendees.") + session_speed_rating = fields.Boolean("Reward quick answers", help="Attendees get more points if they answer quickly") _sql_constraints = [ ('access_token_unique', 'unique(access_token)', 'Access token should be unique'), @@ -157,6 +176,28 @@ 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('session_question_id', 'user_input_ids.user_input_line_ids') + def _compute_session_question_answer_count(self): + """ We have to loop since our result is dependent of the survey.session_question_id. + 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_line_count = self.env['survey.user_input.line'].read_group( + [('question_id', '=', survey.session_question_id.id), ('survey_id', '=', survey.id)], + ['user_input_id:count_distinct'], + ['question_id'], + ) + if input_line_count: + answer_count = input_line_count[0].get('user_input_id') + + survey.session_question_answer_count = answer_count + + @api.depends('scoring_type') + def _compute_session_show_ranking(self): + for survey in self: + survey.session_show_ranking = survey.scoring_type != 'no_scoring' + @api.onchange('scoring_success_min') def _onchange_scoring_success_min(self): if self.scoring_success_min < 0 or self.scoring_success_min > 100: @@ -240,15 +281,25 @@ class Survey(models.Model): answer_vals = { 'survey_id': survey.id, 'test_entry': test_entry, + 'is_session_answer': survey.session_state in ['ready', 'in_progress'] } + if survey.session_state == 'in_progress': + # if the session is already in progress, the answer skips the 'new' state + answer_vals.update({ + 'state': 'in_progress', + 'start_datetime': fields.Datetime.now(), + }) if user and not user._is_public(): answer_vals['partner_id'] = user.partner_id.id answer_vals['email'] = user.email + answer_vals['nickname'] = user.name elif partner: answer_vals['partner_id'] = partner.id answer_vals['email'] = partner.email + answer_vals['nickname'] = partner.name else: answer_vals['email'] = email + answer_vals['nickname'] = email if invite_token: answer_vals['invite_token'] = invite_token @@ -261,10 +312,13 @@ class Survey(models.Model): answer_vals.update(additional_vals) user_inputs += user_inputs.create(answer_vals) - for question in self.mapped('question_ids').filtered(lambda q: q.question_type == 'char_box' and q.save_as_email): + for question in self.mapped('question_ids').filtered( + lambda q: q.question_type == 'char_box' and (q.save_as_email or q.save_as_nickname)): for user_input in user_inputs: - if user_input.email: + if question.save_as_email and user_input.email: user_input.save_lines(question, user_input.email) + if question.save_as_nickname and user_input.nickname: + user_input.save_lines(question, user_input.nickname) return user_inputs @@ -391,6 +445,7 @@ class Survey(models.Model): 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) """ + survey = user_input.survey_id pages_or_questions = survey._get_pages_or_questions(user_input) @@ -411,8 +466,26 @@ class Survey(models.Model): return (pages_or_questions[current_page_index + 1][1], show_last) 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. + + Breakdown of use cases: + - We are currently running a session + We return the current session question and it's id + - The layout is page_per_section + We return the questions for that page and the passed page_id + - The layout is page_per_question + We return the question for the passed question_id and the question_id + - The layout is one_page + We return all the questions of the survey and None + + In addition, we cross the returned questions with the answer.predefined_question_ids, + that allows to handle the randomization of questions. """ + questions, page_or_question_id = None, None + if answer and answer.is_session_answer: + return self.session_question_id, self.session_question_id.id if self.questions_layout == 'page_per_section': if not page_id: raise ValueError("Page id is needed for question layout 'page_per_section'") @@ -434,6 +507,68 @@ class Survey(models.Model): questions = questions & answer.predefined_question_ids return questions, page_or_question_id + # ------------------------------------------------------------ + # SESSIONS MANAGEMENT + # ------------------------------------------------------------ + + def _session_open(self): + """ The session start is sudo'ed to allow survey user to manage sessions of surveys + they do not own. + + We flush after writing to make sure it's updated before bus takes over. """ + + if self.env.user.has_group('survey.group_survey_user'): + self.sudo().write({'session_state': 'in_progress'}) + self.sudo().flush(['session_state']) + + def _session_trigger_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'): + return + + if not self.session_question_id: + 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 + + # 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' + }) + + def _prepare_ranking_values(self): + """" The ranking is descending and takes the total of the attendee points up to the current question. """ + self.ensure_one() + + return self.env['survey.user_input'].search([('survey_id', '=', self.id)], + limit=25, order="scoring_total desc") + # ------------------------------------------------------------ # ACTIONS # ------------------------------------------------------------ @@ -552,9 +687,50 @@ class Survey(models.Model): 'url': '/survey/%s/get_certification_preview' % (self.id) } + def action_start_session(self): + """ Sets the necessary fields for the session to take place and starts it. + The write is sudo'ed because a survey user can start a session even if it's + not his own survey. """ + + if not self.env.user.has_group('survey.group_survey_user'): + raise AccessError(_('Only survey users can manage sessions.')) + + self.ensure_one() + self.sudo().write({ + 'questions_layout': 'page_per_question', + 'session_question_id': None, + 'session_state': 'ready' + }) + return self.action_open_session_manager() + + def action_open_session_manager(self): + self.ensure_one() + + return { + 'type': 'ir.actions.act_url', + 'name': "Open Session Manager", + 'target': 'self', + 'url': '/survey/session/manage/%s' % self.access_token + } + + def action_end_session(self): + """ The write is sudo'ed because a survey user can end a session even if it's + not his own survey. """ + + if not self.env.user.has_group('survey.group_survey_user'): + raise AccessError(_('Only survey users can manage sessions.')) + + self.sudo().write({'session_state': False}) + self.user_input_ids.sudo().write({'state': 'done'}) + self.env['bus.bus'].sendone(self.access_token, {'type': 'end_session'}) + def get_start_url(self): return 'survey/start/%s' % self.access_token + def get_start_short_url(self): + """ See controller method docstring for more details. """ + return '/s/%s' % self.access_token[:6] + def get_print_url(self): return 'survey/print/%s' % self.access_token diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index 5ac8db82cf2..7c94a8db82a 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -34,24 +34,29 @@ class SurveyUserInput(models.Model): is_attempts_limited = fields.Boolean("Limited number of attempts", related='survey_id.is_attempts_limited') attempts_limit = fields.Integer("Number of attempts", related='survey_id.attempts_limit') attempts_number = fields.Integer("Attempt n°", compute='_compute_attempts_number') - is_time_limit_reached = fields.Boolean("Is time limit reached?", compute='_compute_is_time_limit_reached') + survey_time_limit_reached = fields.Boolean("Survey Time Limit Reached", compute='_compute_survey_time_limit_reached') # identification / access access_token = fields.Char('Identification token', default=lambda self: str(uuid.uuid4()), readonly=True, required=True, copy=False) invite_token = fields.Char('Invite token', readonly=True, copy=False) # no unique constraint, as it identifies a pool of attempts partner_id = fields.Many2one('res.partner', string='Partner', readonly=True) email = fields.Char('Email', readonly=True) + nickname = fields.Char('Nickname', help="Attendee nickname, mainly used to identify him in survey sessions rankings.") # questions / answers user_input_line_ids = fields.One2many('survey.user_input.line', 'user_input_id', string='Answers', copy=True) predefined_question_ids = fields.Many2many('survey.question', string='Predefined Questions', readonly=True) - scoring_percentage = fields.Float("Score (%)", compute="_compute_scoring_percentage", store=True, compute_sudo=True) # stored for perf reasons + scoring_percentage = fields.Float("Score (%)", compute="_compute_scoring_values", store=True, compute_sudo=True) # stored for perf reasons + scoring_total = fields.Float("Total Score", compute="_compute_scoring_values", store=True, compute_sudo=True) # stored for perf reasons scoring_success = fields.Boolean('Quizz Passed', compute='_compute_scoring_success', store=True, compute_sudo=True) # stored for perf reasons + # live sessions + is_session_answer = fields.Boolean('Is in a Session', help="Is that user input part of a survey session or not.") + question_time_limit_reached = fields.Boolean("Question Time Limit Reached", compute='_compute_question_time_limit_reached') _sql_constraints = [ ('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') - def _compute_scoring_percentage(self): + def _compute_scoring_values(self): for user_input in self: total_possible_score = sum([ answer_score if answer_score > 0 else 0 @@ -60,24 +65,48 @@ class SurveyUserInput(models.Model): if total_possible_score == 0: user_input.scoring_percentage = 0 + user_input.scoring_total = 0 else: - score = (sum(user_input.user_input_line_ids.mapped('answer_score')) / total_possible_score) * 100 - user_input.scoring_percentage = round(score, 2) if score > 0 else 0 + score_total = sum(user_input.user_input_line_ids.mapped('answer_score')) + user_input.scoring_total = score_total + score_percentage = (score_total / total_possible_score) * 100 + user_input.scoring_percentage = round(score_percentage, 2) if score_percentage > 0 else 0 @api.depends('scoring_percentage', 'survey_id.scoring_success_min') def _compute_scoring_success(self): for user_input in self: user_input.scoring_success = user_input.scoring_percentage >= user_input.survey_id.scoring_success_min - @api.depends('start_datetime', 'survey_id.is_time_limited', 'survey_id.time_limit') - def _compute_is_time_limit_reached(self): + @api.depends( + 'start_datetime', + 'survey_id.is_time_limited', + 'survey_id.time_limit') + def _compute_survey_time_limit_reached(self): """ Checks that the user_input is not exceeding the survey's time limit. """ for user_input in self: - if user_input.start_datetime and user_input.survey_id.is_time_limited: - datetime_limit = user_input.start_datetime + relativedelta(minutes=user_input.survey_id.time_limit) - user_input.is_time_limit_reached = fields.Datetime.now() > datetime_limit + if not user_input.is_session_answer and user_input.start_datetime: + start_time = user_input.start_datetime + time_limit = user_input.survey_id.time_limit + user_input.survey_time_limit_reached = user_input.survey_id.is_time_limited and \ + fields.Datetime.now() > start_time + relativedelta(minutes=time_limit) else: - user_input.is_time_limit_reached = False + user_input.survey_time_limit_reached = False + + @api.depends( + 'survey_id.session_question_id.time_limit', + 'survey_id.session_question_id.is_time_limited', + 'survey_id.session_question_start_time') + def _compute_question_time_limit_reached(self): + """ Checks that the user_input is not exceeding the question's time limit. + Only used in the context of survey sessions. """ + for user_input in self: + if user_input.is_session_answer and user_input.survey_id.session_question_start_time: + start_time = user_input.survey_id.session_question_start_time + time_limit = user_input.survey_id.session_question_id.time_limit + user_input.question_time_limit_reached = user_input.survey_id.session_question_id.is_time_limited and \ + fields.Datetime.now() > start_time + relativedelta(seconds=time_limit) + else: + user_input.question_time_limit_reached = False @api.depends('state', 'test_entry', 'survey_id.is_attempts_limited', 'partner_id', 'email', 'invite_token') def _compute_attempts_number(self): @@ -155,6 +184,13 @@ class SurveyUserInput(models.Model): def _generate_invite_token(self): return str(uuid.uuid4()) + def _mark_in_progress(self): + """ marks the state as 'in_progress' and updates the start_datetime accordingly. """ + self.write({ + 'start_datetime': fields.Datetime.now(), + 'state': 'in_progress' + }) + def _mark_done(self): """ This method will: 1. mark the state as 'done' @@ -205,6 +241,9 @@ class SurveyUserInput(models.Model): self._save_line_simple_answer(question, old_answers, answer) if question.save_as_email and answer: self.write({'email': answer}) + if question.save_as_nickname and answer: + self.write({'nickname': answer}) + elif question.question_type in ['simple_choice', 'multiple_choice']: self._save_line_choice(question, old_answers, answer, comment) elif question.question_type == 'matrix': @@ -244,7 +283,7 @@ class SurveyUserInput(models.Model): for row_key, row_answer in answers.items(): for answer in row_answer: vals = self._get_line_answer_values(question, answer, 'suggestion') - vals['matrix_row_id'] = row_key + vals['matrix_row_id'] = int(row_key) vals_list.append(vals.copy()) if comment: @@ -383,13 +422,53 @@ class SurveyUserInputLine(models.Model): @api.model_create_multi def create(self, vals_list): for vals in vals_list: - suggested_answer_id = vals.get('suggested_answer_id') - if suggested_answer_id: - vals.update({'answer_score': self.env['survey.question.answer'].browse(int(suggested_answer_id)).answer_score}) + score = self._get_answer_score(vals.get('user_input_id'), vals.get('suggested_answer_id')) + if score and not vals.get('answer_score'): + vals.update({'answer_score': score}) return super(SurveyUserInputLine, self).create(vals_list) def write(self, vals): - suggested_answer_id = vals.get('suggested_answer_id') - if suggested_answer_id: - vals.update({'answer_score': self.env['survey.question.answer'].browse(int(suggested_answer_id)).answer_score}) + score = self._get_answer_score( + vals.get('user_input_id'), + vals.get('suggested_answer_id'), + compute_speed_score=False) + if score and not vals.get('answer_score'): + vals.update({'answer_score': score}) return super(SurveyUserInputLine, self).write(vals) + + @api.model + def _get_answer_score(self, user_input_id, suggested_answer_id, compute_speed_score=True): + """ If score depends on the speed of the answer, we need to compute it. + If the user answers in less than 2 seconds, he gets 100% of the points. + If he answers after that, he gets minimum 50% of the points. + The 50 other % are ponderated between the time limit and the time it took him to answer. + + If the score does not depend on the speed, we just return the answer_score field of the + suggested survey.question.answer. """ + + if not suggested_answer_id: + return None + + answer = self.env['survey.question.answer'].search([('id', '=', suggested_answer_id)], limit=1) + answer_score = answer.answer_score + + if compute_speed_score: + user_input = self.env['survey.user_input'].browse(user_input_id) + session_speed_rating = user_input.exists() and user_input.is_session_answer and user_input.survey_id.session_speed_rating + if session_speed_rating and answer.answer_score and answer.answer_score > 0: + max_score_delay = 2 + time_limit = answer.question_id.time_limit + now = fields.Datetime.now() + seconds_to_answer = (now - user_input.survey_id.session_question_start_time).total_seconds() + question_remaining_time = time_limit - seconds_to_answer + if seconds_to_answer < max_score_delay: # if answered within the max_score_delay + answer_score = answer.answer_score + elif question_remaining_time < 0: # if no time left + answer_score = answer.answer_score / 2 + else: + time_limit -= max_score_delay # we remove the max_score_delay to have all possible values + question_remaining_time -= max_score_delay + score_proportion = (time_limit - seconds_to_answer) / time_limit + answer_score = (answer.answer_score / 2) * (1 + score_proportion) + + return answer_score diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index 6c51b0e2d8c..f270949903d 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -33,8 +33,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ this.fadeInOutDelay = 400; return this._super.apply(this, arguments).then(function () { self.options = self.$target.find('form').data(); + self.readonly = self.options.readonly; // Init fields - if (!self.options.isStartScreen) { + if (!self.options.isStartScreen && !self.readonly) { self._initTimer(); self._initBreadcrumb(); } @@ -45,9 +46,14 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ self._initTextArea(); self._focusOnFirstInput(); // Init event listener - if (!self.options.readonly) { + if (!self.readonly) { $(document).on('keypress', self._onKeyPress.bind(self)); } + if (self.options.sessionInProgress && + (self.options.isStartScreen || self.options.hasAnswered)) { + self.preventEnterSubmit = true; + } + self._initSessionManagement(); }); }, @@ -71,7 +77,10 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ // Handle Start / Next / Submit if (keyCode === 13) { // Enter : go Next event.preventDefault(); - this._submitForm({}); + if (!this.preventEnterSubmit) { + var isFinish = this.$('button[value="finish"]').length !== 0; + this._submitForm({isFinish: isFinish}); + } } else if (self.options.questionsLayout === 'page_per_question' && letter.match(/[a-z]/i)) { var $choiceInput = this.$(`input[data-selection-key=${letter}]`); @@ -131,14 +140,16 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ }, _onMatrixBtnClick: function (event) { - if (!this.options.readonly) { - var $target = $(event.currentTarget); - var $input = $target.find('input'); - if ($input.attr('type') === 'radio') { - $input.prop("checked", true).trigger('change'); - } else { - $input.prop("checked", !$input.prop("checked")).trigger('change'); - } + if (this.readonly) { + return; + } + + var $target = $(event.currentTarget); + var $input = $target.find('input'); + if ($input.attr('type') === 'radio') { + $input.prop("checked", true).trigger('change'); + } else { + $input.prop("checked", !$input.prop("checked")).trigger('change'); } }, @@ -161,6 +172,75 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ this._submitForm({'previousPageId': event.data.previousPageId}); }, + /** + * We listen to 'next_question' and 'end_session' events to load the next + * page of the survey automatically, based on the host pacing. + * + * 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) + * 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) + * + * 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) + * + * @private + * @param {Array[]} notifications structured as specified by the bus feature + */ + _onNotification: function (notifications) { + var nextPageEvent = false; + if (notifications && notifications.length !== 0) { + notifications.forEach(function (notification) { + if (notification.length >= 2) { + var event = notification[1]; + if (event.type === 'next_question' || + event.type === 'end_session') { + nextPageEvent = event; + } + } + }); + } + + if (this.options.isStartScreen && nextPageEvent.type === 'end_session') { + // can happen when triggering the same survey session multiple times + // we received an "old" end_session event that needs to be ignored + return; + } + + if (nextPageEvent) { + if (nextPageEvent.type === 'next_question') { + var serverDelayMS = moment.utc().valueOf() - moment.unix(nextPageEvent.question_start).utc().valueOf(); + if (serverDelayMS < 0) { + serverDelayMS = 0; + } else if (serverDelayMS > 2000) { + serverDelayMS = 2000; + } + this.fadeInOutDelay = (2000 - serverDelayMS) / 2; + } else { + this.fadeInOutDelay = 400; + } + + this.preventEnterSubmit = false; + this.readonly = false; + this._nextScreen( + this._rpc({ + route: `/survey/next_question/${this.options.surveyToken}/${this.options.answerToken}`, + }), { + initTimer: true, + isFinish: nextPageEvent.type === 'end_session' + } + ); + } + }, + // SUBMIT // ------------------------------------------------------------------------- @@ -174,6 +254,8 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ * @param {Array} [options] * @param {Integer} [options.previousPageId] navigates to page id * @param {Boolean} [options.skipValidation] skips JS validation + * @param {Boolean} [options.initTime] will force the re-init of the timer after next + * screen transition * @param {Boolean} [options.isFinish] fades out breadcrumb and timer * @private */ @@ -201,6 +283,32 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ this._prepareSubmitValues(formData, params); } + // prevent user from submitting more times using enter key + this.preventEnterSubmit = true; + + if (this.options.sessionInProgress) { + // reset the fadeInOutDelay when attendee is submitting form + this.fadeInOutDelay = 400; + // prevent user from clicking on matrix options when form is submitted + this.readonly = true; + } + + var submitPromise = self._rpc({ + route: _.str.sprintf('%s/%s/%s', route, self.options.surveyToken, self.options.answerToken), + params: params, + }); + this._nextScreen(submitPromise, options); + }, + + /** + * Will fade out / fade in the next screen based on passed promise and options. + * + * @param {Promise} nextScreenPromise + * @param {Object} options see '_submitForm' for details + */ + _nextScreen: function (nextScreenPromise, options) { + var self = this; + var resolveFadeOut; var fadeOutPromise = new Promise(function (resolve, reject) {resolveFadeOut = resolve;}); @@ -211,33 +319,41 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ self.$(selectorsToFadeout.join(',')).fadeOut(this.fadeInOutDelay, function () { resolveFadeOut(); }); - var submitPromise = self._rpc({ - route: _.str.sprintf('%s/%s/%s', route, self.options.surveyToken, self.options.answerToken), - params: params, - }); - Promise.all([fadeOutPromise, submitPromise]).then(function (results) { - return self._onSubmitDone(results[1], options.isFinish); + + Promise.all([fadeOutPromise, nextScreenPromise]).then(function (results) { + return self._onNextScreenDone(results[1], options); }); }, /** - * Follow the submit and handle the transition from one screen to another - * Also handle server side validation and displays eventual error messages. - */ - _onSubmitDone: function (result, isFinish) { + * Handle server side validation and display eventual error messages. + * + * @param {string} result the HTML result of the screen to display + * @param {Object} options see '_submitForm' for details + */ + _onNextScreenDone: function (result, options) { var self = this; + if (!(options && options.isFinish) + && !this.options.sessionInProgress) { + this.preventEnterSubmit = false; + } + if (result && !result.error) { this.$(".o_survey_form_content").empty(); this.$(".o_survey_form_content").html(result); this.$('div.o_survey_form_date').each(function () { self._initDateTimePicker($(this)); }); - if (this.options.isStartScreen) { + if (this.options.isStartScreen || (options && options.initTimer)) { this._initTimer(); this.options.isStartScreen = false; + } else { + if (this.options.sessionInProgress && this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } } - if (isFinish) { + if (options && options.isFinish) { this._initResultWidget(); if (this.surveyBreadcrumbWidget) { this.$('.o_survey_breadcrumb_container').addClass('d-none'); @@ -588,12 +704,37 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } }, + /** + * Will handle bus specific behavior for survey 'sessions' + * + * @private + */ + _initSessionManagement: function () { + if (this.options.surveyToken && this.options.sessionInProgress) { + this.call('bus_service', 'addChannel', this.options.surveyToken); + this.call('bus_service', 'startPolling'); + + this.call('bus_service', 'onNotification', this, this._onNotification); + } + }, + _initTimer: function () { + if (this.surveyTimerWidget) { + this.surveyTimerWidget.destroy(); + } + var self = this; - var $timer = this.$('.o_survey_timer'); - if ($timer.length) { - var timeLimitMinutes = this.options.timeLimitMinutes; - var timer = this.options.timer; + var $timerData = this.$('.o_survey_form_content_data'); + var questionTimeLimitReached = $timerData.data('questionTimeLimitReached'); + var timeLimitMinutes = $timerData.data('timeLimitMinutes'); + var hasAnswered = $timerData.data('hasAnswered'); + + if (!questionTimeLimitReached && !hasAnswered && timeLimitMinutes) { + var timer = $timerData.data('timer'); + var $timer = $('', { + class: 'o_survey_timer' + }); + this.$('.o_survey_timer_container').append($timer); this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this, { 'timer': timer, 'timeLimitMinutes': timeLimitMinutes @@ -602,10 +743,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ this.surveyTimerWidget.on('time_up', this, function (ev) { self._submitForm({ 'skipValidation': true, - 'isFinish': true + 'isFinish': !this.options.sessionInProgress }); }); - $timer.removeClass('d-none'); } }, diff --git a/addons/survey/static/src/js/survey_quick_access.js b/addons/survey/static/src/js/survey_quick_access.js new file mode 100644 index 00000000000..0534aa41878 --- /dev/null +++ b/addons/survey/static/src/js/survey_quick_access.js @@ -0,0 +1,66 @@ +odoo.define('survey.quick.access', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); + +publicWidget.registry.SurveyQuickAccessWidget = publicWidget.Widget.extend({ + selector: '.o_survey_quick_access', + events: { + 'click button[type="submit"]': '_onSubmit', + }, + + //-------------------------------------------------------------------------- + // Widget + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + // Init event listener + if (!self.readonly) { + $(document).on('keypress', self._onKeyPress.bind(self)); + } + }); + }, + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + // Handlers + // ------------------------------------------------------------------------- + + _onKeyPress: function (event) { + if (event.keyCode === 13) { // Enter + event.preventDefault(); + this._submitCode(); + } + }, + + _onSubmit: function (event) { + event.preventDefault(); + this._submitCode(); + }, + + _submitCode: function () { + var self = this; + this.$('.o_survey_error').addClass("d-none"); + var $accessCodeInput = this.$('input#access_code'); + this._rpc({ + route: `/survey/check_access_code/${$accessCodeInput.val()}`, + }).then(function (response) { + if (response.survey_url) { + window.location = response.survey_url; + } else { + self.$('.o_survey_error').removeClass("d-none"); + } + }); + }, +}); + +return publicWidget.registry.SurveyQuickAccessWidget; + +}); diff --git a/addons/survey/static/src/js/survey_result.js b/addons/survey/static/src/js/survey_result.js index dd7e1d5773d..c27fc343a43 100644 --- a/addons/survey/static/src/js/survey_result.js +++ b/addons/survey/static/src/js/survey_result.js @@ -325,17 +325,25 @@ publicWidget.registry.SurveyResultWidget = publicWidget.Widget.extend({ start: function () { var self = this; return this._super.apply(this, arguments).then(function () { + var allPromises = []; + self.$('.pagination').each(function (){ var questionId = $(this).data("question_id"); - new publicWidget.registry.SurveyResultPagination(self, { + allPromises.push(new publicWidget.registry.SurveyResultPagination(self, { 'questionsEl': self.$('#survey_table_question_'+ questionId) - }).attachTo($(this)); + }).attachTo($(this))); }); self.$('.survey_graph').each(function () { - new publicWidget.registry.SurveyResultChart(self) - .attachTo($(this)); + allPromises.push(new publicWidget.registry.SurveyResultChart(self) + .attachTo($(this))); }); + + if (allPromises.length !== 0) { + return Promise.all(allPromises); + } else { + return Promise.resolve(); + } }); }, diff --git a/addons/survey/static/src/js/survey_session_manage.js b/addons/survey/static/src/js/survey_session_manage.js new file mode 100644 index 00000000000..daa6979b4ab --- /dev/null +++ b/addons/survey/static/src/js/survey_session_manage.js @@ -0,0 +1,323 @@ +odoo.define('survey.session_manage', function (require) { +'use strict'; + +var publicWidget = require('web.public.widget'); +var core = require('web.core'); +var _t = core._t; + +publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({ + selector: '.o_survey_session_manage', + events: { + 'click .o_survey_session_copy': '_onCopySessionLink', + 'click .o_survey_session_next, .o_survey_session_start': '_onNextQuestionClick', + 'click .o_survey_session_end': '_onEndSessionClick', + 'click .o_survey_session_results_href': '_onResultsTabClick', + 'click .o_survey_session_ranking_href': '_onRankingTabClick', + 'click .o_survey_session_refresh_results': '_onRefreshResultsClick', + 'click .o_survey_session_refresh_ranking': '_onRefreshRankingClick', + }, + + /** + * Overridden to set a few properties that come from the python template rendering. + * + * We also handle the timer IF we're not "transitioning", meaning a fade out of the previous + * $el to the next question (the fact that we're transitioning is in the isRpcCall data). + * If we're transitioning, the timer is handled manually at the end of the transition. + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.surveyId = self.$el.data('surveyId'); + self.surveyAccessToken = self.$el.data('surveyAccessToken'); + self.isStartScreen = self.$el.data('isStartScreen'); + + var isRpcCall = self.$el.data('isRpcCall'); + if (!isRpcCall) { + self._startTimer(); + } + + if (self.isStartScreen) { + self._refreshAttendeesCount(); + } else if (!self.refreshingAnswers) { + self.stopRefreshingAttendees = true; + self._refreshReceivedAnswers(); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Copies the survey URL link to the clipboard. + * We use 'ClipboardJS' to avoid having to print the URL in a standard text input + * + * @param {MouseEvent} ev + */ + _onCopySessionLink: function (ev) { + var self = this; + ev.preventDefault(); + + var $clipboardBtn = this.$('.o_survey_session_copy'); + + $clipboardBtn.popover({ + placement: 'right', + container: 'body', + offset: '0, 3', + content: function () { + return _t("Copied !"); + } + }); + + var clipboard = new ClipboardJS('.o_survey_session_copy', { + text: function () { + return self.$('.o_survey_session_copy_url').val(); + }, + container: this.el + }); + + clipboard.on('success', function () { + clipboard.destroy(); + $clipboardBtn.popover('show'); + _.delay(function () { + $clipboardBtn.popover('hide'); + }, 800); + }); + + clipboard.on('error', function (e) { + clipboard.destroy(); + }); + }, + + /** + * We use a fade in/out mechanism to display the next question of the session. + * + * The fade out happens at the same moment as the _rpc to get the new question template. + * When they're both finished, we update the HTML of this widget with the new template and then + * fade in the updated question to the user. + * + * The timer (if configured) starts at the end of the fade in animation. + * + * @param {MouseEvent} ev + * @private + */ + _onNextQuestionClick: function (ev) { + var self = this; + ev.preventDefault(); + this.isStartScreen = false; + + var resolveFadeOut; + var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; }); + this.$el.fadeOut(1000, function () { + resolveFadeOut(); + }); + + var nextQuestionPromise = this._rpc({ + route: _.str.sprintf('/survey/session/next_question/%s', self.surveyAccessToken) + }); + + 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(); + }); + }); + }, + + /** + * Marks this session as 'done' and redirects the user to the results. + * + * @param {MouseEvent} ev + * @private + */ + _onEndSessionClick: function (ev) { + var self = this; + ev.preventDefault(); + + this._rpc({ + model: 'survey.survey', + method: 'action_end_session', + args: [[this.surveyId]], + }).then(function () { + document.location = _.str.sprintf( + '/survey/results/%s', + self.surveyId + ); + }); + }, + + _onResultsTabClick: function (ev) { + this._onRefreshResultsClick(ev, true); + }, + + _onRankingTabClick: function (ev) { + this._onRefreshRankingClick(ev, true); + }, + + _onRefreshResultsClick: function (ev, preventSpin) { + ev.preventDefault(); + + if (this.$('.o_survey_session_refresh_results').hasClass('fa-spin')) { + return; + } + + var refreshPromise = this._refreshResults(); + if (!preventSpin) { + this._spinIcon(this.$('.o_survey_session_refresh_results'), refreshPromise); + } + }, + + _onRefreshRankingClick: function (ev, preventSpin) { + ev.preventDefault(); + + if (this.$('.o_survey_session_refresh_ranking').hasClass('fa-spin')) { + return; + } + + var refreshPromise = this._refreshRanking(); + + if (!preventSpin) { + this._spinIcon(this.$('.o_survey_session_refresh_ranking'), refreshPromise); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Will start the question timer so that the host may know when the question is done to display + * the results and the ranking. + */ + _startTimer: function () { + var $timer = this.$('.o_survey_timer'); + + if ($timer.length) { + var timeLimitMinutes = this.$el.data('timeLimitMinutes'); + var timer = this.$el.data('timer'); + this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this, { + 'timer': timer, + 'timeLimitMinutes': timeLimitMinutes + }); + this.surveyTimerWidget.attachTo($timer); + } + }, + + /** + * Refreshes the question results on screen. + */ + _refreshResults: function () { + var self = this; + + return this._rpc({ + route: _.str.sprintf('/survey/session/results/%s', this.surveyAccessToken) + }).then(function (renderedTemplate) { + var $renderedTemplate = $(renderedTemplate).addClass('py-3'); + self.$('.o_survey_result').empty().append($renderedTemplate); + return new publicWidget.registry.SurveyResultWidget().attachTo($renderedTemplate); + }); + }, + + /** + * Refreshes the question ranking on screen. + * We set the width of the progress bars after the rendering to enable a width css animation. + */ + _refreshRanking: function () { + var self = this; + + return this._rpc({ + route: _.str.sprintf('/survey/session/ranking/%s', this.surveyAccessToken) + }).then(function (renderedTemplate) { + var $renderedTemplate = $(renderedTemplate).addClass('py-3'); + self.$('.o_survey_session_ranking_container').empty().append($renderedTemplate); + // delay by 200ms to account for fade out / in + setTimeout(function () { + self.$('.o_survey_session_ranking_bar').each(function () { + $(this).css('width', `calc(calc(100% - 10rem) * ${$(this).data('widthRatio')})`); + }); + }, 200); + + return Promise.resolve(); + }); + }, + + /** + * We refresh the attendees count every 2 seconds while the user is on the start screen. + * When he leaves the start screen, the "stopRefreshingAttendees" becomes true and we stop + * querying the answer_count. + */ + _refreshAttendeesCount: function () { + var self = this; + + setTimeout(function () { + self._rpc({ + model: 'survey.survey', + method: 'read', + args: [[self.surveyId], ['answer_count']], + }).then(function (result) { + if (result && result.length === 1){ + self.$('.o_survey_session_attendees_count').text( + result[0].answer_count + ); + } + if (!self.stopRefreshingAttendees) { + self._refreshAttendeesCount(); + } + }); + }, 2000); + }, + + /** + * We refresh the received answers count every 2 seconds for the current question. + * This allows the host to know what he can move on to the next question. + * (Without having to manually refresh the page). + */ + _refreshReceivedAnswers: function () { + var self = this; + + this.refreshingAnswers = true; + setTimeout(function () { + self._rpc({ + model: 'survey.survey', + method: 'read', + args: [[self.surveyId], ['session_question_answer_count']], + }).then(function (result) { + if (result && result.length === 1){ + self.$('.o_survey_session_answer_count').text( + result[0].session_question_answer_count + ); + } + self._refreshReceivedAnswers(); + }); + }, 2000); + }, + + /** + * We need the refresh icon to spin for at least 1s to make the user feel like it's + * refreshing something. + * If we only wait for the 'refreshPromise', it can be so fast that the icon doesn't even move. + * + * @param {$.Element} $target + * @param {Promise} refreshPromise + */ + _spinIcon: function ($target, refreshPromise) { + $target.addClass('fa-spin'); + + var minimumSpinResolve; + var minimumSpinPromise = new Promise(function (resolve) { minimumSpinResolve = resolve;}); + setTimeout(function () { + minimumSpinResolve(); + }, 1000); + + Promise.all([minimumSpinPromise, refreshPromise]).then(function () { + $target.removeClass('fa-spin'); + }); + }, +}); + +return publicWidget.registry.SurveySessionManage; + +}); diff --git a/addons/survey/static/src/js/survey_timer.js b/addons/survey/static/src/js/survey_timer.js index 664871e4e7e..d0a38c88324 100644 --- a/addons/survey/static/src/js/survey_timer.js +++ b/addons/survey/static/src/js/survey_timer.js @@ -31,7 +31,6 @@ publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({ if (this.timeLimitMinutes <= 0 || this.countDownDate.diff(moment.utc(), 'seconds') < 0) { this.trigger_up('time_up'); } else { - this.$timer = this.$el.find('.timer'); this._updateTimer(this); this.surveyTimerInterval = setInterval(this._updateTimer.bind(this), 1000); } @@ -57,7 +56,7 @@ publicWidget.registry.SurveyTimerWidget = publicWidget.Widget.extend({ if (timeLeft >= 0) { var timeLeftMinutes = parseInt(timeLeft / 60); var timeLeftSeconds = timeLeft - (timeLeftMinutes * 60); - this.$timer.html(this._formatTime(timeLeftMinutes) + ':' + this._formatTime(timeLeftSeconds)); + this.$el.text(this._formatTime(timeLeftMinutes) + ':' + this._formatTime(timeLeftSeconds)); } else { clearInterval(this.surveyTimerInterval); this.trigger_up('time_up'); diff --git a/addons/survey/static/src/scss/survey_form.scss b/addons/survey/static/src/scss/survey_form.scss index 262a0532130..f9f98c9ea03 100644 --- a/addons/survey/static/src/scss/survey_form.scss +++ b/addons/survey/static/src/scss/survey_form.scss @@ -6,7 +6,7 @@ Common Style **********************************************************/ -.o_survey_form, .o_survey_print { +.o_survey_form, .o_survey_print, .o_survey_session_manage, .o_survey_quick_access { .o_survey_question_error { height: 0px; transition: height .5s ease; @@ -16,6 +16,15 @@ } } + fieldset[disabled] { + .o_survey_question_text_box, + .o_survey_question_date, + .o_survey_question_datetime, + .o_survey_question_numerical_box { + padding-left: 0px; + } + } + .o_survey_question_text_box, .o_survey_question_date, .o_survey_question_datetime, @@ -88,16 +97,47 @@ } } -/********************************************************** - Form Specific Style - **********************************************************/ - -.o_survey_form { +.o_survey_form, .o_survey_session_manage { min-height: 100%; .o_survey_choice_btn { background-color: rgba($primary, 0.5); border: 2px solid $primary; + } + + .o_survey_choice_key { + width: 25px; + height: 25px; + border: 2px solid $primary; + span { + font-size: smaller; + top: -2px; + &.o_survey_key { + opacity: 0; + } + } + } + + .o_survey_question_matrix { + th { + background-color: $primary; + } + td { + background-color: rgba($primary, 0.2); + } + } + + .o_survey_progress { + height:0.5em; + } +} + +/********************************************************** + Form Specific Style + **********************************************************/ + +.o_survey_form { + .o_survey_choice_btn { cursor: pointer; &.o_survey_selected { @@ -112,45 +152,132 @@ } } - .o_survey_choice_key { - width: 25px; - height: 25px; + .o_survey_choice_key span.o_survey_key { + width: inherit; + right: 10px; border: 2px solid $primary; - span { - font-size: smaller; - top: -2px; - &.o_survey_key { - width: inherit; - right: 10px; - border: 2px solid $primary; - border-right: 0px; - height: 25px; - opacity: 0; - transition: opacity 0.4s ease; - white-space: nowrap; - } + border-right: 0px; + height: 25px; + transition: opacity 0.4s ease; + white-space: nowrap; + } + + .o_survey_question_matrix td:hover { + background-color: rgba($primary, 0.5); + cursor: pointer; + .o_survey_choice_key span.o_survey_key { + opacity: 1; + } + } +} + +/********************************************************** + Survey Session Specific Style + **********************************************************/ + +.o_survey_session_manage { + .o_survey_manage_fontsize_14 { + font-size: 1.4rem; + } + + .o_survey_session_manage_container { + border: solid 1px #999999; + box-shadow: 0px 1px 1px 0px #999999; + min-height: 75vh; + + .js_question-wrapper { + padding-bottom: 0px; } } - .o_survey_question_matrix { - th { - background-color: $primary; - } - td { - background-color: rgba($primary, 0.2); + .tab-content > .o_survey_session_manage_question.tab-pane { + display: none; + } + .tab-content > .o_survey_session_manage_question.active { + display: flex; + } + + .o_survey_session_copy { + cursor: pointer; + opacity: .75; + transition: opacity .3s ease; + &:hover { + opacity: 1; + } + } + + .btn { + font-variant: small-caps; + &.o_survey_session_end { + right: 0px; + } + } + + .o_survey_session_results, .o_survey_session_ranking { + .fa { + font-size: .9rem; + opacity: .75; &:hover { - background-color: rgba($primary, 0.5); + transition: opacity .4s ease; + opacity: 1; cursor: pointer; - .o_survey_choice_key span.o_survey_key { - opacity: 1; - } } } + + .o_survey_result, .o_survey_session_ranking_container { + overflow: hidden; + } + + .survey_filter { + display: none; + } } - .o_survey_progress { - height:0.5em; + .o_survey_result { + .nav-tabs .nav-link.active { + background-color: transparent; + border-color: #dee2e6 #dee2e6 transparent; + } + } + + .nav-tabs { + border-bottom: 0px; + .nav-item .nav-link { + border-radius: 0px; + background-color: transparent; + border: solid 1px transparent; + + &.active { + border: solid 1px #999999; + border-bottom: 0px; + } + } + } +} + +.o_survey_session_ranking { + .o_survey_session_ranking_item { + line-height: 1.4rem; + + .o_survey_session_ranking_index { + width: 1.5rem; + } + + .o_survey_session_ranking_bar { + min-width: 90px; + height: 1.4rem; + color: white; + background-color: #007A77; + transition: width ease 1.5s; + } + + .o_survey_session_ranking_name { + width: 7.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } } diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index 640556491f0..609a133c286 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -216,6 +216,11 @@ class TestSurveyCommon(common.SavepointCase): def _access_page(self, survey, token): return self.url_open('/survey/%s/%s' % (survey.access_token, token)) + def _access_begin(self, survey, token): + base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') + url = base_url + '/survey/begin/%s/%s' % (survey.access_token, token) + return self.opener.post(url=url, json={}) + def _access_submit(self, survey, token, post_data): base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') url = base_url + '/survey/submit/%s/%s' % (survey.access_token, token) diff --git a/addons/survey/tests/test_certification_flow.py b/addons/survey/tests/test_certification_flow.py index 3689c1cc200..e23fdffb8b0 100644 --- a/addons/survey/tests/test_certification_flow.py +++ b/addons/survey/tests/test_certification_flow.py @@ -115,6 +115,9 @@ class TestCertificationFlow(common.TestSurveyCommon, HttpCase): self.assertResponse(response, 200) csrf_token = self._find_csrf_token(response.text) + r = self._access_begin(certification, answer_token) + self.assertResponse(r, 200) + with patch.object(IrMailServer, 'connect'): self._answer_question(q01, q01.suggested_answer_ids.ids[3], answer_token, csrf_token) self._answer_question(q02, q02.suggested_answer_ids.ids[1], answer_token, csrf_token) diff --git a/addons/survey/tests/test_survey_flow.py b/addons/survey/tests/test_survey_flow.py index 534b3d29a59..c8d814fae69 100644 --- a/addons/survey/tests/test_survey_flow.py +++ b/addons/survey/tests/test_survey_flow.py @@ -85,6 +85,9 @@ class TestSurveyFlow(common.TestSurveyCommon, HttpCase): self.assertAnswer(answers, 'new', self.env['survey.question']) csrf_token = self._find_csrf_token(r.text) + r = self._access_begin(survey, answer_token) + self.assertResponse(r, 200) + # Customer submit first page answers answer_data = { page0_q0.id: {'value': ['Alfred Poilvache']}, diff --git a/addons/survey/views/assets.xml b/addons/survey/views/assets.xml index 846c0e0150d..854274013ba 100644 --- a/addons/survey/views/assets.xml +++ b/addons/survey/views/assets.xml @@ -7,6 +7,7 @@ +