[MERGE] survey: perform a back 2 basics

PURPOSE

Perform a back2basics review of survey to improve daily use, new user
onboarding and labelling.

SPECIFICATIONS

Improve labels of fields and wording in actions. See commit content for proper
specifications.

Reorder some code to better understand module organization.

Order answer lines following question order.

Display section in answer lines form view to better understand question and
answer organization.

Add a correct flag on answer lines and display it in user input embedded
view in order to know which line are correct.

When finishing a certification survey, add an overall correctness circular
graph. Quarters are

* correct
* partially (if multiple corrects answers on the survey);
* incorrect
* unanswered

Questions without points are not displayed in this graph.

LINKS

Task 2039068

closes odoo/odoo#36229

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2019-08-30 11:58:09 +02:00
committed by GitHub
7 changed files with 309 additions and 178 deletions
+20
View File
@@ -20,6 +20,10 @@ _logger = logging.getLogger(__name__)
class Survey(http.Controller):
# ------------------------------------------------------------
# ACCESS
# ------------------------------------------------------------
def _fetch_from_access_token(self, survey_token, answer_token):
""" Check that given token matches an answer from the given survey_id.
Returns a sudo-ed browse record of survey in order to avoid access rights
@@ -182,6 +186,10 @@ class Survey(http.Controller):
'deadline': answer.deadline,
}
# ------------------------------------------------------------
# TAKING SURVEY ROUTES
# ------------------------------------------------------------
@http.route('/survey/start/<string:survey_token>', type='http', auth='public', website=True)
def survey_start(self, survey_token, answer_token=None, email=False, **post):
""" Start a survey by providing
@@ -423,6 +431,10 @@ class Survey(http.Controller):
return json.dumps(ret)
# ------------------------------------------------------------
# COMPLETED SURVEY ROUTES
# ------------------------------------------------------------
@http.route('/survey/print/<string:survey_token>', type='http', auth='public', website=True)
def survey_print(self, survey_token, review=False, answer_token=None, **post):
'''Display an survey in printable view; if <answer_token> is set, it will
@@ -639,4 +651,12 @@ class Survey(http.Controller):
values = {'survey': survey, 'answer': answer}
if token:
values['token'] = token
if survey.scoring_type != 'no_scoring' and survey.certificate:
answer_perf = survey._get_answers_correctness(answer)[answer]
values['graph_data'] = json.dumps([
{"text": "Correct", "count": answer_perf['correct']},
{"text": "Partially", "count": answer_perf['partial']},
{"text": "Incorrect", "count": answer_perf['incorrect']},
{"text": "Unanswered", "count": answer_perf['skipped']}
])
return values
+209 -160
View File
@@ -8,7 +8,7 @@ from itertools import product
from werkzeug import urls
import random
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.osv import expression
@@ -25,7 +25,7 @@ class Survey(models.Model):
return str(uuid.uuid4())
# description
title = fields.Char('Title', required=True, translate=True)
title = fields.Char('Survey Title', required=True, translate=True)
description = fields.Html("Description", translate=True,
help="The description will be displayed on the home page of the survey. You can use this to give the purpose and guidelines to your candidates before they start it.")
color = fields.Integer('Color Index', default=0)
@@ -188,7 +188,10 @@ class Survey(models.Model):
if not self.users_login_required or not self.certificate:
self.certification_give_badge = False
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@api.model
def create(self, vals):
survey = super(Survey, self).create(vals)
@@ -202,12 +205,15 @@ class Survey(models.Model):
return self.sudo()._handle_certification_badges(vals)
return result
# Public methods #
def copy_data(self, default=None):
title = _("%s (copy)") % (self.title)
default = dict(default or {}, title=title)
return super(Survey, self).copy_data(default)
# ------------------------------------------------------------
# TECHNICAL
# ------------------------------------------------------------
def _create_answer(self, user=False, partner=False, email=False, test_entry=False, check_attempts=True, **additional_vals):
""" Main entry point to get a token back or create a new one. This method
does check for current user access in order to explicitely validate
@@ -278,6 +284,60 @@ class Survey(models.Model):
if check_attempts and not self._has_attempts_left(partner or (user and user.partner_id), email, invite_token):
raise UserError(_('No attempts left.'))
def _prepare_answer_questions(self):
""" Will generate the questions for a randomized survey.
It uses the random_questions_count of every sections of the survey to
pick a random number of questions and returns the merged recordset """
self.ensure_one()
questions = self.env['survey.question']
for page in self.page_ids:
if self.questions_selection == 'all':
questions |= page.question_ids
else:
if page.random_questions_count > 0 and len(page.question_ids) > page.random_questions_count:
questions = questions.concat(*random.sample(page.question_ids, page.random_questions_count))
else:
questions |= page.question_ids
if not questions:
questions = self.question_ids
return questions
def _has_attempts_left(self, partner, email, invite_token):
self.ensure_one()
if (self.access_mode != 'public' or self.users_login_required) and self.is_attempts_limited:
return self._get_number_of_attempts_lefts(partner, email, invite_token) > 0
return True
def _get_number_of_attempts_lefts(self, partner, email, invite_token):
""" Returns the number of attempts left. """
self.ensure_one()
domain = [
('survey_id', '=', self.id),
('test_entry', '=', False),
('state', '=', 'done')
]
if partner:
domain = expression.AND([domain, [('partner_id', '=', partner.id)]])
else:
domain = expression.AND([domain, [('email', '=', email)]])
if invite_token:
domain = expression.AND([domain, [('invite_token', '=', invite_token)]])
return self.attempts_limit - self.env['survey.user_input'].search_count(domain)
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
@api.model
def next_page_or_question(self, user_input, page_or_question_id, go_back=False):
""" The next page to display to the user, knowing that page_id is the id
@@ -329,6 +389,115 @@ class Survey(models.Model):
else:
return (pages_or_questions[current_page_index + 1][1], False)
def action_draft(self):
self.write({'state': 'draft'})
def action_open(self):
self.write({'state': 'open'})
def action_close(self):
self.write({'state': 'closed'})
def action_start_survey(self):
""" Open the website page with the survey form """
self.ensure_one()
token = self.env.context.get('survey_token')
trail = "?answer_token=%s" % token if token else ""
return {
'type': 'ir.actions.act_url',
'name': "Start Survey",
'target': 'self',
'url': self.public_url + trail
}
def action_send_survey(self):
""" Open a window to compose an email, pre-filled with the survey message """
# Ensure that this survey has at least one page with at least one question.
if (not self.page_ids and self.questions_layout == 'page_per_section') or not self.question_ids:
raise UserError(_('You cannot send an invitation for a survey that has no questions.'))
if self.state == 'closed':
raise UserError(_("You cannot send invitations for closed surveys."))
template = self.env.ref('survey.mail_template_user_input_invite', raise_if_not_found=False)
local_context = dict(
self.env.context,
default_survey_id=self.id,
default_use_template=bool(template),
default_template_id=template and template.id or False,
notif_layout='mail.mail_notification_light',
)
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'survey.invite',
'target': 'new',
'context': local_context,
}
def action_print_survey(self):
""" Open the website page with the survey printable view """
self.ensure_one()
token = self.env.context.get('survey_token')
trail = "?answer_token=%s" % token if token else ""
return {
'type': 'ir.actions.act_url',
'name': "Print Survey",
'target': 'self',
'url': '/survey/print/%s%s' % (self.access_token, trail)
}
def action_result_survey(self):
""" Open the website page with the survey results view """
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': "Results of the Survey",
'target': 'self',
'url': '/survey/results/%s' % self.id
}
def action_test_survey(self):
''' Open the website page with the survey form into test mode'''
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': "Test Survey",
'target': 'self',
'url': '/survey/test/%s' % self.access_token,
}
def action_survey_user_input_completed(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
ctx = dict(self.env.context)
ctx.update({'search_default_survey_id': self.ids[0],
'search_default_completed': 1})
action['context'] = ctx
return action
def action_survey_user_input_certified(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
ctx = dict(self.env.context)
ctx.update({'search_default_survey_id': self.ids[0],
'search_default_quizz_passed': 1})
action['context'] = ctx
return action
def action_survey_user_input(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
ctx = dict(self.env.context)
ctx.update({'search_default_survey_id': self.ids[0]})
action['context'] = ctx
return action
# ------------------------------------------------------------
# GRAPH / RESULTS
# ------------------------------------------------------------
def filter_input_ids(self, filters, finished=False):
"""If user applies any filters, then this function returns list of
filtered user_input_id and label's strings for display data in web.
@@ -453,6 +622,43 @@ class Survey(models.Model):
return result
def _get_answers_correctness(self, user_answers):
if not user_answers.mapped('survey_id') == self:
raise UserError(_('Invalid performance computation'))
res = dict((user_answer, {
'correct': 0,
'incorrect': 0,
'partial': 0,
'skipped': 0,
}) for user_answer in user_answers)
scored_questions = self.question_ids.filtered(
lambda question: question.question_type in ['simple_choice', 'multiple_choice']
)
for question in scored_questions:
question_answer_correct = question.labels_ids.filtered(lambda answer: answer.is_correct)
for user_answer in user_answers:
user_answer_lines_question = user_answer.user_input_line_ids.filtered(lambda line: line.question_id == question)
user_answer_correct = user_answer_lines_question.filtered(lambda line: line.answer_is_correct and not line.skipped).mapped('value_suggested')
user_answer_incorrect = user_answer_lines_question.filtered(lambda line: not line.answer_is_correct and not line.skipped)
if user_answer_correct == question_answer_correct:
res[user_answer]['correct'] += 1
elif user_answer_correct and user_answer_correct < question_answer_correct:
res[user_answer]['partial'] += 1
if not user_answer_correct and user_answer_incorrect:
res[user_answer]['incorrect'] += 1
if not user_answer_correct and not user_answer_incorrect:
res[user_answer]['skipped'] += 1
return res
# ------------------------------------------------------------
# GAMIFICATION / BADGES
# ------------------------------------------------------------
def _create_certification_badge_trigger(self):
self.ensure_one()
goal = self.env['gamification.goal.definition'].create({
@@ -502,160 +708,3 @@ class Survey(models.Model):
# delete all challenges and goals because not needed anymore (challenge lines are deleted in cascade)
challenges_to_delete.unlink()
goals_to_delete.unlink()
# Actions
def action_draft(self):
self.write({'state': 'draft'})
def action_open(self):
self.write({'state': 'open'})
def action_close(self):
self.write({'state': 'closed'})
def action_start_survey(self):
""" Open the website page with the survey form """
self.ensure_one()
token = self.env.context.get('survey_token')
trail = "?answer_token=%s" % token if token else ""
return {
'type': 'ir.actions.act_url',
'name': "Start Survey",
'target': 'self',
'url': self.public_url + trail
}
def action_send_survey(self):
""" Open a window to compose an email, pre-filled with the survey message """
# Ensure that this survey has at least one page with at least one question.
if (not self.page_ids and self.questions_layout == 'page_per_section') or not self.question_ids:
raise UserError(_('You cannot send an invitation for a survey that has no questions.'))
if self.state == 'closed':
raise UserError(_("You cannot send invitations for closed surveys."))
template = self.env.ref('survey.mail_template_user_input_invite', raise_if_not_found=False)
local_context = dict(
self.env.context,
default_survey_id=self.id,
default_use_template=bool(template),
default_template_id=template and template.id or False,
notif_layout='mail.mail_notification_light',
)
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'survey.invite',
'target': 'new',
'context': local_context,
}
def action_print_survey(self):
""" Open the website page with the survey printable view """
self.ensure_one()
token = self.env.context.get('survey_token')
trail = "?answer_token=%s" % token if token else ""
return {
'type': 'ir.actions.act_url',
'name': "Print Survey",
'target': 'self',
'url': '/survey/print/%s%s' % (self.access_token, trail)
}
def action_result_survey(self):
""" Open the website page with the survey results view """
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': "Results of the Survey",
'target': 'self',
'url': '/survey/results/%s' % self.id
}
def action_test_survey(self):
''' Open the website page with the survey form into test mode'''
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'name': "Test Survey",
'target': 'self',
'url': '/survey/test/%s' % self.access_token,
}
def action_survey_user_input_completed(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
ctx = dict(self.env.context)
ctx.update({'search_default_survey_id': self.ids[0],
'search_default_completed': 1})
action['context'] = ctx
return action
def action_survey_user_input_certified(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
ctx = dict(self.env.context)
ctx.update({'search_default_survey_id': self.ids[0],
'search_default_quizz_passed': 1})
action['context'] = ctx
return action
def action_survey_user_input(self):
action_rec = self.env.ref('survey.action_survey_user_input_notest')
action = action_rec.read()[0]
ctx = dict(self.env.context)
ctx.update({'search_default_survey_id': self.ids[0]})
action['context'] = ctx
return action
def _has_attempts_left(self, partner, email, invite_token):
self.ensure_one()
if (self.access_mode != 'public' or self.users_login_required) and self.is_attempts_limited:
return self._get_number_of_attempts_lefts(partner, email, invite_token) > 0
return True
def _get_number_of_attempts_lefts(self, partner, email, invite_token):
""" Returns the number of attempts left. """
self.ensure_one()
domain = [
('survey_id', '=', self.id),
('test_entry', '=', False),
('state', '=', 'done')
]
if partner:
domain = expression.AND([domain, [('partner_id', '=', partner.id)]])
else:
domain = expression.AND([domain, [('email', '=', email)]])
if invite_token:
domain = expression.AND([domain, [('invite_token', '=', invite_token)]])
return self.attempts_limit - self.env['survey.user_input'].search_count(domain)
def _prepare_answer_questions(self):
""" Will generate the questions for a randomized survey.
It uses the random_questions_count of every sections of the survey to
pick a random number of questions and returns the merged recordset """
self.ensure_one()
questions = self.env['survey.question']
for page in self.page_ids:
if self.questions_selection == 'all':
questions |= page.question_ids
else:
if page.random_questions_count > 0 and len(page.question_ids) > page.random_questions_count:
questions = questions.concat(*random.sample(page.question_ids, page.random_questions_count))
else:
questions |= page.question_ids
if not questions:
questions = self.question_ids
return questions
+16 -3
View File
@@ -199,11 +199,15 @@ class SurveyUserInputLine(models.Model):
_name = 'survey.user_input_line'
_description = 'Survey User Input Line'
_rec_name = 'user_input_id'
_order = 'question_sequence,id'
# survey data
user_input_id = fields.Many2one('survey.user_input', string='User Input', ondelete='cascade', required=True)
question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade', required=True)
page_id = fields.Many2one(related='question_id.page_id', string="Page", readonly=False)
survey_id = fields.Many2one(related='user_input_id.survey_id', string='Survey', store=True, readonly=False)
question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade', required=True)
page_id = fields.Many2one(related='question_id.page_id', string="Section", readonly=False)
question_sequence = fields.Integer('Sequence', related='question_id.sequence', store=True)
# answer
skipped = fields.Boolean('Skipped')
answer_type = fields.Selection([
('text', 'Text'),
@@ -219,7 +223,16 @@ class SurveyUserInputLine(models.Model):
value_free_text = fields.Text('Free Text answer')
value_suggested = fields.Many2one('survey.label', string="Suggested answer")
value_suggested_row = fields.Many2one('survey.label', string="Row answer")
answer_score = fields.Float('Score given for this choice')
answer_score = fields.Float('Score')
answer_is_correct = fields.Boolean('Correct', compute='_compute_answer_is_correct')
@api.depends('value_suggested', 'question_id')
def _compute_answer_is_correct(self):
for answer in self:
if answer.value_suggested and answer.question_id.question_type in ['simple_choice', 'multiple_choice']:
answer.answer_is_correct = answer.value_suggested.is_correct
else:
answer.answer_is_correct = False
@api.constrains('skipped', 'answer_type')
def _answered_or_skipped(self):
@@ -2,6 +2,7 @@ odoo.define('survey.result', function (require) {
'use strict';
require('web.dom_ready');
var _t = require('web.core')._t;
if(!$('.js_surveyresult').length) {
return Promise.reject("DOM doesn't contain '.js_surveyresult'");
@@ -159,6 +160,35 @@ if(!$('.js_surveyresult').length) {
return chartConfig;
}
//initialize doughnut Chart
function init_doughnut_chart(graph_data, quizz_score){
var data = graph_data.map(function (point) {
return point.count;
});
var chartConfig = {
type: 'doughnut',
data: {
labels: graph_data.map(function (point) {
return point.text;
}),
datasets: [{
label: '',
data: data,
backgroundColor: data.map(function (val, index) {
return D3_COLORS[index % 20];
}),
}]
},
options: {
title: {
display: true,
text: _.str.sprintf(_t("Overall Performance %.2f%s"), parseFloat(quizz_score), '%'),
},
}
};
return chartConfig;
}
//load chart to svg element chart:initialized chart, response:AJAX response, quistion_id:if of survey question, tick_limit:text length limit
function load_chart(chartConfig, containerSelector){
var $container = $(containerSelector).css({position: 'relative'});
@@ -187,6 +217,11 @@ if(!$('.js_surveyresult').length) {
chartConfig = init_pie_chart(graph_data);
return load_chart(chartConfig, containerSelector);
}
else if (graph_type === 'doughnut') {
var quizz_score = $(graph).attr("quizz-score") || 0.0;
chartConfig = init_doughnut_chart(graph_data, quizz_score);
return load_chart(chartConfig, containerSelector);
}
});
var $scoringResultsChart = $('#scoring_results_chart');
+2 -2
View File
@@ -44,7 +44,7 @@
<widget name="web_ribbon" text="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title" style="width: 100%;">
<label for="title" class="oe_edit_only"/>
<h1><field name="title" placeholder="Survey Title"/></h1>
<h1><field name="title" placeholder="e.g. Satisfaction Survey"/></h1>
</div>
<group>
<group>
@@ -257,7 +257,7 @@
</p><p>
You can create surveys for different purposes: customer opinion, services feedback, recruitment interviews, employee's periodical evaluations, marketing campaigns, etc.
</p><p>
Design easily your survey, send invitations to answer by email and analyze answers.
Design easily your survey, send invitations and analyze answers.
</p>
</field>
</record>
+9
View File
@@ -57,6 +57,15 @@
</div>
</div>
</div>
<div class="container js_surveyresult p-4" t-if="graph_data">
<div class="tab-content">
<div role="tabpanel" class="tab-pane active survey_graph" t-att-quizz-score="answer.quizz_score" t-att-id="'graph_question_%d' % answer.id" t-att-data-question_id="answer.id" data-graph_type="doughnut" t-att-graph-data="graph_data">
<canvas id="doughnut_chart"></canvas>
<span class="o_overall_performance"></span>
</div>
</div>
</div>
<div class="oe_structure"/>
</div>
</t>
</template>
+18 -13
View File
@@ -2,8 +2,8 @@
<odoo>
<data>
<!-- USER INPUTS -->
<record id="survey_user_input_search" model="ir.ui.view">
<field name="name">survey_user_input_search</field>
<record id="survey_user_input_view_search" model="ir.ui.view">
<field name="name">survey.user_input.view.search</field>
<field name="model">survey.user_input</field>
<field name="arch" type="xml">
<search string="Search Survey">
@@ -26,8 +26,9 @@
</search>
</field>
</record>
<record model="ir.ui.view" id="survey_user_input_form">
<field name="name">survey_user_input_form</field>
<record id="survey_user_input_view_form" model="ir.ui.view">
<field name="name">survey.user_input.view.form</field>
<field name="model">survey.user_input</field>
<field name="arch" type="xml">
<form string="Survey User inputs" create="false">
@@ -65,19 +66,23 @@
</group>
<field name="user_input_line_ids" mode="tree" attrs="{'readonly': False}">
<tree>
<field name="question_sequence" invisible="1"/>
<field name="question_id"/>
<field name="page_id"/>
<field name="answer_type"/>
<field name="skipped"/>
<field name="create_date"/>
<field name="answer_score" groups="base.group_no_one"/>
<field name="answer_is_correct"/>
<field name="answer_score"/>
</tree>
</field>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="survey_user_input_tree">
<field name="name">survey_user_input_tree</field>
<record id="survey_user_input_view_tree" model="ir.ui.view">
<field name="name">survey.user_input.view.tree</field>
<field name="model">survey.user_input</field>
<field name="arch" type="xml">
<tree string="Survey User inputs" decoration-muted="test_entry == True" create="false">
@@ -96,8 +101,8 @@
</field>
</record>
<record id="view_survey_user_input_kanban" model="ir.ui.view">
<field name="name">survey.user_input.kanban</field>
<record id="survey_user_input_viuew_kanban" model="ir.ui.view">
<field name="name">survey.user_input.view.kanban</field>
<field name="model">survey.user_input</field>
<field name="arch" type="xml">
<kanban create="false">
@@ -135,8 +140,8 @@
<field name="name">Answers</field>
<field name="res_model">survey.user_input</field>
<field name="view_mode">tree,kanban,form</field>
<field name="view_id" ref="survey_user_input_tree"></field>
<field name="search_view_id" ref="survey_user_input_search"/>
<field name="view_id" ref="survey_user_input_view_tree"></field>
<field name="search_view_id" ref="survey_user_input_view_search"/>
<field name="context">{'search_default_group_by_survey': True}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
@@ -148,8 +153,8 @@
<field name="name">Answers</field>
<field name="res_model">survey.user_input</field>
<field name="view_mode">tree,kanban,form</field>
<field name="view_id" ref="survey_user_input_tree"></field>
<field name="search_view_id" ref="survey_user_input_search"/>
<field name="view_id" ref="survey_user_input_view_tree"></field>
<field name="search_view_id" ref="survey_user_input_view_search"/>
<field name="context">{'search_default_group_by_survey': True}</field>
<field name="domain">[('test_entry', '=', False)]</field>
<field name="help" type="html">