[IMP] survey: integrate conditional questions in session mode

Purpose
=======

This commit 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.

Link
====

Task ID: 23509
PR #44950
This commit is contained in:
David Beguin
2020-03-06 10:29:50 +00:00
committed by Thibault Delavallée
parent 3968bed239
commit 2d499c0236
6 changed files with 98 additions and 42 deletions
+2 -2
View File
@@ -460,7 +460,7 @@ class Survey(http.Controller):
errors = {}
# Prepare answers / comment by question, validate and save answers
inactive_questions = answer_sudo._get_inactive_conditional_questions()
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
@@ -472,7 +472,7 @@ 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: # Ignore conditional configurations during survey session.
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':
@@ -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):
+43 -22
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
@@ -566,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.
@@ -585,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()
@@ -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);
});
}
});
},
@@ -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>