diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 045661c5d75..7eabe92f733 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -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/', 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/', 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 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 diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index 64a74f57652..5418d414603 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -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 diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index 3a53d73f439..8cfa9e9dde4 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -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): diff --git a/addons/survey/static/src/js/survey_result.js b/addons/survey/static/src/js/survey_result.js index 6e63d473ea4..380a5c893a8 100644 --- a/addons/survey/static/src/js/survey_result.js +++ b/addons/survey/static/src/js/survey_result.js @@ -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'); diff --git a/addons/survey/views/survey_survey_views.xml b/addons/survey/views/survey_survey_views.xml index 2c16f8f5992..19249cc12bd 100644 --- a/addons/survey/views/survey_survey_views.xml +++ b/addons/survey/views/survey_survey_views.xml @@ -44,7 +44,7 @@
@@ -257,7 +257,7 @@

You can create surveys for different purposes: customer opinion, services feedback, recruitment interviews, employee's periodical evaluations, marketing campaigns, etc.

- Design easily your survey, send invitations to answer by email and analyze answers. + Design easily your survey, send invitations and analyze answers.

diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 569da41c1c5..4906e4e6c0e 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -57,6 +57,15 @@ +
+
+
+ + +
+
+
+
diff --git a/addons/survey/views/survey_user_views.xml b/addons/survey/views/survey_user_views.xml index 0725db4ba88..3d9384ead5d 100644 --- a/addons/survey/views/survey_user_views.xml +++ b/addons/survey/views/survey_user_views.xml @@ -2,8 +2,8 @@ - - survey_user_input_search + + survey.user_input.view.search survey.user_input @@ -26,8 +26,9 @@ - - survey_user_input_form + + + survey.user_input.view.form survey.user_input
@@ -65,19 +66,23 @@ + + - + +
- - survey_user_input_tree + + + survey.user_input.view.tree survey.user_input @@ -96,8 +101,8 @@ - - survey.user_input.kanban + + survey.user_input.view.kanban survey.user_input @@ -135,8 +140,8 @@ Answers survey.user_input tree,kanban,form - - + + {'search_default_group_by_survey': True}

@@ -148,8 +153,8 @@ Answers survey.user_input tree,kanban,form - - + + {'search_default_group_by_survey': True} [('test_entry', '=', False)]