[IMP] survey: add a 'live session' mode to the survey app
PURPOSE
Add a "live session" mode for survey that allows the host to interact with its
audience.
He controls the pace of the survey and the audience answers questions one at a
time.
Results for each question can be displayed by the host to adapt his speech in
real time.
A gamification component even allows to give points to the attendees, based on
the quickness of their answer, and create ranking to keep everyone's attention.
SPECIFICATIONS
This whole feature is deeply integrated with survey.
It adds a layer of "survey session" on top if it that gathers attendees and
answers during the lifespan of the session.
The attendee identity is filled in by a special question that is marked as
"Save as user nickname".
If the host has not configured that question or if the user doesn't answer,
they are marked as "Anonymous" in the rankings.
The answer score computation is also altered during the session to take the
speed of the answer into account.
To ease the access to the survey to attendees, we have introduced two new
routes in the survey main controller:
- '/s/123456'
That uses the 6 first characters of the token to give quick access to the
survey and allow typing the URL manually somewhat conveniently.
- '/s' that renders a view that asks the attendee to enter the survey 'code',
which then redirects to the previous route.
This commit also fixes a few bugs:
- A bugged use case with the "enter key" listener.
When the survey is done, we display a "result template" that shows the user
his score and allows him to retry if possible.
When this template is displayed, we don't want to listen to the "enter" key
that allows to submit the survey form.
- We now correctly remove the timer and initialize the result
widget when the user presses 'enter' on the last page / question.
SURVEY SESSION FLOW
HOST point of view:
- On the Survey Form, the host configures:
- "Reward quick answers" that gives more points to attendees if they answer
quickly
- Question Time Limit, on each question, that defines the time limit for
that specific question, allowing to have different (or no) timer for each
question.
- From the Survey Form, the host can start a "live session"
Only one session per survey can be running at a time.
- When he starts the session, the session_state is marked as 'ready', meaning
all attendees landing on the survey page will be part of the session.
- The host will land on a page showing the current number of attendees as well
as the link for the attendees to join that session.
- The host starts the session, activating the first question of this session.
We keep an active reference to the "current_question_id" and use the bus to
trigger an event that will refresh the survey page for all attendees, showing
them the question and allowing them to answer.
- The host gets a "question management screen", from which he can:
- See the current question text & suggested answers
- See the current question timer
- See the number of answers received for the question
- Display the answers of the question (same view as the survey "results"
page, but only for the question)
- Display the ranking of attendees (if "competitive mode" is enabled)
- The host controls the pace of the survey by moving to the next question until
it's the last one of the survey, then ends the current session.
When the session is closed, we mark the answers of the attendees as "done".
- The host lands on a screen where he sees the survey results and the ranking
of all attendees.
ATTENDEE point of view:
- He reaches the survey when a session and open, and gets a screen asking him
to wait until the host decides to start the session.
- When the sessions starts, he's automatically redirected to the first question
(see above).
- If the session is already started when he reaches the link, he lands on the
current question (and NOT on the first one)
- If the host has configured a time limit, the attendee sees the countdown
while he's answering.
- When the attendee answers the question, the screen tells him the answer is
registered and he has to wait for the host to go to the next question, which
will happen automatically.
- If the timer reaches 0 and he has not submitted his answer, it's too late and
he waits for the next one.
- It continues like that until the end of the session.
- At the end of the session, the attendee gets a screen with his global results
(same as a regular scored survey).
PR #43568
Task 1972640
Co-authored-by: David Beguin <dbe@odoo.com>
This commit is contained in:
co-authored by
David Beguin
parent
5e6c09a1ae
commit
e973bd5519
@@ -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',
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
from . import survey_session_manage
|
||||
|
||||
@@ -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/<string:survey_short_token>', 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/<string:survey_token>', 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/<string:survey_token>/<string:answer_token>', 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/<string:survey_token>/<string:answer_token>', 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/<string: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]
|
||||
|
||||
@@ -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/<string:survey_token>', 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/<string:survey_token>', 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/<string:survey_token>', 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/<string:survey_token>', 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()),
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<record model="survey.survey" id="survey_feedback">
|
||||
<field name="title">Feedback Form</field>
|
||||
<field name="access_token">b137640d-14d4-4748-9ef6-344ca256531e</field>
|
||||
<field name="access_token">b135640d-14d4-4748-9ef6-344ca256531e</field>
|
||||
<field name="state">open</field>
|
||||
<field name="access_mode">public</field>
|
||||
<field name="users_can_go_back" eval="True" />
|
||||
|
||||
@@ -121,6 +121,8 @@
|
||||
<field name="comments_allowed" eval="True"/>
|
||||
<field name="comment_count_as_answer" eval="False"/>
|
||||
<field name="constr_mandatory" eval="True"/>
|
||||
<field name="is_time_limited" eval="True"/>
|
||||
<field name="time_limit">15</field>
|
||||
</record>
|
||||
<record id="survey_demo_quiz_p3_q2_sug1" model="survey.question.answer">
|
||||
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
|
||||
@@ -148,6 +150,8 @@
|
||||
<field name="constr_mandatory" eval="False"/>
|
||||
<field name="comments_allowed" eval="True"/>
|
||||
<field name="comment_count_as_answer" eval="True"/>
|
||||
<field name="is_time_limited" eval="True"/>
|
||||
<field name="time_limit">20</field>
|
||||
</record>
|
||||
<record id="survey_demo_quiz_p3_q3_sug1" model="survey.question.answer">
|
||||
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
|
||||
@@ -181,6 +185,8 @@
|
||||
<field name="constr_mandatory" eval="True"/>
|
||||
<field name="comments_allowed" eval="True"/>
|
||||
<field name="comment_count_as_answer" eval="False"/>
|
||||
<field name="is_time_limited" eval="True"/>
|
||||
<field name="time_limit">20</field>
|
||||
</record>
|
||||
<record id="survey_demo_quiz_p3_q4_sug1" model="survey.question.answer">
|
||||
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = $('<span>', {
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
<script type="text/javascript" src="/web/static/src/js/fields/field_utils.js"></script>
|
||||
|
||||
<script type="text/javascript" src="/survey/static/src/js/survey_quick_access.js" />
|
||||
<script type="text/javascript" src="/survey/static/src/js/survey_timer.js" />
|
||||
<script type="text/javascript" src="/survey/static/src/js/survey_breadcrumb.js" />
|
||||
<script type="text/javascript" src="/survey/static/src/js/survey_form.js" />
|
||||
@@ -21,6 +22,11 @@
|
||||
<link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_form.scss"/>
|
||||
</template>
|
||||
|
||||
<!-- survey user input session assets -->
|
||||
<template id="survey_user_input_session_assets" name="Survey User Input Session Assets">
|
||||
<script type="text/javascript" src="/survey/static/src/js/survey_session_manage.js" />
|
||||
</template>
|
||||
|
||||
<template id="survey_report_assets_pdf" inherit_id="web.report_assets_pdf">
|
||||
<xpath expr="link[last()]" position="after">
|
||||
<link rel="stylesheet" type="text/scss" href="/survey/static/src/scss/survey_reports.scss"/>
|
||||
|
||||
@@ -100,6 +100,10 @@
|
||||
<label for="validation_email" attrs="{'invisible': [('question_type', '!=', 'char_box')]}"/><br/>
|
||||
<field name="save_as_email" attrs="{'invisible': ['|', ('question_type', '!=', 'char_box'), ('validation_email', '=', False)]}"/>
|
||||
<label for="save_as_email" attrs="{'invisible': ['|', ('question_type', '!=', 'char_box'), ('validation_email', '=', False)]}"/>
|
||||
<div attrs="{'invisible': [('question_type', '!=', 'char_box')]}">
|
||||
<field name="save_as_nickname"/>
|
||||
<label for="save_as_nickname"/>
|
||||
</div>
|
||||
<field name="page_id" invisible="1" required="0"/>
|
||||
<field name="survey_id" invisible="1" readonly="1"/>
|
||||
<field name="scoring_type" invisible="1"/>
|
||||
@@ -162,6 +166,15 @@
|
||||
<field name='comments_message' attrs="{'invisible': [('comments_allowed', '=', False)]}"/>
|
||||
<field name='comment_count_as_answer' attrs="{'invisible': ['|', ('comments_allowed', '=', False), ('question_type', 'in', ['matrix'])]}" />
|
||||
</group>
|
||||
<group string="Live Sessions">
|
||||
<label for="is_time_limited" string="Question Time Limit"/>
|
||||
<div>
|
||||
<field name="is_time_limited" nolabel="1"/>
|
||||
<field name="time_limit" nolabel="1" class="oe_inline"
|
||||
attrs="{'invisible': [('is_time_limited', '=', False)]}" />
|
||||
<span attrs="{'invisible': [('is_time_limited', '=', False)]}"> seconds</span>
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Survey" class="o_survey_form">
|
||||
<field name="id" invisible="1"/>
|
||||
<field name="session_state" invisible="1"/>
|
||||
<header>
|
||||
<button name="action_open" string="Start Survey" type="object" class="oe_highlight" attrs="{'invisible': ['|', ('state', '!=', 'draft'), ('id', '=', False)]}"/>
|
||||
<button name="action_send_survey" string="Share" type="object" class="oe_highlight" states="open"/>
|
||||
<button name="action_result_survey" string="See results" type="object" class="oe_highlight"
|
||||
attrs="{'invisible': ['|', ('state', '=', 'draft'), ('answer_done_count', '<=', 0)]}"/>
|
||||
<button name="action_start_session" string="Create Live Session" type="object"
|
||||
attrs="{'invisible': ['|', ('session_state', '!=', False), ('state', '=', 'draft')]}" />
|
||||
<button name="action_open_session_manager" string="Open Session Manager" type="object"
|
||||
attrs="{'invisible': ['|', ('session_state', '=', False), ('state', '=', 'draft')]}" />
|
||||
<button name="action_end_session" string="Close Live Session" type="object"
|
||||
attrs="{'invisible': ['|', ('session_state', '!=', 'in_progress'), ('state', '=', 'draft')]}" />
|
||||
<button name="action_draft" string="Set to draft" type="object" states="closed"/>
|
||||
<button name="action_test_survey" string="Test" type="object" attrs="{'invisible': ['|', ('state', '=', 'closed'), ('id', '=', False)]}"/>
|
||||
<button name="action_print_survey" string="Print" type="object" attrs="{'invisible': [('id', '=', False)]}"/>
|
||||
@@ -75,9 +82,10 @@
|
||||
<page string="Options" name="options">
|
||||
<group name="options">
|
||||
<group string="Questions" name="questions">
|
||||
<field name="questions_layout" widget="radio" />
|
||||
<field name="questions_layout" widget="radio"
|
||||
attrs="{'readonly': [('session_state', 'in', ['ready', 'in_progress'])]}"/>
|
||||
<field name="progression_mode" widget="radio" />
|
||||
<label for="is_time_limited" string="Time Limit"/>
|
||||
<label for="is_time_limited" string="Survey Time Limit"/>
|
||||
<div>
|
||||
<field name="is_time_limited" nolabel="1"/>
|
||||
<field name="time_limit" widget="float_time" attrs="{'invisible': [('is_time_limited', '=', False)]}" nolabel="1" class="oe_inline" /> <span attrs="{'invisible': [('is_time_limited', '=', False)]}"> minutes</span>
|
||||
@@ -119,6 +127,9 @@
|
||||
options="{'no_create': True}"
|
||||
context="{'form_view_ref': 'survey.gamification_badge_form_view_simplified'}"/>
|
||||
</group>
|
||||
<group string="Live Session">
|
||||
<field name="session_speed_rating" />
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
@@ -10,14 +10,15 @@
|
||||
<t t-call-assets="survey.survey_assets" lazy_load="True"/>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="before">
|
||||
<t t-set="no_header" t-value="no_header or survey.certification"/>
|
||||
<t t-set="no_footer" t-value="no_footer or survey.certification"/>
|
||||
<t t-set="no_header" t-value="no_header or (survey and survey.certification) or (answer and answer.is_session_answer)"/>
|
||||
<t t-set="no_footer" t-value="no_footer or not survey or survey.certification or (answer and answer.is_session_answer)"/>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="after">
|
||||
<div id="wrap" class="oe_structure oe_empty"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Main survey template -->
|
||||
<template id="survey_page_fill" name="Survey: main page (take survey)">
|
||||
<t t-call="survey.layout">
|
||||
<div class="wrap o_survey_wrap mt16 pb16 d-flex">
|
||||
@@ -36,8 +37,9 @@
|
||||
<div class="container m-0 p-0">
|
||||
<div class="row">
|
||||
<div class="col-lg-8"><h1 t-esc="survey.title"></h1></div>
|
||||
<div t-if="survey.is_time_limited and answer.state != 'done'" class="o_survey_timer col-lg-4 d-none">
|
||||
<h1 class="timer text-right">00:00</h1>
|
||||
<div class="o_survey_timer col-lg-4">
|
||||
<h1 class="o_survey_timer_container timer text-right">
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,15 +51,15 @@
|
||||
</template>
|
||||
|
||||
<template id="survey_fill_form" name="Survey: main page content">
|
||||
<t t-set="survey_form_readonly" t-value="false"/>
|
||||
<t t-set="survey_form_readonly" t-value="answer.state == 'done'"/>
|
||||
<form role="form" method="post" t-att-name="survey.id"
|
||||
class="d-flex flex-grow-1 align-items-center"
|
||||
t-att-data-answer-token="answer.access_token"
|
||||
t-att-data-survey-token="survey.access_token"
|
||||
t-att-data-timer="answer.start_datetime.isoformat() if survey.is_time_limited
|
||||
and answer.start_datetime and answer.state != 'done' else False"
|
||||
t-att-data-time-limit-minutes="survey.time_limit"
|
||||
t-att-data-session-in-progress="answer.is_session_answer"
|
||||
t-att-data-is-start-screen="answer.state == 'new'"
|
||||
t-att-data-readonly="survey_form_readonly"
|
||||
t-att-data-has-answered="bool(has_answered)"
|
||||
t-att-data-questions-layout="survey.questions_layout">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="token" t-att-value="answer.access_token" />
|
||||
@@ -91,25 +93,38 @@
|
||||
<div class="wrap o_survey_start pb256">
|
||||
<div class='mt32 mb32'>
|
||||
<div t-field='survey.description' class="oe_no_empty"/>
|
||||
<div t-if="survey.is_time_limited">
|
||||
<p>
|
||||
<span>Time limit for this survey: </span>
|
||||
<span class="font-weight-bold text-danger" t-field="survey.time_limit" t-options="{'widget': 'duration', 'unit': 'minute'}"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<t t-if="survey.certification">
|
||||
Start Certification
|
||||
</t>
|
||||
<t t-else="">
|
||||
Start Survey
|
||||
</t>
|
||||
</button><span class="font-weight-bold ml-2 text-primary d-none d-md-inline">or press Enter</span>
|
||||
<t t-if="survey.session_state == 'ready'">
|
||||
<div class="font-weight-bold">
|
||||
The session will begin automatically when the host starts.
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div t-if="survey.is_time_limited">
|
||||
<p>
|
||||
<span>Time limit for this survey: </span>
|
||||
<span class="font-weight-bold text-danger" t-field="survey.time_limit" t-options="{'widget': 'duration', 'unit': 'minute'}"></span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" value="start" class="btn btn-primary btn-lg">
|
||||
<t t-if="survey.certification">
|
||||
Start Certification
|
||||
</t>
|
||||
<t t-else="">
|
||||
Start Survey
|
||||
</t>
|
||||
</button>
|
||||
<span class="o_survey_enter font-weight-bold ml-2">or press Enter</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="survey_fill_form_in_progress" name="Survey: form with questions">
|
||||
<div class="o_survey_form_content_data d-none"
|
||||
t-att-data-question-time-limit-reached="answer.question_time_limit_reached"
|
||||
t-att-data-has-answered="bool(has_answered)"
|
||||
t-att-data-timer="timer_start"
|
||||
t-att-data-time-limit-minutes="time_limit_minutes"/>
|
||||
<t t-if="survey.questions_layout == 'one_page'">
|
||||
<t t-foreach='survey.question_and_page_ids' t-as='question'>
|
||||
<h2 t-if="question.is_page" t-field='question.title' class="o_survey_title pb16" />
|
||||
@@ -139,32 +154,54 @@
|
||||
Continue</button><span class="font-weight-bold ml-2 text-primary d-none d-md-inline"> or press Enter</span>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<t t-call="survey.survey_progression">
|
||||
<t t-set="page_ids" t-value="survey.page_ids.ids"/>
|
||||
<t t-set="page_number" t-value="page_ids.index(page.id) + (1 if survey.progression_mode == 'number' else 0)"/>
|
||||
</t>
|
||||
<div class="float-right">
|
||||
<t t-call="survey.survey_progression">
|
||||
<t t-set="page_ids" t-value="survey.page_ids.ids"/>
|
||||
<t t-set="page_number" t-value="page_ids.index(page.id) + (1 if survey.progression_mode == 'number' else 0)"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="survey.questions_layout == 'page_per_question'">
|
||||
<input type="hidden" name="question_id" t-att-value="question.id" />
|
||||
<t t-call="survey.question_container"/>
|
||||
<!-- User has already answered for this session -->
|
||||
<t t-if="answer.is_session_answer and (has_answered or answer.question_time_limit_reached)">
|
||||
<fieldset disabled="disabled">
|
||||
<t t-set="survey_form_readonly" t-value="True" />
|
||||
<t t-call="survey.question_container" />
|
||||
</fieldset>
|
||||
<div t-if="answer.question_time_limit_reached and not has_answered" class="font-weight-bold mb128">Sorry, you have not been fast enough.</div>
|
||||
<div t-else="" class="font-weight-bold mb128">We have registered your answer! Please wait for the host to go to the next question.</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="survey.question_container"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="offset-3 col-6 text-center mt16 mb128">
|
||||
<button t-if="survey.users_can_go_back and question != answer.predefined_question_ids[0]" type="submit" class="btn btn-secondary"
|
||||
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Back</button>
|
||||
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
|
||||
Continue</button><span class="font-weight-bold ml-2 text-primary d-none d-md-inline">or press Enter</span>
|
||||
<div class="row">
|
||||
<div class="offset-3 col-6 text-center mt16 mb128">
|
||||
<button t-if="not answer.is_session_answer and survey.users_can_go_back and question != answer.predefined_question_ids[0]" type="submit" class="btn btn-secondary"
|
||||
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Back</button>
|
||||
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
|
||||
<t t-if="answer.is_session_answer">
|
||||
Submit
|
||||
</t>
|
||||
<t t-else="">
|
||||
Continue
|
||||
</t>
|
||||
</button>
|
||||
<span class="font-weight-bold ml-2 text-primary d-none d-md-inline">or press Enter</span>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="float-right">
|
||||
<t t-call="survey.survey_progression">
|
||||
<t t-set="page_ids" t-value="survey.question_ids.ids"/>
|
||||
<t t-set="page_number" t-value="page_ids.index(question.id)"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<t t-call="survey.survey_progression">
|
||||
<t t-set="page_ids" t-value="survey.question_ids.ids"/>
|
||||
<t t-set="page_number" t-value="page_ids.index(question.id)"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
@@ -176,7 +213,7 @@
|
||||
<div t-field="survey.description_done" class="oe_no_empty" />
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<t t-if="survey.scoring_type != 'no_scoring'">
|
||||
<t t-if="survey.scoring_type != 'no_scoring' and survey.scoring_success_min">
|
||||
<div>You scored <t t-esc="answer.scoring_percentage" />%</div>
|
||||
<t t-if="answer.scoring_success">
|
||||
<div>Congratulations, you have passed the test!</div>
|
||||
@@ -301,11 +338,16 @@
|
||||
</template>
|
||||
|
||||
<template id="question_suggested_value_image" name="Image from the question suggested answer">
|
||||
<div t-if="question.allow_value_image and label.value_image"
|
||||
class="d-flex my-3 justify-content-center">
|
||||
<img t-att-src="'/survey/get_question_image/%s/%s/%s/%s' % (survey.access_token, answer.access_token, question.id, label.id)"/>
|
||||
</div>
|
||||
|
||||
<t t-if="question.allow_value_image and label.value_image">
|
||||
<!-- Directly use field or route if the user doesn't have access rights -->
|
||||
<div t-if="not env.user.has_group('survey.group_survey_user')"
|
||||
class="d-flex my-3 justify-content-center">
|
||||
<img t-att-src="'/survey/get_question_image/%s/%s/%s/%s' % (survey.access_token, answer.access_token, question.id, label.id)"/>
|
||||
</div>
|
||||
<div t-else="" t-field="label.value_image"
|
||||
class="d-flex my-3 justify-content-center"
|
||||
t-options="{'widget': 'image', 'alt-field': 'name', 'itemprop': 'image'}"/>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="question_simple_choice" name="Question: simple choice">
|
||||
@@ -488,22 +530,20 @@
|
||||
</template>
|
||||
|
||||
<template id="survey_progression" name="Survey: Progression">
|
||||
<div class="float-right">
|
||||
<t t-if="len(page_ids) > 1">
|
||||
<t t-set="percentage" t-value="round(100*(page_number/len(page_ids)))"/>
|
||||
<t t-if="survey.progression_mode == 'percent'">
|
||||
<span class="o_survey_progress_percent" t-esc="percentage"/> % completed
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="o_survey_progress_number" t-esc="page_number"/> of <span t-esc="len(page_ids)"/>
|
||||
<span t-if="survey.questions_layout == 'page_per_question'">answered</span>
|
||||
<span t-else="">pages</span>
|
||||
</t>
|
||||
<div class="o_survey_progress progress flex-grow-1">
|
||||
<div class="progress-bar bg-primary" t-att-style="'width: ' + str(percentage) + '%'"/>
|
||||
</div>
|
||||
<t t-if="len(page_ids) > 1">
|
||||
<t t-set="percentage" t-value="round(100*(page_number/len(page_ids)))"/>
|
||||
<t t-if="survey.progression_mode == 'percent'">
|
||||
<span class="o_survey_progress_percent" t-esc="percentage"/> % completed
|
||||
</t>
|
||||
</div>
|
||||
<t t-else="">
|
||||
<span class="o_survey_progress_number" t-esc="page_number"/> of <span t-esc="len(page_ids)"/>
|
||||
<span t-if="survey.questions_layout == 'page_per_question'">answered</span>
|
||||
<span t-else="">pages</span>
|
||||
</t>
|
||||
<div class="o_survey_progress progress flex-grow-1">
|
||||
<div class="progress-bar bg-primary" t-att-style="'width: ' + str(percentage) + '%'"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
<template id="survey_button_retake" name="Survey: retake button">
|
||||
<div>
|
||||
<t t-if="not answer.scoring_success">
|
||||
<t t-if="not answer.scoring_success and not answer.is_session_answer">
|
||||
<t t-if="survey.is_attempts_limited">
|
||||
<t t-set="attempts_left" t-value="survey._get_number_of_attempts_lefts(answer.partner_id, answer.email, answer.invite_token)" />
|
||||
<t t-if="attempts_left > 0">
|
||||
@@ -101,5 +101,40 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Survey Home page - Access Code
|
||||
Mainly used in 'session mode' to give an easy access to the survey through the '/s' route. -->
|
||||
<template id="survey_access_code" name="Survey: Access Code page">
|
||||
<t t-call="survey.layout">
|
||||
<t t-set="no_header" t-value="True" />
|
||||
<t t-set="no_footer" t-value="True" />
|
||||
<div class="wrap o_survey_wrap pb16 d-flex">
|
||||
<div class="container o_survey_quick_access d-flex flex-column">
|
||||
<div class="d-flex flex-grow-1 align-items-center">
|
||||
<div class="col-lg-12">
|
||||
<div class="text-center mb32">
|
||||
<h3>Please enter the code</h3>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="offset-md-4 col-md-4 text-center">
|
||||
<input id="access_code" type="text" placeholder="ABC123"
|
||||
class="form-control o_survey_question_text_box font-weight-bold bg-transparent text-primary text-center rounded-0 p-2 w-100"/>
|
||||
</div>
|
||||
<div class="col-3 text-center o_survey_error text-danger d-none pt-2" role="alert">
|
||||
<span>Code is incorrect. Please try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt32 p-2">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</div>
|
||||
<div class="text-center p-2">
|
||||
<span class="text-muted">The code is found on the screen in front of you</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
</template>
|
||||
|
||||
<template id="survey_page_statistics_inner" name="Survey: result statistics content">
|
||||
<div t-if="survey.session_show_ranking" class="o_survey_session_ranking mb-5 mt-1">
|
||||
<h2 class="mt16 text-uppercase text-muted">Ranking</h2>
|
||||
<t t-call="survey.user_input_session_ranking" />
|
||||
</div>
|
||||
<div t-if="question_and_page_data" class="card d-print-none">
|
||||
<div class="card-header"><span class="fa fa-filter"></span> Filters <span t-if="search_filters" class="float-right text-primary clear_survey_filter"><i class="fa fa-times"></i> Clear All Filters</span></div>
|
||||
<div class="card-body">
|
||||
@@ -38,7 +42,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers']">
|
||||
<h1 class="mt16">Results Overview</h1>
|
||||
<h2 class="mt16 text-uppercase text-muted">Results Overview</h2>
|
||||
<div>Success rate: <mark class="font-weight-bold"><t t-esc="survey_data['global_success_rate']"></t>%</mark></div>
|
||||
<div class="survey_graph" data-graph-type="pie" t-att-data-graph-data="survey_data['global_success_graph']">
|
||||
<!-- canvas element for drawing pie chart -->
|
||||
@@ -81,7 +85,6 @@
|
||||
</strong>
|
||||
</t>
|
||||
</div>
|
||||
<div class="ml-3 text-muted" t-field="question.description"/>
|
||||
</div>
|
||||
<!-- Question info -->
|
||||
<span class="badge badge-info" t-field='question.question_type'/>
|
||||
@@ -99,6 +102,10 @@
|
||||
<span class="badge badge-info ml-3"><span t-esc="len(question_data['answer_input_done_ids'])"></span> Answered</span>
|
||||
<span class="badge badge-info ml-1"><span t-esc="len(question_data['answer_input_skipped_ids'])"></span> Skipped</span>
|
||||
</div>
|
||||
|
||||
<!-- Question Description -->
|
||||
<div class="ml-3 text-muted" t-field="question.description"/>
|
||||
|
||||
<t t-if="question.question_type in ['text_box', 'char_box', 'date', 'datetime']">
|
||||
<t t-call="survey.question_result_text"/>
|
||||
</t>
|
||||
@@ -126,20 +133,29 @@
|
||||
<t t-foreach="table_data" t-as="input_line">
|
||||
<tr t-if="not input_line.skipped" t-att-class="'d-none' if input_line_index >= page_record_limit else ''">
|
||||
<td>
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()"><t t-esc="input_line_index + 1"></t></a>
|
||||
<t t-if="no_print_url"><t t-esc="input_line_index + 1"></t></t>
|
||||
<t t-else="">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()">
|
||||
<t t-esc="input_line_index + 1"></t>
|
||||
</a>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<t t-if="question.question_type == 'text_box'">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_text_box"></a>
|
||||
<span t-if="no_print_url" t-field="input_line.value_text_box" />
|
||||
<a t-else="" t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_text_box"></a>
|
||||
</t>
|
||||
<t t-if="question.question_type == 'char_box'">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_char_box"></a>
|
||||
<span t-if="no_print_url" t-field="input_line.value_char_box" />
|
||||
<a t-else="" t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_char_box"></a>
|
||||
</t>
|
||||
<t t-if="question.question_type == 'date'">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_date"></a>
|
||||
<span t-if="no_print_url" t-field="input_line.value_date" />
|
||||
<a t-else="" t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_date"></a>
|
||||
</t>
|
||||
<t t-if="question.question_type == 'datetime'">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_datetime"></a>
|
||||
<span t-if="no_print_url" t-field="input_line.value_datetime" />
|
||||
<a t-else="" t-att-href="input_line.user_input_id.get_print_url()" t-field="input_line.value_datetime"></a>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -201,7 +217,12 @@
|
||||
<t t-foreach="table_data" t-as="input_line">
|
||||
<tr t-if="not input_line.skipped" t-att-class="'d-none' if input_line_index >= page_record_limit else ''">
|
||||
<td>
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()"><t t-esc="input_line_index + 1"></t></a>
|
||||
<t t-if="no_print_url"><t t-esc="input_line_index + 1"></t></t>
|
||||
<t t-else="">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()">
|
||||
<t t-esc="input_line_index + 1"></t>
|
||||
</a>
|
||||
</t>
|
||||
</td>
|
||||
<td><span t-field="input_line.value_numerical_box"></span><br/></td>
|
||||
</tr>
|
||||
@@ -342,7 +363,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="comment_lines" t-as="input_line">
|
||||
<td><a t-att-href="input_line.user_input_id.get_print_url()"><t t-esc="input_line_index + 1"></t></a></td>
|
||||
<td>
|
||||
<t t-if="no_print_url"><t t-esc="input_line_index + 1"></t></t>
|
||||
<t t-else="">
|
||||
<a t-att-href="input_line.user_input_id.get_print_url()">
|
||||
<t t-esc="input_line_index + 1"></t>
|
||||
</a>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<span t-field="input_line.value_char_box"></span><br/>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="user_input_session" name="Survey User Input Session" inherit_id="web.frontend_layout" primary="True">
|
||||
<xpath expr="//div[@id='wrapwrap']" position="attributes">
|
||||
<attribute name="t-att-style" add="('height: 100%; overflow: auto; background: url(' + '/web/image/survey.survey/%s/background_image' % survey.id + ') no-repeat fixed center; box-shadow: inset 0 0 0 10000px rgba(255,255,255,.7); background-size: cover;') if survey and survey.background_image else 'height: 100%; overflow: auto;'"/>
|
||||
</xpath>
|
||||
<xpath expr="//head/t[@t-call-assets][last()]" position="after">
|
||||
<t t-call-assets="survey.survey_user_input_session_assets" lazy_load="True"/>
|
||||
<t t-call-assets="survey.survey_assets" lazy_load="True"/>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="before">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
</xpath>
|
||||
<xpath expr="//header" position="after">
|
||||
<div id="wrap" class="oe_structure oe_empty"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="user_input_session_open" name="Survey: Open Session">
|
||||
<t t-call="survey.user_input_session">
|
||||
<div t-att-data-survey-access-token="survey.access_token"
|
||||
t-att-data-survey-id="survey.id"
|
||||
t-att-data-is-start-screen="True"
|
||||
class="wrap py-3 min-vh-100 align-items-center justify-content-center d-flex o_survey_session_open o_survey_session_manage">
|
||||
<div class="w-75 p-4 o_survey_session_manage_container">
|
||||
<div class="mb-5 text-center">
|
||||
<h1 t-field="survey.title" />
|
||||
<div t-field="survey.description" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12 text-center mb-2" t-if="survey.access_mode == 'public'">
|
||||
<h1>
|
||||
<a t-att-href="survey_url" t-esc="survey_url" target="_blank" />
|
||||
<i class="fa fa-copy font-weight-normal ml-3 o_survey_session_copy" />
|
||||
</h1>
|
||||
<input class="o_survey_session_copy_url d-none" type="text" t-att-value="survey_url" />
|
||||
</div>
|
||||
<div class="col-lg-12 text-center ml-3 font-weight-light o_survey_manage_fontsize_14">
|
||||
Participants <span class="ml-1 font-weight-bold o_survey_session_attendees_count" t-esc="survey.answer_count" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-5">
|
||||
<a role="button" class="btn btn-primary font-weight-bold o_survey_session_start" href="#">
|
||||
Start Session
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="user_input_session_manage" name="Survey: Manage Session">
|
||||
<t t-call="survey.user_input_session">
|
||||
<t t-call="survey.user_input_session_manage_content" />
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="user_input_session_manage_content" name="Survey User Input Session Manage">
|
||||
<div class="wrap py-3 pt-4 min-vh-100 align-items-center d-flex flex-column o_survey_session_manage"
|
||||
t-att-style="'display: none;' if is_rpc_call else ''"
|
||||
t-att-data-is-rpc-call="is_rpc_call"
|
||||
t-att-data-survey-id="survey.id"
|
||||
t-att-data-survey-access-token="survey.access_token"
|
||||
t-att-data-timer="survey.session_question_start_time.isoformat()"
|
||||
t-att-data-time-limit-minutes="survey.session_question_id.time_limit / 60">
|
||||
<nav class="w-75 navbar navbar-expand-lg text-left font-weight-bold pl-0 pb-0">
|
||||
<div class="collapse navbar-collapse">
|
||||
<ul class="nav nav-tabs mr-auto" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a data-toggle="tab" role="tab" class="nav-link active py-2 px-3"
|
||||
aria-controls="#o_survey_session_manage_question" href="#o_survey_session_manage_question">
|
||||
Question
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a data-toggle="tab" role="tab" class="nav-link py-2 px-3 o_survey_session_results_href"
|
||||
aria-controls="#o_survey_session_results" href="#o_survey_session_results">
|
||||
Results
|
||||
</a>
|
||||
</li>
|
||||
<li t-if="survey.session_show_ranking" class="nav-item">
|
||||
<a data-toggle="tab" role="tab" class="nav-link py-2 px-3 o_survey_session_ranking_href"
|
||||
aria-controls="#o_survey_session_ranking" href="#o_survey_session_ranking">
|
||||
Ranking
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="tab-content w-75">
|
||||
<div role="tabpanel" id="o_survey_session_manage_question"
|
||||
class="p-4 px-5 flex-column tab-pane fade show active o_survey_session_manage_question o_survey_session_manage_container">
|
||||
<t t-if="survey.access_mode == 'public'">
|
||||
<h4 class="text-right">
|
||||
<a t-att-href="survey_url" t-esc="survey_url" target="_blank" />
|
||||
<i class="fa fa-copy font-weight-normal ml-3 mr-1 o_survey_session_copy" />
|
||||
</h4>
|
||||
<input class="o_survey_session_copy_url d-none" type="text" t-att-value="survey_url" />
|
||||
</t>
|
||||
<div class="d-flex flex-column justify-content-center flex-grow-1">
|
||||
<div>
|
||||
<fieldset disabled="disabled">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8"><h1 t-esc="survey.title"></h1></div>
|
||||
<h1 t-if="survey.session_question_id.is_time_limited" class="o_survey_timer_container col-lg-4 text-right">
|
||||
<span class="o_survey_timer"/>
|
||||
</h1>
|
||||
</div>
|
||||
<t t-set="question" t-value="survey.session_question_id" />
|
||||
<t t-set="answer" t-value="env['survey.user_input']" />
|
||||
<t t-set="answer_lines" t-value="env['survey.user_input.line']" />
|
||||
<t t-set="survey_form_readonly" t-value="True"/>
|
||||
<t t-call="survey.question_container" />
|
||||
</fieldset>
|
||||
<div class="mb-5">
|
||||
<div class="d-inline-block font-weight-bold">
|
||||
<t t-call="survey.survey_progression">
|
||||
<t t-set="page_ids" t-value="survey.question_ids.ids"/>
|
||||
<t t-set="page_number" t-value="page_ids.index(survey.session_question_id.id)"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="float-right d-inline-block font-weight-bold my-2">
|
||||
Received answers:
|
||||
<span class="o_survey_session_answer_count" t-esc="survey.session_question_answer_count" />
|
||||
/
|
||||
<t t-esc="survey.answer_count" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center position-relative">
|
||||
<a t-if="not is_last_question" role="button" class="btn btn-primary font-weight-bold o_survey_session_next" href="#">
|
||||
Next Question
|
||||
</a>
|
||||
<a role="button" href="#"
|
||||
t-attf-class="btn font-weight-bold o_survey_session_end #{'position-absolute btn-secondary' if not is_last_question else 'btn-primary'}">
|
||||
End Session
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" id="o_survey_session_results"
|
||||
class="p-4 px-5 tab-pane fade o_survey_session_manage_container o_survey_session_results">
|
||||
<div class="row m-0 pt-2 d-flex align-items-center">
|
||||
<div class="col-lg-10 m-0 p-0">
|
||||
<h3 class="m-0">Results</h3>
|
||||
</div>
|
||||
<div class="col-lg-2 m-0 p-0 text-right">
|
||||
<h5 class="m-0">
|
||||
<i class="fa fa-refresh o_survey_session_refresh_results mr-2" />
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_survey_result">
|
||||
</div>
|
||||
</div>
|
||||
<div role="tabpanel" id="o_survey_session_ranking"
|
||||
t-if="survey.session_show_ranking"
|
||||
class="p-4 px-5 tab-pane fade o_survey_session_manage_container o_survey_session_ranking">
|
||||
<div class="row m-0 pt-2 d-flex align-items-center">
|
||||
<div class="col-lg-10 m-0 p-0">
|
||||
<h3 class="m-0">Ranking</h3>
|
||||
</div>
|
||||
<div class="col-lg-2 m-0 p-0 text-right">
|
||||
<h5 class="m-0">
|
||||
<i class="fa fa-refresh o_survey_session_refresh_ranking mr-2" />
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_survey_session_ranking_container">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="user_input_session_ranking" name="Survey User Input Ranking">
|
||||
<div t-if="ranking">
|
||||
<t t-set="max_score" t-value="ranking[0].scoring_total or 1" />
|
||||
<t t-foreach="ranking" t-as="ranking_item">
|
||||
<div class="o_survey_session_ranking_item mb-1">
|
||||
<div t-esc="'%d.' % (ranking_item_index + 1)" class="o_survey_session_ranking_index d-inline-block font-weight-bold"></div>
|
||||
<!-- We keep "10rem" of space to display the points.
|
||||
Then, the length of the bar is a percentage of the attendee's score compared to the max_score. -->
|
||||
<t t-set="width_ratio" t-value="round(ranking_item.scoring_total / max_score, 3)"/>
|
||||
<div class="o_survey_session_ranking_bar mr-1 pr-2 align-top d-inline-block text-right font-weight-bold mb-1"
|
||||
t-esc="ranking_item.nickname"
|
||||
t-att-style="'width: %s' % ('0%' if animate_width else ('calc(calc(%s - 10rem) * %s' % ('100%', width_ratio)))"
|
||||
t-att-data-width-ratio="width_ratio">
|
||||
<span t-if="ranking_item.nickname" t-esc="ranking_item.nickname" />
|
||||
<span t-else="">Anonymous</span>
|
||||
</div>
|
||||
<div class="o_survey_session_ranking_name font-weight-bold align-top d-inline-block">
|
||||
<span t-esc="'%.0f ' % ranking_item.scoring_total" />
|
||||
<span>points</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user