diff --git a/addons/bus/views/bus.xml b/addons/bus/views/bus.xml
index 4d8f7bc36de..8ccabfdfa3c 100644
--- a/addons/bus/views/bus.xml
+++ b/addons/bus/views/bus.xml
@@ -8,6 +8,14 @@
+
+
+
+
+
+
+
+
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-344ca256531eopenpublic
diff --git a/addons/survey/data/survey_demo_quiz.xml b/addons/survey/data/survey_demo_quiz.xml
index c7ede2feab2..e2b11c6fc32 100644
--- a/addons/survey/data/survey_demo_quiz.xml
+++ b/addons/survey/data/survey_demo_quiz.xml
@@ -9,7 +9,7 @@
scoring_with_answers55
- page_per_section
+ page_per_question
This small quiz will test your knowledge about our Company. Be prepared !
@@ -28,13 +28,30 @@
2
- Where are you from ?
+ What is your email ?char_box
+
+ 3
+ What is your nickname ?
+ char_box
+
+
+
+
+
+ 4
+ Where are you from ?
+ char_box
+
+
+
+
+ 5How old are you ?
Just to categorize your answers, don't worry.
numerical_box
@@ -99,14 +116,14 @@
1Fruits
- 2
+ 202Vegetables
- 1
+ 10
@@ -121,13 +138,15 @@
+
+ 151Bees
- 2
+ 20
@@ -148,30 +167,34 @@
+
+ 201Apple Trees
- 2
+ 202Lemon Trees
- 1
+ 103Baobab Trees
+ -104Cookies
+ -10
@@ -181,30 +204,34 @@
+
+ 201Pomelos
- 2
+ 202Grapefruits
- 2
+ 203Cosmic rays
+ -104Bricks
+ -10
diff --git a/addons/survey/data/survey_demo_quiz_userinput.xml b/addons/survey/data/survey_demo_quiz_userinput.xml
index 8c20468f290..1330bad2187 100644
--- a/addons/survey/data/survey_demo_quiz_userinput.xml
+++ b/addons/survey/data/survey_demo_quiz_userinput.xml
@@ -42,11 +42,23 @@
char_box
- Brussels
+ mark.brown23@example.com
+ char_box
+ Mark Brown
+
+
+
+
+ char_box
+ Brussels
+
+
+
+ numerical_box36
@@ -188,14 +200,26 @@
+
+
+ char_box
+ admin@yourcompany.example.com
+
+
+
+
+ char_box
+ Mitchell Admin
+
+
-
+ char_boxOttawa
-
+
-
+ numerical_box48
@@ -330,14 +354,26 @@
+
+
+ char_box
+ joel.willis63@example.com
+
+
+
+
+ char_box
+ Joël Willis
+
+
-
+ char_boxBrussels
-
+
-
+ numerical_box28
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 @@
+
@@ -21,6 +22,11 @@
+
+
+
+
+
diff --git a/addons/survey/views/survey_question_views.xml b/addons/survey/views/survey_question_views.xml
index efab66e039a..e273f0b1f83 100644
--- a/addons/survey/views/survey_question_views.xml
+++ b/addons/survey/views/survey_question_views.xml
@@ -100,6 +100,10 @@
+