[MERGE][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 within survey. It adds a layer of
"survey session" on top if it that gathers attendees and answers during
the lifespan of the session.

In addition, we fixed/pimped several survey features:
- we now prevent 'enter' submit when survey is done to avoid a raise.
- we made a few small improvements on the frontend views
- we now correctly remove the timer and initialize the result
  widget when the user presses 'enter' on the last page / question.

See sub-commits for details.

Task 1972640

closes odoo/odoo#43568

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2020-01-31 16:36:55 +01:00
committed by GitHub
28 changed files with 1873 additions and 298 deletions
+8
View File
@@ -8,6 +8,14 @@
</xpath>
</template>
<template id="assets_frontend" name="im assets" inherit_id="web.assets_frontend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/bus/static/src/js/longpolling_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/crosstab_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
</xpath>
</template>
<template id="qunit_suite" name="bus tests" inherit_id="web.qunit_suite">
<xpath expr="//script[last()]" position="after">
<script type="text/javascript" src="/bus/static/tests/bus_tests.js"/>
+1
View File
@@ -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',
+1
View File
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
from . import survey_session_manage
+110 -23
View File
@@ -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()),
}
+1 -1
View File
@@ -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" />
+36 -9
View File
@@ -9,7 +9,7 @@
<field name="users_can_go_back" eval="False"/>
<field name="scoring_type">scoring_with_answers</field>
<field name="scoring_success_min">55</field>
<field name="questions_layout">page_per_section</field>
<field name="questions_layout">page_per_question</field>
<field name="description" type="html">
<p>This small quiz will test your knowledge about our Company. Be prepared !</p></field>
<field name="background_image" type="base64" file="survey/static/src/img/survey_background.jpg"/>
@@ -28,13 +28,30 @@
<record id="survey_demo_quiz_p1_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">2</field>
<field name="title">Where are you from ?</field>
<field name="title">What is your email ?</field>
<field name="question_type">char_box</field>
<field name="constr_mandatory" eval="True"/>
<field name="validation_email" eval="True"/>
<field name="save_as_email" eval="True"/>
</record>
<record id="survey_demo_quiz_p1_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">3</field>
<field name="title">What is your nickname ?</field>
<field name="question_type">char_box</field>
<field name="constr_mandatory" eval="True"/>
<field name="save_as_nickname" eval="True"/>
</record>
<record id="survey_demo_quiz_p1_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">4</field>
<field name="title">Where are you from ?</field>
<field name="question_type">char_box</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p1_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">5</field>
<field name="title">How old are you ?</field>
<field name="description" type="html"><p>Just to categorize your answers, don't worry.</p></field>
<field name="question_type">numerical_box</field>
@@ -99,14 +116,14 @@
<field name="sequence">1</field>
<field name="value">Fruits</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">2</field>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="sequence">2</field>
<field name="value">Vegetables</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">1</field>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_quiz_p3_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
@@ -121,13 +138,15 @@
<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"/>
<field name="sequence">1</field>
<field name="value">Bees</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">2</field>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
@@ -148,30 +167,34 @@
<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"/>
<field name="sequence">1</field>
<field name="value">Apple Trees</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">2</field>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">2</field>
<field name="value">Lemon Trees</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">1</field>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">3</field>
<field name="value">Baobab Trees</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">4</field>
<field name="value">Cookies</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
@@ -181,30 +204,34 @@
<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"/>
<field name="sequence">1</field>
<field name="value">Pomelos</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">2</field>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">2</field>
<field name="value">Grapefruits</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">2</field>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">3</field>
<field name="value">Cosmic rays</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">4</field>
<field name="value">Bricks</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q5" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
@@ -42,11 +42,23 @@
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Brussels</field>
<field name="value_char_box">mark.brown23@example.com</field>
</record>
<record id="survey_demo_quiz_answer_1_p1_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Mark Brown</field>
</record>
<record id="survey_demo_quiz_answer_1_p1_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Brussels</field>
</record>
<record id="survey_demo_quiz_answer_1_p1_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q4"/>
<field name="answer_type">numerical_box</field>
<field name="value_numerical_box">36</field>
</record>
@@ -188,14 +200,26 @@
<!-- Page 1: general informations -->
<record id="survey_demo_quiz_answer_2_p1_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">admin@yourcompany.example.com</field>
</record>
<record id="survey_demo_quiz_answer_2_p1_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Mitchell Admin</field>
</record>
<record id="survey_demo_quiz_answer_2_p1_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Ottawa</field>
</record>
<record id="survey_demo_quiz_answer_2_p1_q2_l1" model="survey.user_input.line">
<record id="survey_demo_quiz_answer_2_p1_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q4"/>
<field name="answer_type">numerical_box</field>
<field name="value_numerical_box">48</field>
</record>
@@ -330,14 +354,26 @@
<!-- Page 1: general informations -->
<record id="survey_demo_quiz_answer_3_p1_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">joel.willis63@example.com</field>
</record>
<record id="survey_demo_quiz_answer_3_p1_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Joël Willis</field>
</record>
<record id="survey_demo_quiz_answer_3_p1_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Brussels</field>
</record>
<record id="survey_demo_quiz_answer_3_p1_q2_l1" model="survey.user_input.line">
<record id="survey_demo_quiz_answer_3_p1_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q4"/>
<field name="answer_type">numerical_box</field>
<field name="value_numerical_box">28</field>
</record>
+14 -2
View File
@@ -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
+178 -2
View File
@@ -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
+97 -18
View File
@@ -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
+169 -29
View File
@@ -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;
});
+12 -4
View File
@@ -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;
});
+1 -2
View File
@@ -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');
+161 -34
View File
@@ -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;
}
}
}
+5
View File
@@ -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)
+3
View File
@@ -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']},
+6
View File
@@ -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>
+13 -2
View File
@@ -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', '&lt;=', 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>
+113 -63
View File
@@ -10,14 +10,25 @@
<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>
<xpath expr="//footer" position="after">
<div t-if="no_footer" class="py-3">
<div class="float-right px-5 py-2 bg-white rounded-left">
<t t-call="web.brand_promotion_message">
<t t-set="_message"></t>
<t t-set="_utm_medium" t-valuef="survey"/>
</t>
</div>
</div>
</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 +47,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 +61,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 +103,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" />
@@ -118,7 +143,7 @@
<div class="text-center mt16 mb256">
<button type="submit" value="finish" class="btn btn-primary">
Submit</button><span class="font-weight-bold ml-2 text-primary d-none d-md-inline">or press Enter</span>
Submit</button><span class="font-weight-bold ml-2 d-none d-md-inline">or press Enter</span>
</div>
</t>
@@ -136,35 +161,57 @@
<button t-if="survey.users_can_go_back and page != survey.page_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>
Continue</button><span class="font-weight-bold ml-2 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 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 +223,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 +348,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 +540,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>
@@ -6,100 +6,121 @@
<t t-set="page_record_limit" t-value="10"/><!-- Change this record_limit to change number of record per page-->
<div class="container o_survey_result">
<t t-call="survey.survey_button_form_view" />
<div class="jumbotron mt32">
<h1>
<span t-field="survey.title"/>
<span style="font-size:1.5em;"
t-attf-class="fa fa-bar-chart-o #{'fa-bar-chart-o' if survey.scoring_type == 'no_scoring' else 'fa-trophy' if survey.certification else 'fa-question-circle-o'} float-right " role="img" aria-label="Chart" title="Chart"/>
</h1>
<div t-field="survey.description" class="oe_no_empty"/>
<h2 t-if="not question_and_page_data">
Sorry, no one answered this survey yet.
</h2>
</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">
<span t-att-class="'badge only_left_radius filter-all %s' % 'badge-secondary' if search_finished else 'badge-primary o_active_filter'">All surveys</span>
<span t-att-class="'badge only_right_radius filter-finished %s' % 'badge-secondary' if not search_finished else 'badge-primary o_active_filter'">Finished surveys</span>
<span t-foreach="search_filters" t-as="filter_data">
<span class="badge badge-primary only_left_radius"><i class="fa fa-filter" role="img" aria-label="Filter" title="Filter"></i></span><span class="badge badge-primary no_radius" t-esc="filter_data['question']"></span><span class="badge badge-success only_right_radius" t-esc="' > '.join(filter_data['answers'])"></span>
</span>
</div>
</div>
<div t-if="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers']">
<h1 class="mt16">Results Overview</h1>
<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 -->
<canvas/>
</div>
<hr/>
</div>
<div t-foreach="question_and_page_data" t-as='question_data'>
<t t-set="question" t-value="question_data['question']"/>
<t t-if="question_data['is_page']">
<h2 class="mt16 text-uppercase text-muted" t-field="question.title"/>
<div t-field="question.description" class="oe_no_empty" />
<hr class="mt-2 pb-1 mb-1"/>
</t>
<div t-else="" class="ml-4 mt-4">
<t t-set="question_scoring" t-value="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers'] and question.question_type in ['simple_choice', 'multiple_choice']"/>
<t t-set="comment_lines" t-value="question_data['comment_line_ids']"/>
<t t-set="graph_data" t-value="question_data['graph_data']"/>
<t t-set="table_data" t-value="question_data['table_data']"/>
<div class="d-flex align-items-start mb-2">
<div class="mr-auto">
<h5 t-field="question.title" class="mb-1"/>
<div t-if="question_scoring">
<t t-if="len(question_data['right_answers']) >= 1">
<span t-if="len(question_data['right_answers']) == 1">Right answer: </span>
<span t-if="len(question_data['right_answers']) > 1">Right answers: </span>
<strong>
<t t-foreach="question_data['right_answers']" t-as="answer">
<span t-if="answer_index != 0">, </span><span t-esc="answer.value"/>
</t>
</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'/>
<t t-if="question.question_type == 'matrix'">
<span class="badge badge-info ml-2" t-field='question.matrix_subtype'/>
</t>
<!-- Scoring info -->
<t t-if="question_scoring">
<span class="badge badge-success ml-3"><span t-esc="len(question_data['right_inputs'])"></span> Correct</span>
<span class="badge badge-warning ml-1" t-if="question.question_type == 'multiple_choice'">
<span t-esc="len(question_data['partial_inputs'])"></span> Partial
</span>
</t>
<!-- Inputs info -->
<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>
<t t-if="question.question_type in ['text_box', 'char_box', 'date', 'datetime']">
<t t-call="survey.question_result_text"/>
</t>
<t t-elif="question.question_type in ['numerical_box']">
<t t-call="survey.question_result_number"></t>
</t>
<t t-elif="question.question_type in ['simple_choice', 'multiple_choice']">
<t t-call="survey.question_result_choice"></t>
</t>
<t t-elif="question.question_type in ['matrix']">
<t t-call="survey.question_result_matrix"></t>
</t>
</div>
</div>
<t t-call="survey.survey_page_statistics_header" />
<t t-call="survey.survey_page_statistics_inner" />
</div>
</t>
</template>
<template id="survey_page_statistics_header" name="Survey: result statistics header">
<div class="jumbotron mt32">
<h1>
<span t-field="survey.title"/>
<span style="font-size:1.5em;"
t-attf-class="fa fa-bar-chart-o #{'fa-bar-chart-o' if survey.scoring_type == 'no_scoring' else 'fa-trophy' if survey.certification else 'fa-question-circle-o'} float-right " role="img" aria-label="Chart" title="Chart"/>
</h1>
<div t-field="survey.description" class="oe_no_empty"/>
<h2 t-if="not question_and_page_data">
Sorry, no one answered this survey yet.
</h2>
</div>
</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">
<span t-att-class="'badge only_left_radius filter-all %s' % 'badge-secondary' if search_finished else 'badge-primary o_active_filter'">All surveys</span>
<span t-att-class="'badge only_right_radius filter-finished %s' % 'badge-secondary' if not search_finished else 'badge-primary o_active_filter'">Finished surveys</span>
<span t-foreach="search_filters" t-as="filter_data">
<span class="badge badge-primary only_left_radius"><i class="fa fa-filter" role="img" aria-label="Filter" title="Filter"></i></span><span class="badge badge-primary no_radius" t-esc="filter_data['question']"></span><span class="badge badge-success only_right_radius" t-esc="' > '.join(filter_data['answers'])"></span>
</span>
</div>
</div>
<div t-if="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers']">
<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 -->
<canvas/>
</div>
<hr/>
</div>
<div t-foreach="question_and_page_data" t-as='question_data'>
<t t-set="question" t-value="question_data['question']"/>
<t t-if="question_data['is_page']">
<h2 class="mt16 text-uppercase text-muted" t-field="question.title"/>
<div t-field="question.description" class="oe_no_empty" />
<hr class="mt-2 pb-1 mb-1"/>
</t>
<div t-else="" class="ml-4 mt-4">
<t t-call="survey.survey_page_statistics_question" />
</div>
</div>
</template>
<template id="survey_page_statistics_question" name="Question: result statistics">
<t t-set="question_scoring" t-value="survey.scoring_type in ['scoring_with_answers', 'scoring_without_answers'] and question.question_type in ['simple_choice', 'multiple_choice']"/>
<t t-set="comment_lines" t-value="question_data['comment_line_ids']"/>
<t t-set="graph_data" t-value="question_data['graph_data']"/>
<t t-set="table_data" t-value="question_data['table_data']"/>
<div>
<div class="d-flex align-items-start mb-2">
<div class="mr-auto">
<h5 t-field="question.title" class="mb-1"/>
<div t-if="question_scoring">
<t t-if="len(question_data['right_answers']) >= 1">
<span t-if="len(question_data['right_answers']) == 1">Right answer: </span>
<span t-if="len(question_data['right_answers']) > 1">Right answers: </span>
<strong>
<t t-foreach="question_data['right_answers']" t-as="answer">
<span t-if="answer_index != 0">, </span><span t-esc="answer.value"/>
</t>
</strong>
</t>
</div>
</div>
<!-- Question info -->
<span class="badge badge-info" t-field='question.question_type'/>
<t t-if="question.question_type == 'matrix'">
<span class="badge badge-info ml-2" t-field='question.matrix_subtype'/>
</t>
<!-- Scoring info -->
<t t-if="question_scoring">
<span class="badge badge-success ml-3"><span t-esc="len(question_data['right_inputs'])"></span> Correct</span>
<span class="badge badge-warning ml-1" t-if="question.question_type == 'multiple_choice'">
<span t-esc="len(question_data['partial_inputs'])"></span> Partial
</span>
</t>
<!-- Inputs info -->
<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>
<t t-elif="question.question_type in ['numerical_box']">
<t t-call="survey.question_result_number"></t>
</t>
<t t-elif="question.question_type in ['simple_choice', 'multiple_choice']">
<t t-call="survey.question_result_choice"></t>
</t>
<t t-elif="question.question_type in ['matrix']">
<t t-call="survey.question_result_matrix"></t>
</t>
</div>
</template>
<template id="question_result_text" name="Question: text result (text_box, char_box, date, datetime)">
<table class="table table-hover table-sm" t-att-id="'survey_table_question_%d' % question.id">
<thead>
@@ -112,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>
@@ -187,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>
@@ -328,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>
@@ -20,10 +20,6 @@
<script type="text/javascript" src="/mail/static/src/js/thread_windows/abstract_thread_window.js"></script>
<script type="text/javascript" src="/im_livechat/static/src/js/website_livechat_window.js"></script>
<script type="text/javascript" src="/bus/static/src/js/longpolling_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/crosstab_bus.js"></script>
<script type="text/javascript" src="/bus/static/src/js/services/bus_service.js"></script>
<script type="text/javascript" src="/mail/static/src/js/document_viewer.js"></script>
<script type="text/javascript" src="/mail/static/src/js/thread_widget.js"></script>
<script type="text/javascript" src="/mail/static/src/js/utils.js"></script>