[MERGE] survey: support conditional questions

Purpose
=======

This merge adds a new survey feature : conditional questions. A conditional
question is a question that will be displayed to the user only if the user
selects a specific answer from another previous question i.e. question B is
displayed if user answers 'Choice 1' at question A.

Specifications
==============

There are 2 different ways to handle conditional questions.

  * For one_page and page_per_section survey layout: conditional management is
    done mainly at frontend side. The idea is to be able to load all the
    questions of the section, hide the conditional questions and display them
    dynamically on answer check.
    E.g.: If user checks answer 'Choice 1' from question A, question B is
    displayed immediately.
    If question B is on section 2 and survey layout is set to section per page,
    question B will be displayed immediately on next page template load.
    Indeed, on each page load, server sends all the selected answers and the
    template displays the conditional question depending on the already
    elected answers;

  * For page_per_question survey layout: conditional management is done mainly
    at backend side. The idea is to find on each page the next question to
    display depending on:

    * navigation direction (next - previous)
    * already selected answers and conditional question dependance
    * survey questions sequence

    E.g.: If questions survey are Question A, B and C and question B must be
    displayed if answer 'Choice 1' from question A is selected.
    At Question A, if user select 'Choice 1' and goes next: display B
    At Question B, no matter the answer, if user goes next: display C
    At Question B, no matter the answer, if user goes prev: display A
    At Question A, if user select 'Choice 2' and goes next: display C
    At Question C, no matter the answer, if user goes next: End survey
    At Question C, if user has selected 'Choice 1' at question A and goes prev -> B
    At Question C, if user has selected 'Choice 2' at question A and goes prev -> A

In both modes answers on questions that should not have been displayed to the
user depending on the previous selected answers are cleared.
E.g. : User checks 'choice 1' from question A, then complete question B.
Then user unchecks 'choice 1' -> answer on question B is cleared.

Remove questions that should not have been displayed to the user (depending of
the made choices) in scoring computation and print layout.
--> Inactives questions are removed from predefined questions when user finish
the survey.
E.g.: If the user selects first path on 2, he will never see second path.
If the user answers correctly to all question of first path, he should have
100%, instead of 50%, even if questions of path 2 are mandatory and scored.

Specifications: sessions
========================

This merge makes the live session mode work with conditional question.
The point is to determine which question to display next. As multiple users
are 'voting' for answers of the same questions, the way to determine the
next question to display is to keep only the most voted answer.

The most voted answer will be the only one to trigger the linked conditional
question.

E.g.: considering
- following survey configuration:
    - Q1: A1, A2
    - Q2: triggered by Q1-A1
    - Q3: triggered by Q1-A2
- following user inputs:
    - U1: A1 selected
    - U2: A2 selected
    - U3: A2 selected
The next question displayed to ALL users will be Q3.

Note: If U4 had selected A1, the next question would have been Q2 as
when there is an equality, we select the first one.

Links
=====

Task ID 23509
Upgrade PR odoo/upgrade#906

closes odoo/odoo#44950

Related: odoo/upgrade#906
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2020-03-06 14:29:24 +01:00
committed by GitHub
21 changed files with 973 additions and 133 deletions
+1
View File
@@ -49,6 +49,7 @@ sent mails with personal token for the invitation of the survey.
'data/survey_demo_certification.xml',
'data/survey_demo_quiz.xml',
'data/survey_demo_quiz_userinput.xml',
'data/survey_demo_conditional.xml',
'data/survey.user_input.line.csv'
],
'installable': True,
+34 -12
View File
@@ -275,6 +275,19 @@ class Survey(http.Controller):
'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False),
'format_date': lambda date: format_date(request.env, date)
}
if survey_sudo.questions_layout != 'page_per_question':
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values()
data.update({
'triggering_answer_by_question': {
question.id: triggering_answer_by_question[question].id for question in triggering_answer_by_question.keys()
if triggering_answer_by_question[question]
},
'triggered_questions_by_answer': {
answer.id: [
triggered_questions_by_answer[answer].ids
] for answer in triggered_questions_by_answer.keys()},
'selected_answers': selected_answers.ids
})
if not answer_sudo.is_session_answer and survey_sudo.is_time_limited and answer_sudo.start_datetime:
data.update({
@@ -287,7 +300,7 @@ class Survey(http.Controller):
# Bypass all if page_id is specified (comes from breadcrumb or previous button)
if 'previous_page_id' in post:
previous_page_or_question_id = int(post['previous_page_id'])
new_previous_id = survey_sudo._previous_page_or_question_id(answer_sudo, previous_page_or_question_id)
new_previous_id = survey_sudo._get_next_page_or_question(answer_sudo, previous_page_or_question_id, go_back=True).id
data.update({
page_or_question_key: request.env['survey.question'].sudo().browse(previous_page_or_question_id),
'previous_page_id': new_previous_id,
@@ -297,28 +310,31 @@ class Survey(http.Controller):
if answer_sudo.state == 'in_progress':
if answer_sudo.is_session_answer:
page_or_question_id, is_last = survey_sudo.session_question_id, False
next_page_or_question = survey_sudo.session_question_id
else:
page_or_question_id, is_last = survey_sudo.next_page_or_question(
next_page_or_question = survey_sudo._get_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:
if next_page_or_question:
pages_or_questions = survey_sudo._get_pages_or_questions(answer_sudo)
if pages_or_questions.ids.index(next_page_or_question.id) == len(pages_or_questions) - 1:
data.update({'survey_last': True})
if answer_sudo.is_session_answer and next_page_or_question.is_time_limited:
data.update({
'timer_start': survey_sudo.session_question_start_time.isoformat(),
'time_limit_minutes': page_or_question_id.time_limit / 60
'time_limit_minutes': next_page_or_question.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)
page_or_question_key: next_page_or_question,
'has_answered': answer_sudo.user_input_line_ids.filtered(lambda line: line.question_id == next_page_or_question)
})
if survey_sudo.questions_layout != 'one_page':
data.update({
'previous_page_id': survey_sudo._previous_page_or_question_id(answer_sudo, page_or_question_id.id)
'previous_page_id': survey_sudo._get_next_page_or_question(answer_sudo, next_page_or_question.id, go_back=True).id
})
if is_last:
data.update({'last': True})
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)
@@ -444,7 +460,10 @@ class Survey(http.Controller):
errors = {}
# Prepare answers / comment by question, validate and save answers
inactive_questions = request.env['survey.question'] if answer_sudo.is_session_answer else answer_sudo._get_inactive_conditional_questions()
for question in questions:
if question in inactive_questions: # if question is inactive, skip validation and save
continue
answer, comment = self._extract_comment_from_answers(question, post.get(str(question.id)))
errors.update(question.validate_question(answer, comment))
if not errors.get(question.id):
@@ -453,6 +472,9 @@ class Survey(http.Controller):
if errors and not (answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached):
return {'error': 'validation', 'fields': errors}
if not answer_sudo.is_session_answer:
answer_sudo._clear_inactive_conditional_answers()
if answer_sudo.survey_time_limit_reached or survey_sudo.questions_layout == 'one_page':
answer_sudo._mark_done()
elif 'previous_page_id' in post:
@@ -461,8 +483,8 @@ class Survey(http.Controller):
else:
vals = {'last_displayed_page_id': page_or_question_id}
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:
next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id)
if not next_page:
answer_sudo._mark_done()
answer_sudo.write(vals)
@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import datetime
import json
from dateutil.relativedelta import relativedelta
from werkzeug.exceptions import NotFound
from werkzeug.urls import url_join
from odoo import http
from odoo import fields, http
from odoo.http import request
@@ -57,10 +59,25 @@ class UserInputSession(http.Controller):
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)
next_question = survey._get_session_next_question()
# using datetime.datetime because we want the millis portion
if next_question:
now = datetime.datetime.now()
survey.sudo().write({
'session_question_id': next_question.id,
'session_question_start_time': fields.Datetime.now() + relativedelta(seconds=2)
})
request.env['bus.bus'].sendone(survey.access_token, {
'question_start': now.timestamp(),
'type': '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)
else:
return False
@http.route('/survey/session/results/<string:survey_token>', type='json', auth='user', website=True)
def survey_session_results(self, survey_token, **kwargs):
@@ -0,0 +1,474 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="survey_demo_burger_quiz" model="survey.survey">
<field name="title">Burger Quiz</field>
<field name="access_token">burger00-quiz-1234-abcd-344ca2tgb31e</field>
<field name="state">open</field>
<field name="access_mode">public</field>
<field name="users_can_go_back" eval="True"/>
<field name="scoring_type">scoring_with_answers</field>
<field name="scoring_success_min">55</field>
<field name="is_time_limited" >limited</field>
<field name="time_limit" >10.0</field>
<field name="questions_layout">page_per_question</field>
<field name="description" type="html">
<p>Choose your favourite subject and show how good you are. Ready ?</p></field>
<field name="background_image" type="base64" file="survey/static/src/img/burger_quiz_background.jpg"/>
</record>
<!-- Page 1: Start -->
<record id="survey_demo_burger_quiz_p1" model="survey.question">
<field name="title">Start</field>
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">1</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
</record>
<record id="survey_demo_burger_quiz_p1_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">2</field>
<field name="title">Pick a subject</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_burger_quiz_p1_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="sequence">1</field>
<field name="value">Geography</field>
</record>
<record id="survey_demo_burger_quiz_p1_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="sequence">2</field>
<field name="value">History</field>
</record>
<record id="survey_demo_burger_quiz_p1_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="sequence">3</field>
<field name="value">Sciences</field>
</record>
<record id="survey_demo_burger_quiz_p1_q1_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="sequence">4</field>
<field name="value">Art &amp; Culture</field>
</record>
<!-- Page 2 : Geography -->
<record id="survey_demo_burger_quiz_p2" model="survey.question">
<field name="title">Geography</field>
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">100</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
</record>
<record id="survey_demo_burger_quiz_p2_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">110</field>
<field name="title">How long is the White Nile river?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
</record>
<record id="survey_demo_burger_quiz_p2_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q1"/>
<field name="sequence">1</field>
<field name="value">1450 km</field>
</record>
<record id="survey_demo_burger_quiz_p2_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q1"/>
<field name="sequence">2</field>
<field name="value">3700 km</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p2_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q1"/>
<field name="sequence">3</field>
<field name="value">6650 km</field>
</record>
<!-- TODO DBE: Add free text pages with corrections. 1450 km is Blue Nile, 6500 km is the total Nile lenght. -->
<record id="survey_demo_burger_quiz_p2_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">120</field>
<field name="title">What is the biggest city in the world ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
</record>
<record id="survey_demo_burger_quiz_p2_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/>
<field name="sequence">1</field>
<field name="value">Shanghai</field>
</record>
<record id="survey_demo_burger_quiz_p2_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/>
<field name="sequence">2</field>
<field name="value">Tokyo</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p2_q2_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/>
<field name="sequence">3</field>
<field name="value">New York</field>
</record>
<record id="survey_demo_burger_quiz_p2_q2_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/>
<field name="sequence">4</field>
<field name="value">Istanbul</field>
</record>
<record id="survey_demo_burger_quiz_p2_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">130</field>
<field name="title">What is the highest volcano in Europe ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
</record>
<record id="survey_demo_burger_quiz_p2_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/>
<field name="sequence">1</field>
<field name="value">Mount Teide (Spain - Tenerife)</field>
</record>
<record id="survey_demo_burger_quiz_p2_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/>
<field name="sequence">2</field>
<field name="value">Eyjafjallajökull (Iceland)</field>
</record>
<record id="survey_demo_burger_quiz_p2_q3_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/>
<field name="sequence">3</field>
<field name="value">Mount Etna (Italy - Sicily)</field>
</record>
<record id="survey_demo_burger_quiz_p2_q3_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/>
<field name="sequence">4</field>
<field name="value">Mount Elbrus (Russia)</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<!-- Page 3 : History -->
<record id="survey_demo_burger_quiz_p3" model="survey.question">
<field name="title">History</field>
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">200</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
</record>
<record id="survey_demo_burger_quiz_p3_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">210</field>
<field name="title">When did Genghis Khan die ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
</record>
<record id="survey_demo_burger_quiz_p3_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q1"/>
<field name="sequence">1</field>
<field name="value">1227</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p3_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q1"/>
<field name="sequence">2</field>
<field name="value">1324</field> <!--Marco Polo-->
</record>
<record id="survey_demo_burger_quiz_p3_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q1"/>
<field name="sequence">3</field>
<field name="value">1055</field> <!-- Emperor Xingzong (Liao Dynasty) -->
</record>
<record id="survey_demo_burger_quiz_p3_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">220</field>
<field name="title">Who is the architect of the Great Pyramid of Giza ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
</record>
<record id="survey_demo_burger_quiz_p3_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/>
<field name="sequence">1</field>
<field name="value">Imhotep</field> <!-- Djoser's pyramid -->
</record>
<record id="survey_demo_burger_quiz_p3_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/>
<field name="sequence">2</field>
<field name="value">Amenhotep</field> <!-- Pharaoh -->
</record>
<record id="survey_demo_burger_quiz_p3_q2_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/>
<field name="sequence">3</field>
<field name="value">Hemiunu</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p3_q2_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/>
<field name="sequence">4</field>
<field name="value">Papyrus</field>
</record>
<record id="survey_demo_burger_quiz_p3_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">230</field>
<field name="title">How many years did the 100 years war last ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
</record>
<record id="survey_demo_burger_quiz_p3_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/>
<field name="sequence">1</field>
<field name="value">99 years</field>
</record>
<record id="survey_demo_burger_quiz_p3_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/>
<field name="sequence">2</field>
<field name="value">100 years</field>
</record>
<record id="survey_demo_burger_quiz_p3_q3_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/>
<field name="sequence">3</field>
<field name="value">116 years</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p3_q3_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/>
<field name="sequence">4</field>
<field name="value">127 years</field>
</record>
<!-- Page 4 : Sciences -->
<record id="survey_demo_burger_quiz_p4" model="survey.question">
<field name="title">Sciences</field>
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">300</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
</record>
<record id="survey_demo_burger_quiz_p4_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">310</field>
<field name="title">Who received a Nobel prize in Physics for the discovery of neutrino oscillations, which shows that neutrinos have mass ?</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
</record>
<record id="survey_demo_burger_quiz_p4_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/>
<field name="sequence">1</field>
<field name="value">Arthur B. McDonald</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_burger_quiz_p4_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/>
<field name="sequence">2</field>
<field name="value">Peter W. Higgs</field>
</record>
<record id="survey_demo_burger_quiz_p4_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/>
<field name="sequence">3</field>
<field name="value">Takaaki Kajita</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_burger_quiz_p4_q1_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/>
<field name="sequence">4</field>
<field name="value">Willard S. Boyle</field>
</record>
<record id="survey_demo_burger_quiz_p4_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">320</field>
<field name="title">What is, approximately, the critical mass of plutonium-239 ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
</record>
<record id="survey_demo_burger_quiz_p4_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/>
<field name="sequence">1</field>
<field name="value">5.7 kg</field> <!-- Djoser's pyramid -->
</record>
<record id="survey_demo_burger_quiz_p4_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/>
<field name="sequence">2</field>
<field name="value">10 kg</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p4_q2_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/>
<field name="sequence">3</field>
<field name="value">16.2 kg</field>
</record>
<record id="survey_demo_burger_quiz_p4_q2_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/>
<field name="sequence">4</field>
<field name="value">47 kg</field>
</record>
<record id="survey_demo_burger_quiz_p4_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">330</field>
<field name="title">Can Humans ever directly see a photon ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
</record>
<record id="survey_demo_burger_quiz_p4_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
<field name="sequence">1</field>
<field name="value">Yes, that's the only thing a human eye can see.</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p4_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
<field name="sequence">2</field>
<field name="value">No, it's to small for the human eye.</field>
</record>
<!-- Page 5 : Art & Culture -->
<record id="survey_demo_burger_quiz_p5" model="survey.question">
<field name="title">Art &amp; Culture</field>
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">400</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
</record>
<record id="survey_demo_burger_quiz_p5_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">410</field>
<field name="title">Which Musician is not in the 27th Club ?</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
</record>
<record id="survey_demo_burger_quiz_p5_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/>
<field name="sequence">1</field>
<field name="value">Kurt Cobain</field>
</record>
<record id="survey_demo_burger_quiz_p5_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/>
<field name="sequence">2</field>
<field name="value">Kim Jong-hyun</field> <!-- To distinguish from the North Korean Leader Kim Jong-un -->
</record>
<record id="survey_demo_burger_quiz_p5_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/>
<field name="sequence">3</field>
<field name="value">Avicii</field> <!-- Died at 28 -->
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_burger_quiz_p5_q1_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/>
<field name="sequence">4</field>
<field name="value">Cliff Burton</field> <!-- Died at 24 -->
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_burger_quiz_p5_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">420</field>
<field name="title">Which painting/drawing was not made by Pablo Picasso ?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="allow_value_image" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
</record>
<record id="survey_demo_burger_quiz_p5_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/>
<field name="sequence">1</field>
<field name="value"> </field>
<field name="value_image" type="base64" file="survey/static/src/img/burger_quiz_guernica.jpg"/>
</record>
<record id="survey_demo_burger_quiz_p5_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/>
<field name="sequence">2</field>
<field name="value"> </field>
<field name="value_image" type="base64" file="survey/static/src/img/burger_quiz_cubism_klein.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p5_q2_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/>
<field name="sequence">3</field>
<field name="value"> </field>
<field name="value_image" type="base64" file="survey/static/src/img/burger_quiz_don_quixote.jpg"/>
</record>
<record id="survey_demo_burger_quiz_p5_q2_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/>
<field name="sequence">4</field>
<field name="value"> </field>
<field name="value_image" type="base64" file="survey/static/src/img/burger_quiz_self_portrait.jpg"/>
</record>
<record id="survey_demo_burger_quiz_p5_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">430</field>
<field name="title">Which quote is from Jean-Claude Van Damme</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
<field name="sequence">1</field>
<field name="value">I’ve never really wanted to go to Japan. Simply because I don’t like eating fish. And I know that’s very popular out there in Africa.</field> <!-- Britney Spears -->
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
<field name="sequence">2</field>
<field name="value">I am fascinated by air. If you remove the air from the sky, all the birds would fall to the ground. And all the planes, too.</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
<field name="sequence">3</field>
<field name="value">I've been noticing gravity since I was very young !</field> <!-- Cameron Diaz -->
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
<field name="sequence">4</field>
<field name="value">I actually don't like thinking. I think people think I like to think a lot. And I don't. I do not like to think at all.</field> <!-- Kanye West -->
</record>
</data></odoo>
+12 -12
View File
@@ -198,7 +198,7 @@
</record>
<record id="survey_demo_quiz_p3_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">23</field>
<field name="sequence">24</field>
<field name="title">A "Citrus" could give you ...</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
@@ -235,7 +235,7 @@
</record>
<record id="survey_demo_quiz_p3_q5" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">24</field>
<field name="sequence">25</field>
<field name="title">How often should you water those plants</field>
<field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
@@ -264,7 +264,7 @@
</record>
<record id="survey_demo_quiz_p3_q6" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">25</field>
<field name="sequence">26</field>
<field name="title">When do you harvest those fruits</field>
<field name="description" type="html"><span>Best time to do it, is the right time to do it.</span></field>
<field name="question_type">matrix</field>
@@ -312,7 +312,7 @@
<record id="survey_demo_quiz_p4" model="survey.question">
<field name="title">Trees</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">26</field>
<field name="sequence">30</field>
<field name="is_page" eval="True"/>
<field name="question_type" eval="False"/>
<field name="description" type="html">
@@ -325,7 +325,7 @@
<record id="survey_demo_quiz_p4_q1" model="survey.question">
<field name="title">Dogwood is from which family of trees ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">27</field>
<field name="sequence">31</field>
<field name="question_type">simple_choice</field>
<field name="column_nb">6</field>
<field name="allow_value_image" eval="True"/>
@@ -361,7 +361,7 @@
<record id="survey_demo_quiz_p4_q2" model="survey.question">
<field name="title">In which country did the bonsai technique develop ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">28</field>
<field name="sequence">32</field>
<field name="question_type">simple_choice</field>
<field name="column_nb">6</field>
<field name="allow_value_image" eval="True"/>
@@ -397,7 +397,7 @@
<record id="survey_demo_quiz_p4_q3" model="survey.question">
<field name="title">Is the wood of a coniferous hard or soft ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">29</field>
<field name="sequence">33</field>
<field name="question_type">simple_choice</field>
<field name="column_nb">6</field>
<field name="constr_mandatory" eval="True"/>
@@ -424,7 +424,7 @@
<record id="survey_demo_quiz_p4_q4" model="survey.question">
<field name="title">From which continent is native the Scots pine (pinus sylvestris) ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">30</field>
<field name="sequence">34</field>
<field name="question_type">simple_choice</field>
<field name="allow_value_image" eval="True"/>
<field name="constr_mandatory" eval="True"/>
@@ -465,7 +465,7 @@
<record id="survey_demo_quiz_p4_q5" model="survey.question">
<field name="title">In the list below, select all the corniferous.</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">31</field>
<field name="sequence">35</field>
<field name="question_type">multiple_choice</field>
<field name="column_nb">6</field>
<field name="allow_value_image" eval="True"/>
@@ -507,7 +507,7 @@
<record id="survey_demo_quiz_p4_q6" model="survey.question">
<field name="title">After watching this video, will you swear that you are not going to procrastinate to trim your hedge this year ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">32</field>
<field name="sequence">36</field>
<field name="question_type">simple_choice</field>
<field name="column_nb">6</field>
<field name="description" type="html">
@@ -543,7 +543,7 @@
<record id="survey_demo_quiz_p5" model="survey.question">
<field name="title">Your feeling</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">33</field>
<field name="sequence">40</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
<field name="description" type="html">
@@ -551,7 +551,7 @@
</record>
<record id="survey_demo_quiz_p5_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">34</field>
<field name="sequence">41</field>
<field name="title">What do you think about this survey ?</field>
<field name="description" type="html"><span>If you don't like us, please try to be as objective as possible.</span></field>
<field name="question_type">simple_choice</field>
+36
View File
@@ -130,6 +130,23 @@ class SurveyQuestion(models.Model):
'survey.user_input.line', 'question_id', string='Answers',
domain=[('skipped', '=', False)], groups='survey.group_survey_user')
# Conditional display
is_conditional = fields.Boolean(
string='Conditional Display', copy=False, help="""If checked, this question will be displayed only
if the specified conditional answer have been selected in a previous question""")
triggering_question_id = fields.Many2one(
'survey.question', string="Triggering Question", copy=False, compute="_compute_triggering_question_id",
store=True, readonly=False, help="Question containing the triggering answer to display the current question.",
domain="""[('survey_id', '=', survey_id),
'&', ('question_type', 'in', ['simple_choice', 'multiple_choice']),
'|',
('sequence', '<', sequence),
'&', ('sequence', '=', sequence), ('id', '<', id)]""")
triggering_answer_id = fields.Many2one(
'survey.question.answer', string="Triggering Answer", copy=False, compute="_compute_triggering_answer_id",
store=True, readonly=False, help="Answer that will trigger the display the current question.",
domain="[('question_id', '=', triggering_question_id)]")
_sql_constraints = [
('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'),
('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'),
@@ -193,6 +210,25 @@ class SurveyQuestion(models.Model):
if question.question_type != 'char_box':
question.save_as_nickname = False
@api.depends('is_conditional')
def _compute_triggering_question_id(self):
""" Used as an 'onchange' : Reset the triggering question if user uncheck 'Conditional Display'
Avoid CacheMiss : set the value to False if the value is not set yet."""
for question in self:
if not question.is_conditional or question.triggering_question_id is None:
question.triggering_question_id = False
@api.depends('triggering_question_id')
def _compute_triggering_answer_id(self):
""" Used as an 'onchange' : Reset the triggering answer if user unset or change the triggering question
or uncheck 'Conditional Display'.
Avoid CacheMiss : set the value to False if the value is not set yet."""
for question in self:
if not question.triggering_question_id \
or question.triggering_question_id != question.triggering_answer_id.question_id\
or question.triggering_answer_id is None:
question.triggering_answer_id = False
# Validation methods
def validate_question(self, answer, comment=None):
""" Validate question, depending on question type and parameters
+117 -74
View File
@@ -1,14 +1,11 @@
# -*- 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
@@ -83,7 +80,8 @@ class Survey(models.Model):
string="Scoring", required=True, default='no_scoring')
scoring_success_min = fields.Float('Success %', default=80.0)
# attendees context: attempts and time limitation
is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user")
is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user",
compute="_compute_is_attempts_limited", store=True, readonly=False)
attempts_limit = fields.Integer('Number of attempts', default=1)
is_time_limited = fields.Boolean('The survey is limited in time')
time_limit = fields.Float("Time limit (minutes)")
@@ -126,6 +124,8 @@ class Survey(models.Model):
session_show_leaderboard = fields.Boolean("Show Session Leaderboard", compute='_compute_session_show_leaderboard',
help="Whether or not we want to show the attendees leaderboard for this survey.")
session_speed_rating = fields.Boolean("Reward quick answers", help="Attendees get more points if they answer quickly")
# conditional questions management
has_conditional_questions = fields.Boolean("Contains conditional questions", compute="_compute_has_conditional_questions")
_sql_constraints = [
('access_token_unique', 'unique(access_token)', 'Access token should be unique'),
@@ -178,6 +178,14 @@ 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('question_and_page_ids.is_conditional', 'users_login_required', 'access_mode')
def _compute_is_attempts_limited(self):
for survey in self:
if any(question.is_conditional for question in survey.question_and_page_ids)\
or (survey.access_mode == 'public' and not survey.users_login_required)\
or survey.is_attempts_limited is None:
survey.is_attempts_limited = False
@api.depends('session_start_time', 'user_input_ids')
def _compute_session_answer_count(self):
""" We have to loop since our result is dependent of the survey.session_start_time.
@@ -221,6 +229,11 @@ class Survey(models.Model):
survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \
any(question.save_as_nickname for question in survey.question_and_page_ids)
@api.depends('question_and_page_ids.is_conditional')
def _compute_has_conditional_questions(self):
for survey in self:
survey.has_conditional_questions = any(question.is_conditional for question in survey.question_and_page_ids)
@api.onchange('scoring_success_min')
def _onchange_scoring_success_min(self):
if self.scoring_success_min < 0 or self.scoring_success_min > 100:
@@ -232,11 +245,6 @@ class Survey(models.Model):
self.certification = False
self.is_time_limited = False
@api.onchange('users_login_required', 'access_mode')
def _onchange_access_mode(self):
if self.access_mode == 'public' and not self.users_login_required:
self.is_attempts_limited = False
@api.onchange('attempts_limit')
def _onchange_attempts_limit(self):
if self.attempts_limit <= 0:
@@ -430,63 +438,57 @@ class Survey(models.Model):
@api.model
def _get_pages_or_questions(self, user_input):
if self.questions_layout == 'one_page':
return None
return self.env['survey.question']
elif self.questions_layout == 'page_per_question' and self.questions_selection == 'random':
return list(enumerate(
user_input.predefined_question_ids
))
return user_input.predefined_question_ids
else:
return list(enumerate(
self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids
))
return self.question_ids if self.questions_layout == 'page_per_question' else self.page_ids
@api.model
def _previous_page_or_question_id(self, user_input, page_or_question_id):
survey = user_input.survey_id
pages_or_questions = survey._get_pages_or_questions(user_input)
current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id))
# is first page
if current_page_index == 0:
return None
previous_page_id = pages_or_questions[current_page_index - 1][1].id
return previous_page_id
@api.model
def next_page_or_question(self, user_input, page_or_question_id):
""" The next page to display to the user, knowing that page_id is the id
of the last displayed page.
If page_id == 0, it will always return the first page of the survey.
If all the pages have been displayed, it will return None
.. note::
It is assumed here that a careful user will not try to set go_back
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)
def _get_next_page_or_question(self, user_input, page_or_question_id, go_back=False):
"""
This methods gets the next question or page to display.
In case the survey contains conditional question, when navigating to next or previous questions,
in page_per_question layout only, the next or previous question to display depends on the selected answers
and the questions hierarchy. This methods returns the next question index to display among the survey_questions.
:param user_input: user's answers
:param page_or_question_id: current page or question id
:param go_back: must be True to get the previous question
:return: next or previous question.id
"""
survey = user_input.survey_id
pages_or_questions = survey._get_pages_or_questions(user_input)
if not pages_or_questions:
return (None, False)
Question = self.env['survey.question']
# First page
if page_or_question_id == 0:
return (pages_or_questions[0][1], len(pages_or_questions) == 1)
# Get Next
if not go_back:
if not pages_or_questions:
return Question
# First page
if page_or_question_id == 0:
return pages_or_questions[0]
current_page_index = pages_or_questions.index(next(p for p in pages_or_questions if p[1].id == page_or_question_id))
current_page_index = pages_or_questions.ids.index(next(p.id for p in pages_or_questions if p.id == page_or_question_id))
# All the pages have been displayed
if current_page_index == len(pages_or_questions) - 1:
return (None, False)
# Get previous and we are on first page OR Get Next and we are on last page
if (go_back and current_page_index == 0) or current_page_index == len(pages_or_questions) - 1:
return Question
# Conditional Questions Management
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values()
if survey.has_conditional_questions and survey.questions_layout == 'page_per_question' and triggered_questions_by_answer:
potential_next_questions = pages_or_questions[0:current_page_index] if go_back \
else pages_or_questions[current_page_index+1:]
for question in sorted(potential_next_questions, key=lambda q: q.id, reverse=go_back):
triggering_answer = triggering_answer_by_question.get(question)
if not triggering_answer or triggering_answer in selected_answers:
# If next question found
return question
else:
show_last = current_page_index == len(pages_or_questions) - 2
return (pages_or_questions[current_page_index + 1][1], show_last)
return pages_or_questions[current_page_index + (1 if not go_back else -1)]
return Question
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
@@ -530,6 +532,23 @@ class Survey(models.Model):
questions = questions & answer.predefined_question_ids
return questions, page_or_question_id
# ------------------------------------------------------------
# CONDITIONAL QUESTIONS MANAGEMENT
# ------------------------------------------------------------
def _get_conditional_maps(self):
triggering_answer_by_question = {}
triggered_questions_by_answer = {}
for question in self.question_ids:
triggering_answer_by_question[question] = question.is_conditional and question.triggering_answer_id
if question.is_conditional:
if question.triggering_answer_id in triggered_questions_by_answer:
triggered_questions_by_answer[question.triggering_answer_id] |= question
else:
triggered_questions_by_answer[question.triggering_answer_id] = question
return triggering_answer_by_question, triggered_questions_by_answer
# ------------------------------------------------------------
# SESSIONS MANAGEMENT
# ------------------------------------------------------------
@@ -544,7 +563,7 @@ class Survey(models.Model):
self.sudo().write({'session_state': 'in_progress'})
self.sudo().flush(['session_state'])
def _session_trigger_next_question(self):
def _get_session_next_question(self):
""" Triggers the next question of the session.
We artificially add 2 seconds to the 'current_question_start_time' to account for server delay.
@@ -563,28 +582,52 @@ class Survey(models.Model):
return
if not self.session_question_id:
question = self.question_ids[0]
next_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
most_voted_answers = self._get_session_most_voted_answers()
next_question = self._get_next_page_or_question(most_voted_answers, self.session_question_id.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'
return next_question
def _get_session_most_voted_answers(self):
""" In sessions of survey that has conditional questions, as the survey is passed at the same time by
many users, we need to extract the most chosen answers, to determine the next questions to display. """
# get user_inputs from current session
current_user_inputs = self.user_input_ids.filtered(lambda input: input.create_date > self.session_start_time)
current_user_input_lines = current_user_inputs.mapped('user_input_line_ids').filtered(lambda answer: answer.suggested_answer_id)
# count the number of vote per answer
votes_by_answer = dict.fromkeys(current_user_input_lines.mapped('suggested_answer_id'), 0)
for answer in current_user_input_lines:
votes_by_answer[answer.suggested_answer_id] += 1
# extract most voted answer for each question
most_voted_answer_by_questions = dict.fromkeys(current_user_input_lines.mapped('question_id'))
for question in most_voted_answer_by_questions.keys():
for answer in votes_by_answer.keys():
if answer.question_id != question:
continue
most_voted_answer = most_voted_answer_by_questions[question]
if not most_voted_answer or votes_by_answer[most_voted_answer] < votes_by_answer[answer]:
most_voted_answer_by_questions[question] = answer
# return a fake 'audiance' user_input
fake_user_input = self.env['survey.user_input'].new({
'survey_id': self.id,
})
fake_user_input_lines = self.env['survey.user_input.line']
for question, answer in most_voted_answer_by_questions.items():
fake_user_input_lines |= self.env['survey.user_input.line'].new({
'question_id': question.id,
'suggested_answer_id': answer.id,
'survey_id': self.id,
'user_input_id': fake_user_input.id
})
return fake_user_input
def _prepare_leaderboard_values(self):
"""" The leaderboard is descending and takes the total of the attendee points up to the current question. """
self.ensure_one()
+88 -1
View File
@@ -55,7 +55,7 @@ class SurveyUserInput(models.Model):
('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')
@api.depends('user_input_line_ids.answer_score', 'user_input_line_ids.question_id', 'predefined_question_ids')
def _compute_scoring_values(self):
for user_input in self:
total_possible_score = sum([
@@ -209,6 +209,9 @@ class SurveyUserInput(models.Model):
if user_input.survey_id.certification_give_badge:
badge_ids.append(user_input.survey_id.certification_badge_id.id)
# Update predefined_question_id to remove inactive questions
user_input.predefined_question_ids -= user_input._get_inactive_conditional_questions()
if badge_ids:
challenges = Challenge.search([('reward_id', 'in', badge_ids)])
if challenges:
@@ -359,6 +362,90 @@ class SurveyUserInput(models.Model):
{'text': _("Unanswered"), 'count': res[user_input]['skipped']}
] for user_input in self]
# ------------------------------------------------------------
# Conditional Questions Management
# ------------------------------------------------------------
def _get_conditional_values(self):
""" For survey containing conditional questions, we need a triggered_questions_by_answer map that contains
{key: answer, value: the question that the answer triggers, if selected},
The idea is to be able to verify, on every answer check, if this answer is triggering the display
of another question.
If answer is not in the conditional map:
- nothing happens.
If the answer is in the conditional map:
- If we are in ONE PAGE survey : (handled at CLIENT side)
-> display immediately the depending question
- If we are in PAGE PER SECTION : (handled at CLIENT side)
- If related question is on the same page :
-> display immediately the depending question
- If the related question is not on the same page :
-> keep the answers in memory and check at next page load if the depending question is in there and
display it, if so.
- If we are in PAGE PER QUESTION : (handled at SERVER side)
-> During submit, determine which is the next question to display getting the next question
that is the next in sequence and that is either not triggered by another question's answer, or that
is triggered by an already selected answer.
To do all this, we need to return:
- list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is
updated at client side)
- triggered_questions_by_answer: dict -> for a given answer, list of questions triggered by this answer;
Used mainly for dynamic show/hide behaviour at client side
- triggering_answer_by_question: dict -> for a given question, the answer that triggers it
Used mainly to ease template rendering
"""
triggering_answer_by_question, triggered_questions_by_answer = {}, {}
# Ignore conditional configuration if randomised questions selection
if self.survey_id.questions_selection != 'random':
triggering_answer_by_question, triggered_questions_by_answer = self.survey_id._get_conditional_maps()
selected_answers = self._get_selected_suggested_answers()
return triggering_answer_by_question, triggered_questions_by_answer, selected_answers
def _get_selected_suggested_answers(self):
"""
For now, only simple and multiple choices question type are handled by the conditional questions feature.
Mapping all the suggested answers selected by the user will also include answers from matrix question type,
Those ones won't be used.
Maybe someday, conditional questions feature will be extended to work with matrix question.
:return: all the suggested answer selected by the user.
"""
return self.mapped('user_input_line_ids.suggested_answer_id')
def _clear_inactive_conditional_answers(self):
"""
Clean eventual answers on conditional questions that should not have been displayed to user.
This method is used mainly for page per question survey, a similar method does the same treatment
at client side for the other survey layouts.
E.g.: if depending answer was uncheck after answering conditional question, we need to clear answers
of that conditional question, for two reasons:
- ensure correct scoring
- if the selected answer triggers another question later in the survey, if the answer is not cleared,
a question that should not be displayed to the user will be.
TODO DBE: Maybe this can be the only cleaning method, even for section_per_page or one_page where
conditional questions are, for now, cleared in JS directly. But this can be annoying if user typed a long
answer, changed his mind unchecking depending answer and changed again his mind by rechecking the depending
answer -> For now, the long answer will be lost. If we use this as the master cleaning method,
long answer will be cleared only during submit.
"""
inactive_questions = self._get_inactive_conditional_questions()
# delete user.input.line on question that should not be answered.
answers_to_delete = self.user_input_line_ids.filtered(lambda answer: answer.question_id in inactive_questions)
answers_to_delete.unlink()
def _get_inactive_conditional_questions(self):
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = self._get_conditional_values()
# get questions that should not be answered
inactive_questions = self.env['survey.question']
for answer in triggered_questions_by_answer.keys():
if answer not in selected_answers:
for question in triggered_questions_by_answer[answer]:
inactive_questions |= question
return inactive_questions
class SurveyUserInputLine(models.Model):
_name = 'survey.user_input.line'
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+115 -5
View File
@@ -34,6 +34,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
return this._super.apply(this, arguments).then(function () {
self.options = self.$target.find('form').data();
self.readonly = self.options.readonly;
self.selectedAnswers = self.options.selectedAnswers;
// Init fields
if (!self.options.isStartScreen && !self.readonly) {
self._initTimer();
@@ -105,6 +106,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
* @param {Event} event
*/
_onChangeChoiceItem: function (event) {
var self = this;
var $target = $(event.currentTarget);
var $choiceItemGroup = $target.closest('.o_survey_form_choice');
var $otherItem = $choiceItemGroup.find('.o_survey_js_form_other_comment');
@@ -126,8 +128,42 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
$matrixBtn.closest('tr').find('td').removeClass('o_survey_selected');
$matrixBtn.addClass('o_survey_selected');
} else {
$choiceItemGroup.find('label').removeClass('o_survey_selected');
$target.closest('label').addClass('o_survey_selected');
var previouslySelectedAnswer = $choiceItemGroup.find('label.o_survey_selected');
previouslySelectedAnswer.removeClass('o_survey_selected');
var newlySelectedAnswer = $target.closest('label');
if (newlySelectedAnswer.find('input').val() !== previouslySelectedAnswer.find('input').val()) {
newlySelectedAnswer.addClass('o_survey_selected');
}
// Conditional display
if (this.options.questionsLayout !== 'page_per_question') {
var treatedQuestionIds = []; // Needed to avoid show (1st 'if') then immediately hide (2nd 'if') question during conditional propagation cascade
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes(previouslySelectedAnswer.find('input').val())) {
// Hide and clear depending question
this.options.triggeredQuestionsByAnswer[previouslySelectedAnswer.find('input').val()].forEach(function (questionId) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.addClass('d-none');
self._clearQuestionInputs(dependingQuestion);
treatedQuestionIds.push(questionId);
});
// Remove answer from selected answer
self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1);
}
if (Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) {
// Display depending question
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) {
if (!treatedQuestionIds.includes(questionId)) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.removeClass('d-none');
}
});
// Add answer to selected answer
this.selectedAnswers.push(parseInt($target.val()));
}
}
}
} else { // $target.attr('type') === 'checkbox'
if ($matrixBtn.length > 0) {
@@ -135,6 +171,25 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
} else {
var $label = $target.closest('label');
$label.toggleClass('o_survey_selected', !$label.hasClass('o_survey_selected'));
// Conditional display
if (this.options.questionsLayout !== 'page_per_question' && Object.keys(this.options.triggeredQuestionsByAnswer).includes($target.val())) {
var isInputSelected = $label.hasClass('o_survey_selected');
// Hide and clear or display depending question
this.options.triggeredQuestionsByAnswer[$target.val()].forEach(function (questionId) {
var dependingQuestion = $('.js_question-wrapper#' + questionId);
dependingQuestion.toggleClass('d-none', !isInputSelected);
if (!isInputSelected) {
self._clearQuestionInputs(dependingQuestion);
}
});
// Add/remove answer to/from selected answer
if (!isInputSelected) {
self.selectedAnswers.splice(self.selectedAnswers.indexOf(parseInt($target.val())), 1);
} else {
self.selectedAnswers.push(parseInt($target.val()));
}
}
}
}
},
@@ -342,6 +397,12 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
if (result && !result.error) {
this.$(".o_survey_form_content").empty();
this.$(".o_survey_form_content").html(result);
// Hide timer if end screen (if page_per_question in case of conditional questions)
if (self.options.questionsLayout === 'page_per_question' && this.$('.o_survey_finished').length > 0) {
options.isFinish = true;
}
this.$('div.o_survey_form_date').each(function () {
self._initDateTimePicker($(this));
});
@@ -408,16 +469,24 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
data[key] = value;
});
var inactiveQuestionIds = this.options.sessionInProgress ? [] : this._getInactiveConditionalQuestionIds();
$form.find('[data-question-type]').each(function () {
var $input = $(this);
var $questionWrapper = $input.closest(".js_question-wrapper");
var questionId = $questionWrapper.attr('id');
// If question is inactive, skip validation.
if (inactiveQuestionIds.includes(parseInt(questionId))) {
return;
}
var questionRequired = $questionWrapper.data('required');
var constrErrorMsg = $questionWrapper.data('constrErrorMsg');
var validationErrorMsg = $questionWrapper.data('validationErrorMsg');
var questionId = $questionWrapper.attr('id');
var questionRequired = $questionWrapper.data('required');
switch ($input.data('questionType')) {
case 'char_box':
if (questionRequired && !$input.val()) {
if (questionRequired && !questionInactive && !$input.val()) {
errors[questionId] = constrErrorMsg;
} else if ($input.val() && $input.attr('type') === 'email' && !self._validateEmail($input.val())) {
errors[questionId] = validationEmailMsg;
@@ -835,6 +904,47 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({
}
},
// CONDITIONAL QUESTIONS MANAGEMENT TOOLS
// -------------------------------------------------------------------------
/**
* Clear / Un-select all the input from the given question
* + propagate conditional hierarchy by triggering change on choice inputs.
*
* @private
*/
_clearQuestionInputs: function (question) {
question.find('input').each(function () {
if ($(this).attr('type') === 'text' || $(this).attr('type') === 'number') {
$(this).val('');
} else if ($(this).prop('checked')) {
$(this).prop('checked', false).change();
}
});
question.find('textarea').val('');
},
/**
* Get questions that are not supposed to be answered by the user.
* Those are the ones triggered by answers that the user did not selected.
*
* @private
*/
_getInactiveConditionalQuestionIds: function () {
var self = this;
var inactiveQuestionIds = [];
if (this.options.triggeredQuestionsByAnswer) {
Object.keys(this.options.triggeredQuestionsByAnswer).forEach(function (answerId) {
if (!self.selectedAnswers.includes(parseInt(answerId))) {
self.options.triggeredQuestionsByAnswer[answerId].forEach(function (questionId) {
inactiveQuestionIds.push(questionId);
});
}
});
}
return inactiveQuestionIds;
},
// ERRORS TOOLS
// -------------------------------------------------------------------------
@@ -24,14 +24,21 @@ publicWidget.registry.SurveySessionLeaderboard = publicWidget.Widget.extend({
*
* The width of the progress bars is set after the rendering to enable a width css animation.
*/
showLeaderboard: function () {
showLeaderboard: function (fadeOut) {
var self = this;
var resolveFadeOut;
var fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; });
self.$sessionResults.fadeOut(400, function () {
resolveFadeOut();
});
var fadeOutPromise;
if (fadeOut) {
fadeOutPromise = new Promise(function (resolve, reject) { resolveFadeOut = resolve; });
self.$sessionResults.fadeOut(400, function () {
resolveFadeOut();
});
} else {
fadeOutPromise = Promise.resolve();
self.$sessionResults.hide();
self.$('.o_survey_session_leaderboard_container').empty();
}
var leaderboardPromise = this._rpc({
route: _.str.sprintf('/survey/session/leaderboard/%s', this.surveyAccessToken)
@@ -161,7 +161,7 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({
this.$('.o_survey_session_leaderboard_title').text(_('Final Leaderboard'));
this.$('.o_survey_session_navigation_next').addClass('d-none');
}
this.leaderBoard.showLeaderboard();
this.leaderBoard.showLeaderboard(true);
} else {
if (!this.isLastQuestion) {
this._nextQuestion();
@@ -317,12 +317,23 @@ publicWidget.registry.SurveySessionManage = publicWidget.Widget.extend({
}
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();
});
if (results[1]) {
var $renderedTemplate = $(results[1]);
self.$el.replaceWith($renderedTemplate);
self.attachTo($renderedTemplate);
self.$el.fadeIn(1000, function () {
self._startTimer();
});
} else {
// Display last screen
self.isLastQuestion = true;
self._setupLeaderboard().then(function () {
self.$('.o_survey_session_leaderboard_title').text(_('Final Leaderboard'));
self.$('.o_survey_session_navigation_next').addClass('d-none');
self.$('.o_survey_leaderboard_buttons').removeClass('d-none');
self.leaderBoard.showLeaderboard(false);
});
}
});
},
@@ -20,3 +20,7 @@ table.o_section_list_view tr.o_data_row.o_is_section {
border-top: 1px solid #BBB;
border-bottom: 1px solid #BBB;
}
.icon_rotates {
transform: rotate(180deg);
}
+17 -3
View File
@@ -157,9 +157,19 @@
<field name="matrix_subtype" attrs="{'invisible':[('question_type','not in',['matrix'])],'required':[('question_type','=','matrix')]}"/>
</group>
</group>
<group string="Display" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
<field name="column_nb" string="Number of columns"/>
<field name="allow_value_image"/>
<group>
<group string="Display" attrs="{'invisible':[('question_type','not in',['simple_choice', 'multiple_choice'])]}">
<field name="column_nb" string="Number of columns"/>
<field name="allow_value_image"/>
</group>
<group string="Conditional Display" attrs="{'invisible': [('questions_selection', '=', 'random')]}">
<field name="is_conditional"/>
<field name="triggering_question_id" options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': [('is_conditional','=', False)], 'required': [('is_conditional','=', True)]}"/>
<field name="triggering_answer_id" options="{'no_open': True, 'no_create': True}"
attrs="{'invisible': ['|', ('is_conditional','=', False), ('triggering_question_id','=', False)],
'required': [('is_conditional','=', True)]}"/>
</group>
</group>
<group string="Allow Comments" attrs="{'invisible':[('question_type','not in',['simple_choice','multiple_choice', 'matrix'])]}">
<field name='comments_allowed' />
@@ -190,6 +200,9 @@
<field name="title"/>
<field name="survey_id"/>
<field name="question_type"/>
<field name="triggering_question_id" invisible="1"/>
<button icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
title="This question depends on another question's answer." class="icon_rotates"/>
</tree>
</field>
</record>
@@ -203,6 +216,7 @@
<field name="question_type" string="Type"/>
<group expand="1" string="Group By">
<filter name="group_by_type" string="Type" domain="[]" context="{'group_by':'question_type'}"/>
<filter name="group_by_survey" string="Survey" domain="[]" context="{'group_by':'survey_id'}"/>
</group>
</search>
</field>
+8 -2
View File
@@ -57,6 +57,7 @@
<group>
<group>
<field name="active" invisible="1"/>
<field name="has_conditional_questions" invisible="1"/>
</group>
</group>
<notebook>
@@ -68,7 +69,11 @@
<field name="question_type" />
<field name="is_page" invisible="1"/>
<field name="questions_selection" invisible="1"/>
<field name="survey_id" invisible="1"/>
<field name="triggering_question_id" invisible="1"/>
<field name="random_questions_count" attrs="{'column_invisible': [('parent.questions_selection', '=', 'all')], 'invisible': [('is_page', '=', False)]}" />
<button icon="fa-code-fork" attrs="{'invisible': [('triggering_question_id', '=', False)]}"
title="This question depends on another question's answer." class="icon_rotates"/>
<control>
<create name="add_section_control" string="Add a section" context="{'default_is_page': True, 'default_questions_selection': 'all'}"/>
<create name="add_question_control" string="Add a question"/>
@@ -96,8 +101,9 @@
<group string="Candidates" name="candidates">
<field name="access_mode"/>
<field name="users_login_required"/>
<label for="is_attempts_limited" string="Attempts Limit" attrs="{'invisible': [('access_mode', '=', 'public'), ('users_login_required', '=', False)]}"/>
<div attrs="{'invisible': [('access_mode', '=', 'public'), ('users_login_required', '=', False)]}">
<label for="is_attempts_limited" string="Attempts Limit"
attrs="{'invisible': ['|', ('has_conditional_questions', '=', True), '&amp;', ('access_mode', '=', 'public'), ('users_login_required', '=', False)]}"/>
<div attrs="{'invisible': ['|', ('has_conditional_questions', '=', True), '&amp;', ('access_mode', '=', 'public'), ('users_login_required', '=', False)]}">
<field name="is_attempts_limited" nolabel="1"/>
<field name="attempts_limit" attrs="{'invisible': [('is_attempts_limited', '=', False)]}" nolabel="1" class="oe_inline" /> <span attrs="{'invisible': [('is_attempts_limited', '=', False)]}"> attempts</span>
</div>
+14 -6
View File
@@ -58,7 +58,7 @@
</div>
</div>
<div t-att-class="'o_survey_breadcrumb_container mt8' + (' d-none ' if answer.state != 'in_progress' else '')"
t-if="survey.questions_layout == 'page_per_section' and answer.state != 'done'"
t-if="not survey.has_conditional_questions and survey.questions_layout == 'page_per_section' and answer.state != 'done'"
t-att-data-can-go-back="survey.users_can_go_back"
t-att-data-pages="json.dumps(breadcrumb_pages)" />
</div>
@@ -74,7 +74,9 @@
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">
t-att-data-questions-layout="survey.questions_layout"
t-att-data-triggered-questions-by-answer="json.dumps(triggered_questions_by_answer)"
t-att-data-selected-answers="json.dumps(selected_answers)">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="token" t-att-value="answer.access_token" />
<div class="o_survey_error alert alert-danger d-none" role="alert">
@@ -164,7 +166,7 @@
<div class="offset-3 col-6 text-center mt16 mb128">
<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">
<button type="submit" t-att-value="'next' if not survey_last else 'finish'" class="btn btn-primary">
Continue</button><span class="font-weight-bold ml-2 d-none d-md-inline"> or press Enter</span>
</div>
<div class="col-3">
@@ -196,7 +198,7 @@
<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">
<button type="submit" t-att-value="'next' if not survey_last else 'finish'" class="btn btn-primary">
<t t-if="answer.is_session_answer">
Submit
</t>
@@ -274,11 +276,17 @@
<!-- Question widgets -->
<template id="question_container" name="Survey: question container">
<t t-set="display_question"
t-value="survey.questions_layout == 'page_per_question'
or (survey.questions_layout == 'one_page' and not question.is_conditional)
or (survey.questions_layout == 'page_per_section' and (not question.is_conditional or triggering_answer_by_question[question.id] in selected_answers))"/>
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<!--Use Key selection if number of choices is < 26 to keep Z for other choice if any-->
<t t-set="letters" t-value="'ABCDEFGHIJKLMNOPQRSTUVWXYZ'"/>
<t t-set="useKeySelection" t-value="len(question.suggested_answer_ids) &lt; len(letters) and survey.questions_layout == 'page_per_question'"/>
<div class="js_question-wrapper pb-4" t-att-id="question.id" t-att-data-required="question.constr_mandatory"
<div t-att-class="'js_question-wrapper pb-4 %s' % ('d-none' if not display_question else '')"
t-att-id="question.id" t-att-data-required="question.constr_mandatory"
t-att-data-constr-error-msg="question.constr_error_msg"
t-att-data-validation-error-msg="question.validation_error_msg">
<h4>
@@ -544,7 +552,7 @@
</template>
<template id="survey_progression" name="Survey: Progression">
<t t-if="len(page_ids) > 1">
<t t-if="len(page_ids) > 1 and not survey.has_conditional_questions">
<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
@@ -130,7 +130,7 @@
<div class="o_survey_session_leaderboard w-100 flex-column flex-grow-1" style="display: none;">
<div class="d-flex">
<h1 class="o_survey_session_leaderboard_title flex-grow-1">Leaderboard</h1>
<div t-if="is_last_question" class="o_survey_leaderboard_buttons font-weight-bold">
<div t-att-class="'o_survey_leaderboard_buttons font-weight-bold %s' % 'd-none' if not is_last_question else ''">
<a href="#" role="button" class="o_survey_session_close btn btn-primary mr-4"><i class="fa fa-close"/> Close</a>
<a href="#" role="button" class="o_survey_session_close btn btn-primary" t-att-data-show-results="True"><i class="fa fa-bar-chart"/> Results</a>
</div>