mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-24 00:12:05 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
11
odoo-bringout-oca-ocb-survey/survey/models/__init__.py
Normal file
11
odoo-bringout-oca-ocb-survey/survey/models/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# -*- encoding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import survey_survey
|
||||
from . import survey_survey_template
|
||||
from . import survey_question
|
||||
from . import survey_user_input
|
||||
from . import badge
|
||||
from . import challenge
|
||||
from . import res_partner
|
||||
from . import ir_http
|
||||
16
odoo-bringout-oca-ocb-survey/survey/models/badge.py
Normal file
16
odoo-bringout-oca-ocb-survey/survey/models/badge.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class GamificationBadge(models.Model):
|
||||
_inherit = 'gamification.badge'
|
||||
|
||||
survey_ids = fields.One2many('survey.survey', 'certification_badge_id', 'Survey Ids')
|
||||
survey_id = fields.Many2one('survey.survey', 'Survey', compute='_compute_survey_id', store=True)
|
||||
|
||||
@api.depends('survey_ids.certification_badge_id')
|
||||
def _compute_survey_id(self):
|
||||
for badge in self:
|
||||
badge.survey_id = badge.survey_ids[0] if badge.survey_ids else None
|
||||
12
odoo-bringout-oca-ocb-survey/survey/models/challenge.py
Normal file
12
odoo-bringout-oca-ocb-survey/survey/models/challenge.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class Challenge(models.Model):
|
||||
_inherit = 'gamification.challenge'
|
||||
|
||||
challenge_category = fields.Selection(selection_add=[
|
||||
('certification', 'Certifications')
|
||||
], ondelete={'certification': 'set default'})
|
||||
12
odoo-bringout-oca-ocb-survey/survey/models/ir_http.py
Normal file
12
odoo-bringout-oca-ocb-survey/survey/models/ir_http.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = ["ir.http"]
|
||||
|
||||
@classmethod
|
||||
def _get_translation_frontend_modules_name(cls):
|
||||
modules = super()._get_translation_frontend_modules_name()
|
||||
return modules + ["survey"]
|
||||
32
odoo-bringout-oca-ocb-survey/survey/models/res_partner.py
Normal file
32
odoo-bringout-oca-ocb-survey/survey/models/res_partner.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
certifications_count = fields.Integer('Certifications Count', compute='_compute_certifications_count')
|
||||
certifications_company_count = fields.Integer('Company Certifications Count', compute='_compute_certifications_company_count')
|
||||
|
||||
@api.depends('is_company')
|
||||
def _compute_certifications_count(self):
|
||||
read_group_res = self.env['survey.user_input'].sudo()._read_group(
|
||||
[('partner_id', 'in', self.ids), ('scoring_success', '=', True)],
|
||||
['partner_id'], 'partner_id'
|
||||
)
|
||||
data = dict((res['partner_id'][0], res['partner_id_count']) for res in read_group_res)
|
||||
for partner in self:
|
||||
partner.certifications_count = data.get(partner.id, 0)
|
||||
|
||||
@api.depends('is_company', 'child_ids.certifications_count')
|
||||
def _compute_certifications_company_count(self):
|
||||
self.certifications_company_count = sum(child.certifications_count for child in self.child_ids)
|
||||
|
||||
def action_view_certifications(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("survey.res_partner_action_certifications")
|
||||
action['view_mode'] = 'tree'
|
||||
action['domain'] = ['|', ('partner_id', 'in', self.ids), ('partner_id', 'in', self.child_ids.ids)]
|
||||
|
||||
return action
|
||||
628
odoo-bringout-oca-ocb-survey/survey/models/survey_question.py
Normal file
628
odoo-bringout-oca-ocb-survey/survey/models/survey_question.py
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import collections
|
||||
import contextlib
|
||||
import json
|
||||
import itertools
|
||||
import operator
|
||||
|
||||
from odoo import api, fields, models, tools, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class SurveyQuestion(models.Model):
|
||||
""" Questions that will be asked in a survey.
|
||||
|
||||
Each question can have one of more suggested answers (eg. in case of
|
||||
multi-answer checkboxes, radio buttons...).
|
||||
|
||||
Technical note:
|
||||
|
||||
survey.question is also the model used for the survey's pages (with the "is_page" field set to True).
|
||||
|
||||
A page corresponds to a "section" in the interface, and the fact that it separates the survey in
|
||||
actual pages in the interface depends on the "questions_layout" parameter on the survey.survey model.
|
||||
Pages are also used when randomizing questions. The randomization can happen within a "page".
|
||||
|
||||
Using the same model for questions and pages allows to put all the pages and questions together in a o2m field
|
||||
(see survey.survey.question_and_page_ids) on the view side and easily reorganize your survey by dragging the
|
||||
items around.
|
||||
|
||||
It also removes on level of encoding by directly having 'Add a page' and 'Add a question'
|
||||
links on the tree view of questions, enabling a faster encoding.
|
||||
|
||||
However, this has the downside of making the code reading a little bit more complicated.
|
||||
Efforts were made at the model level to create computed fields so that the use of these models
|
||||
still seems somewhat logical. That means:
|
||||
- A survey still has "page_ids" (question_and_page_ids filtered on is_page = True)
|
||||
- These "page_ids" still have question_ids (questions located between this page and the next)
|
||||
- These "question_ids" still have a "page_id"
|
||||
|
||||
That makes the use and display of these information at view and controller levels easier to understand.
|
||||
"""
|
||||
_name = 'survey.question'
|
||||
_description = 'Survey Question'
|
||||
_rec_name = 'title'
|
||||
_order = 'sequence,id'
|
||||
|
||||
# question generic data
|
||||
title = fields.Char('Title', required=True, translate=True)
|
||||
description = fields.Html(
|
||||
'Description', translate=True, sanitize=True, sanitize_overridable=True,
|
||||
help="Use this field to add additional explanations about your question or to illustrate it with pictures or a video")
|
||||
question_placeholder = fields.Char("Placeholder", translate=True, compute="_compute_question_placeholder", store=True, readonly=False)
|
||||
background_image = fields.Image("Background Image", compute="_compute_background_image", store=True, readonly=False)
|
||||
background_image_url = fields.Char("Background Url", compute="_compute_background_image_url")
|
||||
survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade')
|
||||
scoring_type = fields.Selection(related='survey_id.scoring_type', string='Scoring Type', readonly=True)
|
||||
sequence = fields.Integer('Sequence', default=10)
|
||||
# page specific
|
||||
is_page = fields.Boolean('Is a page?')
|
||||
question_ids = fields.One2many('survey.question', string='Questions', compute="_compute_question_ids")
|
||||
questions_selection = fields.Selection(
|
||||
related='survey_id.questions_selection', readonly=True,
|
||||
help="If randomized is selected, add the number of random questions next to the section.")
|
||||
random_questions_count = fields.Integer(
|
||||
'# Questions Randomly Picked', default=1,
|
||||
help="Used on randomized sections to take X random questions from all the questions of that section.")
|
||||
# question specific
|
||||
page_id = fields.Many2one('survey.question', string='Page', compute="_compute_page_id", store=True)
|
||||
question_type = fields.Selection([
|
||||
('simple_choice', 'Multiple choice: only one answer'),
|
||||
('multiple_choice', 'Multiple choice: multiple answers allowed'),
|
||||
('text_box', 'Multiple Lines Text Box'),
|
||||
('char_box', 'Single Line Text Box'),
|
||||
('numerical_box', 'Numerical Value'),
|
||||
('date', 'Date'),
|
||||
('datetime', 'Datetime'),
|
||||
('matrix', 'Matrix')], string='Question Type',
|
||||
compute='_compute_question_type', readonly=False, store=True)
|
||||
is_scored_question = fields.Boolean(
|
||||
'Scored', compute='_compute_is_scored_question',
|
||||
readonly=False, store=True, copy=True,
|
||||
help="Include this question as part of quiz scoring. Requires an answer and answer score to be taken into account.")
|
||||
# -- scoreable/answerable simple answer_types: numerical_box / date / datetime
|
||||
answer_numerical_box = fields.Float('Correct numerical answer', help="Correct number answer for this question.")
|
||||
answer_date = fields.Date('Correct date answer', help="Correct date answer for this question.")
|
||||
answer_datetime = fields.Datetime('Correct datetime answer', help="Correct date and time answer for this question.")
|
||||
answer_score = fields.Float('Score', help="Score value for a correct answer to this question.")
|
||||
# -- char_box
|
||||
save_as_email = fields.Boolean(
|
||||
"Save as user email", compute='_compute_save_as_email', readonly=False, store=True, copy=True,
|
||||
help="If checked, this option will save the user's answer as its email address.")
|
||||
save_as_nickname = fields.Boolean(
|
||||
"Save as user nickname", compute='_compute_save_as_nickname', readonly=False, store=True, copy=True,
|
||||
help="If checked, this option will save the user's answer as its nickname.")
|
||||
# -- simple choice / multiple choice / matrix
|
||||
suggested_answer_ids = fields.One2many(
|
||||
'survey.question.answer', 'question_id', string='Types of answers', copy=True,
|
||||
help='Labels used for proposed choices: simple choice, multiple choice and columns of matrix')
|
||||
# -- matrix
|
||||
matrix_subtype = fields.Selection([
|
||||
('simple', 'One choice per row'),
|
||||
('multiple', 'Multiple choices per row')], string='Matrix Type', default='simple')
|
||||
matrix_row_ids = fields.One2many(
|
||||
'survey.question.answer', 'matrix_question_id', string='Matrix Rows', copy=True,
|
||||
help='Labels used for proposed choices: rows of matrix')
|
||||
# -- display & timing options
|
||||
is_time_limited = fields.Boolean("The question is limited in time",
|
||||
help="Currently only supported for live sessions.")
|
||||
time_limit = fields.Integer("Time limit (seconds)")
|
||||
# -- comments (simple choice, multiple choice, matrix (without count as an answer))
|
||||
comments_allowed = fields.Boolean('Show Comments Field')
|
||||
comments_message = fields.Char('Comment Message', translate=True)
|
||||
comment_count_as_answer = fields.Boolean('Comment is an answer')
|
||||
# question validation
|
||||
validation_required = fields.Boolean('Validate entry', compute='_compute_validation_required', readonly=False, store=True)
|
||||
validation_email = fields.Boolean('Input must be an email')
|
||||
validation_length_min = fields.Integer('Minimum Text Length', default=0)
|
||||
validation_length_max = fields.Integer('Maximum Text Length', default=0)
|
||||
validation_min_float_value = fields.Float('Minimum value', default=0.0)
|
||||
validation_max_float_value = fields.Float('Maximum value', default=0.0)
|
||||
validation_min_date = fields.Date('Minimum Date')
|
||||
validation_max_date = fields.Date('Maximum Date')
|
||||
validation_min_datetime = fields.Datetime('Minimum Datetime')
|
||||
validation_max_datetime = fields.Datetime('Maximum Datetime')
|
||||
validation_error_msg = fields.Char('Validation Error message', translate=True)
|
||||
constr_mandatory = fields.Boolean('Mandatory Answer')
|
||||
constr_error_msg = fields.Char('Error message', translate=True)
|
||||
# answers
|
||||
user_input_line_ids = fields.One2many(
|
||||
'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=True, 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 of 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!'),
|
||||
('validation_length', 'CHECK (validation_length_min <= validation_length_max)', 'Max length cannot be smaller than min length!'),
|
||||
('validation_float', 'CHECK (validation_min_float_value <= validation_max_float_value)', 'Max value cannot be smaller than min value!'),
|
||||
('validation_date', 'CHECK (validation_min_date <= validation_max_date)', 'Max date cannot be smaller than min date!'),
|
||||
('validation_datetime', 'CHECK (validation_min_datetime <= validation_max_datetime)', 'Max datetime cannot be smaller than min datetime!'),
|
||||
('positive_answer_score', 'CHECK (answer_score >= 0)', 'An answer score for a non-multiple choice question cannot be negative!'),
|
||||
('scored_datetime_have_answers', "CHECK (is_scored_question != True OR question_type != 'datetime' OR answer_datetime is not null)",
|
||||
'All "Is a scored question = True" and "Question Type: Datetime" questions need an answer'),
|
||||
('scored_date_have_answers', "CHECK (is_scored_question != True OR question_type != 'date' OR answer_date is not null)",
|
||||
'All "Is a scored question = True" and "Question Type: Date" questions need an answer')
|
||||
]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# CONSTRAINT METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.constrains("is_page")
|
||||
def _check_question_type_for_pages(self):
|
||||
invalid_pages = self.filtered(lambda question: question.is_page and question.question_type)
|
||||
if invalid_pages:
|
||||
raise ValidationError(_("Question type should be empty for these pages: %s", ', '.join(invalid_pages.mapped('title'))))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# COMPUTE METHODS
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@api.depends('question_type')
|
||||
def _compute_question_placeholder(self):
|
||||
for question in self:
|
||||
if question.question_type in ('simple_choice', 'multiple_choice', 'matrix') \
|
||||
or not question.question_placeholder: # avoid CacheMiss errors
|
||||
question.question_placeholder = False
|
||||
|
||||
@api.depends('is_page')
|
||||
def _compute_background_image(self):
|
||||
""" Background image is only available on sections. """
|
||||
for question in self.filtered(lambda q: not q.is_page):
|
||||
question.background_image = False
|
||||
|
||||
@api.depends('survey_id.access_token', 'background_image', 'page_id', 'survey_id.background_image_url')
|
||||
def _compute_background_image_url(self):
|
||||
""" How the background url is computed:
|
||||
- For a question: it depends on the related section (see below)
|
||||
- For a section:
|
||||
- if a section has a background, then we create the background URL using this section's ID
|
||||
- if not, then we fallback on the survey background url """
|
||||
base_bg_url = "/survey/%s/%s/get_background_image"
|
||||
for question in self:
|
||||
if question.is_page:
|
||||
background_section_id = question.id if question.background_image else False
|
||||
else:
|
||||
background_section_id = question.page_id.id if question.page_id.background_image else False
|
||||
|
||||
if background_section_id:
|
||||
question.background_image_url = base_bg_url % (
|
||||
question.survey_id.access_token,
|
||||
background_section_id
|
||||
)
|
||||
else:
|
||||
question.background_image_url = question.survey_id.background_image_url
|
||||
|
||||
@api.depends('is_page')
|
||||
def _compute_question_type(self):
|
||||
pages = self.filtered(lambda question: question.is_page)
|
||||
pages.question_type = False
|
||||
(self - pages).filtered(lambda question: not question.question_type).question_type = 'simple_choice'
|
||||
|
||||
@api.depends('survey_id.question_and_page_ids.is_page', 'survey_id.question_and_page_ids.sequence')
|
||||
def _compute_question_ids(self):
|
||||
"""Will take all questions of the survey for which the index is higher than the index of this page
|
||||
and lower than the index of the next page."""
|
||||
for question in self:
|
||||
if question.is_page:
|
||||
next_page_index = False
|
||||
for page in question.survey_id.page_ids:
|
||||
if page._index() > question._index():
|
||||
next_page_index = page._index()
|
||||
break
|
||||
|
||||
question.question_ids = question.survey_id.question_ids.filtered(
|
||||
lambda q: q._index() > question._index() and (not next_page_index or q._index() < next_page_index)
|
||||
)
|
||||
else:
|
||||
question.question_ids = self.env['survey.question']
|
||||
|
||||
@api.depends('survey_id.question_and_page_ids.is_page', 'survey_id.question_and_page_ids.sequence')
|
||||
def _compute_page_id(self):
|
||||
"""Will find the page to which this question belongs to by looking inside the corresponding survey"""
|
||||
for question in self:
|
||||
if question.is_page:
|
||||
question.page_id = None
|
||||
else:
|
||||
page = None
|
||||
for q in question.survey_id.question_and_page_ids.sorted():
|
||||
if q == question:
|
||||
break
|
||||
if q.is_page:
|
||||
page = q
|
||||
question.page_id = page
|
||||
|
||||
@api.depends('question_type', 'validation_email')
|
||||
def _compute_save_as_email(self):
|
||||
for question in self:
|
||||
if question.question_type != 'char_box' or not question.validation_email:
|
||||
question.save_as_email = False
|
||||
|
||||
@api.depends('question_type')
|
||||
def _compute_save_as_nickname(self):
|
||||
for question in self:
|
||||
if question.question_type != 'char_box':
|
||||
question.save_as_nickname = False
|
||||
|
||||
@api.depends('question_type')
|
||||
def _compute_validation_required(self):
|
||||
for question in self:
|
||||
if not question.validation_required or question.question_type not in ['char_box', 'numerical_box', 'date', 'datetime']:
|
||||
question.validation_required = 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
|
||||
|
||||
@api.depends('question_type', 'scoring_type', 'answer_date', 'answer_datetime', 'answer_numerical_box')
|
||||
def _compute_is_scored_question(self):
|
||||
""" Computes whether a question "is scored" or not. Handles following cases:
|
||||
- inconsistent Boolean=None edge case that breaks tests => False
|
||||
- survey is not scored => False
|
||||
- 'date'/'datetime'/'numerical_box' question types w/correct answer => True
|
||||
(implied without user having to activate, except for numerical whose correct value is 0.0)
|
||||
- 'simple_choice / multiple_choice': set to True even if logic is a bit different (coming from answers)
|
||||
- question_type isn't scoreable (note: choice questions scoring logic handled separately) => False
|
||||
"""
|
||||
for question in self:
|
||||
if question.is_scored_question is None or question.scoring_type == 'no_scoring':
|
||||
question.is_scored_question = False
|
||||
elif question.question_type == 'date':
|
||||
question.is_scored_question = bool(question.answer_date)
|
||||
elif question.question_type == 'datetime':
|
||||
question.is_scored_question = bool(question.answer_datetime)
|
||||
elif question.question_type == 'numerical_box' and question.answer_numerical_box:
|
||||
question.is_scored_question = True
|
||||
elif question.question_type in ['simple_choice', 'multiple_choice']:
|
||||
question.is_scored_question = True
|
||||
else:
|
||||
question.is_scored_question = False
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# CRUD
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_live_sessions_in_progress(self):
|
||||
running_surveys = self.survey_id.filtered(lambda survey: survey.session_state == 'in_progress')
|
||||
if running_surveys:
|
||||
raise UserError(_(
|
||||
'You cannot delete questions from surveys "%(survey_names)s" while live sessions are in progress.',
|
||||
survey_names=', '.join(running_surveys.mapped('title')),
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# VALIDATION
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def validate_question(self, answer, comment=None):
|
||||
""" Validate question, depending on question type and parameters
|
||||
for simple choice, text, date and number, answer is simply the answer of the question.
|
||||
For other multiple choices questions, answer is a list of answers (the selected choices
|
||||
or a list of selected answers per question -for matrix type-):
|
||||
- Simple answer : answer = 'example' or 2 or question_answer_id or 2019/10/10
|
||||
- Multiple choice : answer = [question_answer_id1, question_answer_id2, question_answer_id3]
|
||||
- Matrix: answer = { 'rowId1' : [colId1, colId2,...], 'rowId2' : [colId1, colId3, ...] }
|
||||
|
||||
return dict {question.id (int): error (str)} -> empty dict if no validation error.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if isinstance(answer, str):
|
||||
answer = answer.strip()
|
||||
# Empty answer to mandatory question
|
||||
if self.constr_mandatory and not answer and self.question_type not in ['simple_choice', 'multiple_choice']:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
|
||||
# because in choices question types, comment can count as answer
|
||||
if answer or self.question_type in ['simple_choice', 'multiple_choice']:
|
||||
if self.question_type == 'char_box':
|
||||
return self._validate_char_box(answer)
|
||||
elif self.question_type == 'numerical_box':
|
||||
return self._validate_numerical_box(answer)
|
||||
elif self.question_type in ['date', 'datetime']:
|
||||
return self._validate_date(answer)
|
||||
elif self.question_type in ['simple_choice', 'multiple_choice']:
|
||||
return self._validate_choice(answer, comment)
|
||||
elif self.question_type == 'matrix':
|
||||
return self._validate_matrix(answer)
|
||||
return {}
|
||||
|
||||
def _validate_char_box(self, answer):
|
||||
# Email format validation
|
||||
# all the strings of the form "<something>@<anything>.<extension>" will be accepted
|
||||
if self.validation_email:
|
||||
if not tools.email_normalize(answer):
|
||||
return {self.id: _('This answer must be an email address')}
|
||||
|
||||
# Answer validation (if properly defined)
|
||||
# Length of the answer must be in a range
|
||||
if self.validation_required:
|
||||
if not (self.validation_length_min <= len(answer) <= self.validation_length_max):
|
||||
return {self.id: self.validation_error_msg or _('The answer you entered is not valid.')}
|
||||
return {}
|
||||
|
||||
def _validate_numerical_box(self, answer):
|
||||
try:
|
||||
floatanswer = float(answer)
|
||||
except ValueError:
|
||||
return {self.id: _('This is not a number')}
|
||||
|
||||
if self.validation_required:
|
||||
# Answer is not in the right range
|
||||
with contextlib.suppress(Exception):
|
||||
if not (self.validation_min_float_value <= floatanswer <= self.validation_max_float_value):
|
||||
return {self.id: self.validation_error_msg or _('The answer you entered is not valid.')}
|
||||
return {}
|
||||
|
||||
def _validate_date(self, answer):
|
||||
isDatetime = self.question_type == 'datetime'
|
||||
# Checks if user input is a date
|
||||
try:
|
||||
dateanswer = fields.Datetime.from_string(answer) if isDatetime else fields.Date.from_string(answer)
|
||||
except ValueError:
|
||||
return {self.id: _('This is not a date')}
|
||||
if self.validation_required:
|
||||
# Check if answer is in the right range
|
||||
if isDatetime:
|
||||
min_date = fields.Datetime.from_string(self.validation_min_datetime)
|
||||
max_date = fields.Datetime.from_string(self.validation_max_datetime)
|
||||
dateanswer = fields.Datetime.from_string(answer)
|
||||
else:
|
||||
min_date = fields.Date.from_string(self.validation_min_date)
|
||||
max_date = fields.Date.from_string(self.validation_max_date)
|
||||
dateanswer = fields.Date.from_string(answer)
|
||||
|
||||
if (min_date and max_date and not (min_date <= dateanswer <= max_date))\
|
||||
or (min_date and not min_date <= dateanswer)\
|
||||
or (max_date and not dateanswer <= max_date):
|
||||
return {self.id: self.validation_error_msg or _('The answer you entered is not valid.')}
|
||||
return {}
|
||||
|
||||
def _validate_choice(self, answer, comment):
|
||||
# Empty comment
|
||||
if self.constr_mandatory \
|
||||
and not answer \
|
||||
and not (self.comments_allowed and self.comment_count_as_answer and comment):
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
return {}
|
||||
|
||||
def _validate_matrix(self, answers):
|
||||
# Validate that each line has been answered
|
||||
if self.constr_mandatory and len(self.matrix_row_ids) != len(answers):
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
return {}
|
||||
|
||||
def _index(self):
|
||||
"""We would normally just use the 'sequence' field of questions BUT, if the pages and questions are
|
||||
created without ever moving records around, the sequence field can be set to 0 for all the questions.
|
||||
|
||||
However, the order of the recordset is always correct so we can rely on the index method."""
|
||||
self.ensure_one()
|
||||
return list(self.survey_id.question_and_page_ids).index(self)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# STATISTICS / REPORTING
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def _prepare_statistics(self, user_input_lines):
|
||||
""" Compute statistical data for questions by counting number of vote per choice on basis of filter """
|
||||
all_questions_data = []
|
||||
for question in self:
|
||||
question_data = {'question': question, 'is_page': question.is_page}
|
||||
|
||||
if question.is_page:
|
||||
all_questions_data.append(question_data)
|
||||
continue
|
||||
|
||||
# fetch answer lines, separate comments from real answers
|
||||
all_lines = user_input_lines.filtered(lambda line: line.question_id == question)
|
||||
if question.question_type in ['simple_choice', 'multiple_choice', 'matrix']:
|
||||
answer_lines = all_lines.filtered(
|
||||
lambda line: line.answer_type == 'suggestion' or (
|
||||
line.skipped and not line.answer_type) or (
|
||||
line.answer_type == 'char_box' and question.comment_count_as_answer)
|
||||
)
|
||||
comment_line_ids = all_lines.filtered(lambda line: line.answer_type == 'char_box')
|
||||
else:
|
||||
answer_lines = all_lines
|
||||
comment_line_ids = self.env['survey.user_input.line']
|
||||
skipped_lines = answer_lines.filtered(lambda line: line.skipped)
|
||||
done_lines = answer_lines - skipped_lines
|
||||
question_data.update(
|
||||
answer_line_ids=answer_lines,
|
||||
answer_line_done_ids=done_lines,
|
||||
answer_input_done_ids=done_lines.mapped('user_input_id'),
|
||||
answer_input_skipped_ids=skipped_lines.mapped('user_input_id'),
|
||||
comment_line_ids=comment_line_ids)
|
||||
question_data.update(question._get_stats_summary_data(answer_lines))
|
||||
|
||||
# prepare table and graph data
|
||||
table_data, graph_data = question._get_stats_data(answer_lines)
|
||||
question_data['table_data'] = table_data
|
||||
question_data['graph_data'] = json.dumps(graph_data)
|
||||
|
||||
all_questions_data.append(question_data)
|
||||
return all_questions_data
|
||||
|
||||
def _get_stats_data(self, user_input_lines):
|
||||
if self.question_type == 'simple_choice':
|
||||
return self._get_stats_data_answers(user_input_lines)
|
||||
elif self.question_type == 'multiple_choice':
|
||||
table_data, graph_data = self._get_stats_data_answers(user_input_lines)
|
||||
return table_data, [{'key': self.title, 'values': graph_data}]
|
||||
elif self.question_type == 'matrix':
|
||||
return self._get_stats_graph_data_matrix(user_input_lines)
|
||||
return [line for line in user_input_lines], []
|
||||
|
||||
def _get_stats_data_answers(self, user_input_lines):
|
||||
""" Statistics for question.answer based questions (simple choice, multiple
|
||||
choice.). A corner case with a void record survey.question.answer is added
|
||||
to count comments that should be considered as valid answers. This small hack
|
||||
allow to have everything available in the same standard structure. """
|
||||
suggested_answers = [answer for answer in self.mapped('suggested_answer_ids')]
|
||||
if self.comment_count_as_answer:
|
||||
suggested_answers += [self.env['survey.question.answer']]
|
||||
|
||||
count_data = dict.fromkeys(suggested_answers, 0)
|
||||
for line in user_input_lines:
|
||||
if line.suggested_answer_id in count_data\
|
||||
or (line.value_char_box and self.comment_count_as_answer):
|
||||
count_data[line.suggested_answer_id] += 1
|
||||
|
||||
table_data = [{
|
||||
'value': _('Other (see comments)') if not sug_answer else sug_answer.value,
|
||||
'suggested_answer': sug_answer,
|
||||
'count': count_data[sug_answer],
|
||||
'count_text': _("%s Votes", count_data[sug_answer]),
|
||||
}
|
||||
for sug_answer in suggested_answers]
|
||||
graph_data = [{
|
||||
'text': _('Other (see comments)') if not sug_answer else sug_answer.value,
|
||||
'count': count_data[sug_answer]
|
||||
}
|
||||
for sug_answer in suggested_answers]
|
||||
|
||||
return table_data, graph_data
|
||||
|
||||
def _get_stats_graph_data_matrix(self, user_input_lines):
|
||||
suggested_answers = self.mapped('suggested_answer_ids')
|
||||
matrix_rows = self.mapped('matrix_row_ids')
|
||||
|
||||
count_data = dict.fromkeys(itertools.product(matrix_rows, suggested_answers), 0)
|
||||
for line in user_input_lines:
|
||||
if line.matrix_row_id and line.suggested_answer_id:
|
||||
count_data[(line.matrix_row_id, line.suggested_answer_id)] += 1
|
||||
|
||||
table_data = [{
|
||||
'row': row,
|
||||
'columns': [{
|
||||
'suggested_answer': sug_answer,
|
||||
'count': count_data[(row, sug_answer)]
|
||||
} for sug_answer in suggested_answers],
|
||||
} for row in matrix_rows]
|
||||
graph_data = [{
|
||||
'key': sug_answer.value,
|
||||
'values': [{
|
||||
'text': row.value,
|
||||
'count': count_data[(row, sug_answer)]
|
||||
}
|
||||
for row in matrix_rows
|
||||
]
|
||||
} for sug_answer in suggested_answers]
|
||||
|
||||
return table_data, graph_data
|
||||
|
||||
def _get_stats_summary_data(self, user_input_lines):
|
||||
stats = {}
|
||||
if self.question_type in ['simple_choice', 'multiple_choice']:
|
||||
stats.update(self._get_stats_summary_data_choice(user_input_lines))
|
||||
elif self.question_type == 'numerical_box':
|
||||
stats.update(self._get_stats_summary_data_numerical(user_input_lines))
|
||||
|
||||
if self.question_type in ['numerical_box', 'date', 'datetime']:
|
||||
stats.update(self._get_stats_summary_data_scored(user_input_lines))
|
||||
return stats
|
||||
|
||||
def _get_stats_summary_data_choice(self, user_input_lines):
|
||||
right_inputs, partial_inputs = self.env['survey.user_input'], self.env['survey.user_input']
|
||||
right_answers = self.suggested_answer_ids.filtered(lambda label: label.is_correct)
|
||||
if self.question_type == 'multiple_choice':
|
||||
for user_input, lines in tools.groupby(user_input_lines, operator.itemgetter('user_input_id')):
|
||||
user_input_answers = self.env['survey.user_input.line'].concat(*lines).filtered(lambda l: l.answer_is_correct).mapped('suggested_answer_id')
|
||||
if user_input_answers and user_input_answers < right_answers:
|
||||
partial_inputs += user_input
|
||||
elif user_input_answers:
|
||||
right_inputs += user_input
|
||||
else:
|
||||
right_inputs = user_input_lines.filtered(lambda line: line.answer_is_correct).mapped('user_input_id')
|
||||
return {
|
||||
'right_answers': right_answers,
|
||||
'right_inputs_count': len(right_inputs),
|
||||
'partial_inputs_count': len(partial_inputs),
|
||||
}
|
||||
|
||||
def _get_stats_summary_data_numerical(self, user_input_lines):
|
||||
all_values = user_input_lines.filtered(lambda line: not line.skipped).mapped('value_numerical_box')
|
||||
lines_sum = sum(all_values)
|
||||
return {
|
||||
'numerical_max': max(all_values, default=0),
|
||||
'numerical_min': min(all_values, default=0),
|
||||
'numerical_average': round(lines_sum / (len(all_values) or 1), 2),
|
||||
}
|
||||
|
||||
def _get_stats_summary_data_scored(self, user_input_lines):
|
||||
return {
|
||||
'common_lines': collections.Counter(
|
||||
user_input_lines.filtered(lambda line: not line.skipped).mapped('value_%s' % self.question_type)
|
||||
).most_common(5),
|
||||
'right_inputs_count': len(user_input_lines.filtered(lambda line: line.answer_is_correct).mapped('user_input_id'))
|
||||
}
|
||||
|
||||
|
||||
class SurveyQuestionAnswer(models.Model):
|
||||
""" A preconfigured answer for a question. This model stores values used
|
||||
for
|
||||
|
||||
* simple choice, multiple choice: proposed values for the selection /
|
||||
radio;
|
||||
* matrix: row and column values;
|
||||
|
||||
"""
|
||||
_name = 'survey.question.answer'
|
||||
_rec_name = 'value'
|
||||
_order = 'sequence, id'
|
||||
_description = 'Survey Label'
|
||||
|
||||
# question and question related fields
|
||||
question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade')
|
||||
matrix_question_id = fields.Many2one('survey.question', string='Question (as matrix row)', ondelete='cascade')
|
||||
question_type = fields.Selection(related='question_id.question_type')
|
||||
sequence = fields.Integer('Label Sequence order', default=10)
|
||||
scoring_type = fields.Selection(related='question_id.scoring_type')
|
||||
# answer related fields
|
||||
value = fields.Char('Suggested value', translate=True, required=True)
|
||||
value_image = fields.Image('Image', max_width=1024, max_height=1024)
|
||||
value_image_filename = fields.Char('Image Filename')
|
||||
is_correct = fields.Boolean('Correct')
|
||||
answer_score = fields.Float('Score', help="A positive score indicates a correct choice; a negative or null score indicates a wrong answer")
|
||||
|
||||
@api.constrains('question_id', 'matrix_question_id')
|
||||
def _check_question_not_empty(self):
|
||||
"""Ensure that field question_id XOR field matrix_question_id is not null"""
|
||||
for label in self:
|
||||
if not bool(label.question_id) != bool(label.matrix_question_id):
|
||||
raise ValidationError(_("A label must be attached to only one question."))
|
||||
1167
odoo-bringout-oca-ocb-survey/survey/models/survey_survey.py
Normal file
1167
odoo-bringout-oca-ocb-survey/survey/models/survey_survey.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,254 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import ast
|
||||
|
||||
from odoo import api, models, _
|
||||
|
||||
|
||||
class SurveyTemplate(models.Model):
|
||||
"""This model defines additional actions on the 'survey.survey' model that
|
||||
can be used to load a survey sample. The model defines a sample for:
|
||||
(1) A feedback form
|
||||
(2) A certification
|
||||
(3) A live presentation
|
||||
"""
|
||||
|
||||
_inherit = 'survey.survey'
|
||||
|
||||
@api.model
|
||||
def action_load_sample_feedback_form(self):
|
||||
return self.env['survey.survey'].create({
|
||||
'title': _('Feedback Form'),
|
||||
'description': '<br>'.join([
|
||||
_('Please complete this very short survey to let us know how satisfied your are with our products.'),
|
||||
_('Your responses will help us improve our product range to serve you even better.')
|
||||
]),
|
||||
'description_done': _('Thank you very much for your feedback. We highly value your opinion !'),
|
||||
'progression_mode': 'number',
|
||||
'questions_layout': 'page_per_question',
|
||||
'question_and_page_ids': [
|
||||
(0, 0, { # survey.question
|
||||
'title': _('How frequently do you use our products?'),
|
||||
'question_type': 'simple_choice',
|
||||
'constr_mandatory': True,
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Often (1-3 times per week)')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Rarely (1-3 times per month)')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Never (less than once a month)')
|
||||
})
|
||||
]
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('How many orders did you pass during the last 6 months?'),
|
||||
'question_type': 'numerical_box',
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('How likely are you to recommend the following products to a friend?'),
|
||||
'question_type': 'matrix',
|
||||
'matrix_subtype': 'simple',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Unlikely')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Neutral')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Likely')
|
||||
}),
|
||||
],
|
||||
'matrix_row_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Red Pen')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Blue Pen')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Yellow Pen')
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
}).action_show_sample()
|
||||
|
||||
@api.model
|
||||
def action_load_sample_certification(self):
|
||||
survey_values = {
|
||||
'title': _('Certification'),
|
||||
'certification': True,
|
||||
'access_mode': 'token',
|
||||
'is_time_limited': True,
|
||||
'time_limit': 15, # 15 minutes
|
||||
'is_attempts_limited': True,
|
||||
'attempts_limit': 1,
|
||||
'progression_mode': 'number',
|
||||
'scoring_type': 'scoring_without_answers',
|
||||
'users_can_go_back': True,
|
||||
'description': ''.join([
|
||||
_('Welcome to this Odoo certification. You will receive 2 random questions out of a pool of 3.'),
|
||||
'(<span style="font-style: italic">',
|
||||
_('Cheating on your neighbors will not help!'),
|
||||
'</span> 😁).<br>',
|
||||
_('Good luck!')
|
||||
]),
|
||||
'description_done': _('Thank you. We will contact you soon.'),
|
||||
'questions_layout': 'page_per_section',
|
||||
'questions_selection': 'random',
|
||||
'question_and_page_ids': [
|
||||
(0, 0, { # survey.question
|
||||
'title': _('Odoo Certification'),
|
||||
'is_page': True,
|
||||
'question_type': False,
|
||||
'random_questions_count': 2
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('What does "ODOO" stand for?'),
|
||||
'question_type': 'simple_choice',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It\'s a Belgian word for "Management"')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Object-Directed Open Organization')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Organizational Development for Operation Officers')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It does not mean anything specific'),
|
||||
'is_correct': True,
|
||||
'answer_score': 10
|
||||
}),
|
||||
]
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('On Survey questions, one can define "placeholders". But what are they for?'),
|
||||
'question_type': 'simple_choice',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('They are a default answer, used if the participant skips the question')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It is a small bit of text, displayed to help participants answer'),
|
||||
'is_correct': True,
|
||||
'answer_score': 10
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('They are technical parameters that guarantees the responsiveness of the page')
|
||||
})
|
||||
]
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('What does one need to get to pass an Odoo Survey?'),
|
||||
'question_type': 'simple_choice',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It is an option that can be different for each Survey'),
|
||||
'is_correct': True,
|
||||
'answer_score': 10
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('One needs to get 50% of the total score')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('One needs to answer at least half the questions correctly')
|
||||
})
|
||||
]
|
||||
}),
|
||||
]
|
||||
}
|
||||
mail_template = self.env.ref('survey.mail_template_certification', raise_if_not_found=False)
|
||||
if mail_template:
|
||||
survey_values.update({
|
||||
'certification_mail_template_id': mail_template.id
|
||||
})
|
||||
return self.env['survey.survey'].create(survey_values).action_show_sample()
|
||||
|
||||
@api.model
|
||||
def action_load_sample_live_presentation(self):
|
||||
return self.env['survey.survey'].create({
|
||||
'title': _('Live Presentation'),
|
||||
'description': '<br>'.join([
|
||||
_('How good of a presenter are you? Let\'s find out!'),
|
||||
_('But first, keep listening to the host.')
|
||||
]),
|
||||
'description_done': _('Thank you for your participation, hope you had a blast!'),
|
||||
'progression_mode': 'number',
|
||||
'scoring_type': 'scoring_with_answers',
|
||||
'questions_layout': 'page_per_question',
|
||||
'session_speed_rating': True,
|
||||
'question_and_page_ids': [
|
||||
(0, 0, { # survey.question
|
||||
'title': _('What is the best way to catch the attention of an audience?'),
|
||||
'question_type': 'simple_choice',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Speak softly so that they need to focus to hear you')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Use a fun visual support, like a live presentation'),
|
||||
'is_correct': True,
|
||||
'answer_score': 20
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Show them slides with a ton of text they need to read fast')
|
||||
})
|
||||
]
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('What is a frequent mistake public speakers do?'),
|
||||
'question_type': 'simple_choice',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Practice in front of a mirror')
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Speak too fast'),
|
||||
'is_correct': True,
|
||||
'answer_score': 20
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('Use humor and make jokes')
|
||||
})
|
||||
]
|
||||
}),
|
||||
(0, 0, { # survey.question
|
||||
'title': _('Why should you consider making your presentation more fun with a small quiz?'),
|
||||
'question_type': 'multiple_choice',
|
||||
'suggested_answer_ids': [
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It helps attendees focus on what you are saying'),
|
||||
'is_correct': True,
|
||||
'answer_score': 20
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It is more engaging for your audience'),
|
||||
'is_correct': True,
|
||||
'answer_score': 20
|
||||
}),
|
||||
(0, 0, { # survey.question.answer
|
||||
'value': _('It helps attendees remember the content of your presentation'),
|
||||
'is_correct': True,
|
||||
'answer_score': 20
|
||||
})
|
||||
]
|
||||
}),
|
||||
|
||||
]
|
||||
}).action_show_sample()
|
||||
|
||||
def action_show_sample(self):
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('survey.action_survey_form')
|
||||
action['views'] = [[self.env.ref('survey.survey_survey_view_form').id, 'form']]
|
||||
action['res_id'] = self.id
|
||||
action['context'] = dict(ast.literal_eval(action.get('context', {})),
|
||||
create=False
|
||||
)
|
||||
return action
|
||||
784
odoo-bringout-oca-ocb-survey/survey/models/survey_user_input.py
Normal file
784
odoo-bringout-oca-ocb-survey/survey/models/survey_user_input.py
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import textwrap
|
||||
import uuid
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import float_is_zero
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SurveyUserInput(models.Model):
|
||||
""" Metadata for a set of one user's answers to a particular survey """
|
||||
_name = "survey.user_input"
|
||||
_description = "Survey User Input"
|
||||
_rec_name = "survey_id"
|
||||
_order = "create_date desc"
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
# answer description
|
||||
survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, ondelete='cascade')
|
||||
scoring_type = fields.Selection(string="Scoring", related="survey_id.scoring_type")
|
||||
start_datetime = fields.Datetime('Start date and time', readonly=True)
|
||||
end_datetime = fields.Datetime('End date and time', readonly=True)
|
||||
deadline = fields.Datetime('Deadline', help="Datetime until customer can open the survey and submit answers")
|
||||
state = fields.Selection([
|
||||
('new', 'Not started yet'),
|
||||
('in_progress', 'In Progress'),
|
||||
('done', 'Completed')], string='Status', default='new', readonly=True)
|
||||
test_entry = fields.Boolean(readonly=True)
|
||||
last_displayed_page_id = fields.Many2one('survey.question', string='Last displayed question/page')
|
||||
# attempts management
|
||||
is_attempts_limited = fields.Boolean("Limited number of attempts", related='survey_id.is_attempts_limited')
|
||||
attempts_limit = fields.Integer("Number of attempts", related='survey_id.attempts_limit')
|
||||
attempts_count = fields.Integer("Attempts Count", compute='_compute_attempts_info')
|
||||
attempts_number = fields.Integer("Attempt n°", compute='_compute_attempts_info')
|
||||
survey_time_limit_reached = fields.Boolean("Survey Time Limit Reached", compute='_compute_survey_time_limit_reached')
|
||||
# identification / access
|
||||
access_token = fields.Char('Identification token', default=lambda self: str(uuid.uuid4()), readonly=True, required=True, copy=False)
|
||||
invite_token = fields.Char('Invite token', readonly=True, copy=False) # no unique constraint, as it identifies a pool of attempts
|
||||
partner_id = fields.Many2one('res.partner', string='Contact', readonly=True)
|
||||
email = fields.Char('Email', readonly=True)
|
||||
nickname = fields.Char('Nickname', help="Attendee nickname, mainly used to identify them in the survey session leaderboard.")
|
||||
# questions / answers
|
||||
user_input_line_ids = fields.One2many('survey.user_input.line', 'user_input_id', string='Answers', copy=True)
|
||||
predefined_question_ids = fields.Many2many('survey.question', string='Predefined Questions', readonly=True)
|
||||
scoring_percentage = fields.Float("Score (%)", compute="_compute_scoring_values", store=True, compute_sudo=True) # stored for perf reasons
|
||||
scoring_total = fields.Float("Total Score", compute="_compute_scoring_values", store=True, compute_sudo=True) # stored for perf reasons
|
||||
scoring_success = fields.Boolean('Quizz Passed', compute='_compute_scoring_success', store=True, compute_sudo=True) # stored for perf reasons
|
||||
# live sessions
|
||||
is_session_answer = fields.Boolean('Is in a Session', help="Is that user input part of a survey session or not.")
|
||||
question_time_limit_reached = fields.Boolean("Question Time Limit Reached", compute='_compute_question_time_limit_reached')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_token', 'UNIQUE (access_token)', 'An access token must be unique!'),
|
||||
]
|
||||
|
||||
@api.depends('user_input_line_ids.answer_score', 'user_input_line_ids.question_id', 'predefined_question_ids.answer_score')
|
||||
def _compute_scoring_values(self):
|
||||
for user_input in self:
|
||||
# sum(multi-choice question scores) + sum(simple answer_type scores)
|
||||
total_possible_score = 0
|
||||
for question in user_input.predefined_question_ids:
|
||||
if question.question_type == 'simple_choice':
|
||||
total_possible_score += max([score for score in question.mapped('suggested_answer_ids.answer_score') if score > 0], default=0)
|
||||
elif question.question_type == 'multiple_choice':
|
||||
total_possible_score += sum(score for score in question.mapped('suggested_answer_ids.answer_score') if score > 0)
|
||||
elif question.is_scored_question:
|
||||
total_possible_score += question.answer_score
|
||||
|
||||
if total_possible_score == 0:
|
||||
user_input.scoring_percentage = 0
|
||||
user_input.scoring_total = 0
|
||||
else:
|
||||
score_total = sum(user_input.user_input_line_ids.mapped('answer_score'))
|
||||
user_input.scoring_total = score_total
|
||||
score_percentage = (score_total / total_possible_score) * 100
|
||||
user_input.scoring_percentage = round(score_percentage, 2) if score_percentage > 0 else 0
|
||||
|
||||
@api.depends('scoring_percentage', 'survey_id')
|
||||
def _compute_scoring_success(self):
|
||||
for user_input in self:
|
||||
user_input.scoring_success = user_input.scoring_percentage >= user_input.survey_id.scoring_success_min
|
||||
|
||||
@api.depends(
|
||||
'start_datetime',
|
||||
'survey_id.is_time_limited',
|
||||
'survey_id.time_limit')
|
||||
def _compute_survey_time_limit_reached(self):
|
||||
""" Checks that the user_input is not exceeding the survey's time limit. """
|
||||
for user_input in self:
|
||||
if not user_input.is_session_answer and user_input.start_datetime:
|
||||
start_time = user_input.start_datetime
|
||||
time_limit = user_input.survey_id.time_limit
|
||||
user_input.survey_time_limit_reached = user_input.survey_id.is_time_limited and \
|
||||
fields.Datetime.now() >= start_time + relativedelta(minutes=time_limit)
|
||||
else:
|
||||
user_input.survey_time_limit_reached = False
|
||||
|
||||
@api.depends(
|
||||
'survey_id.session_question_id.time_limit',
|
||||
'survey_id.session_question_id.is_time_limited',
|
||||
'survey_id.session_question_start_time')
|
||||
def _compute_question_time_limit_reached(self):
|
||||
""" Checks that the user_input is not exceeding the question's time limit.
|
||||
Only used in the context of survey sessions. """
|
||||
for user_input in self:
|
||||
if user_input.is_session_answer and user_input.survey_id.session_question_start_time:
|
||||
start_time = user_input.survey_id.session_question_start_time
|
||||
time_limit = user_input.survey_id.session_question_id.time_limit
|
||||
user_input.question_time_limit_reached = user_input.survey_id.session_question_id.is_time_limited and \
|
||||
fields.Datetime.now() >= start_time + relativedelta(seconds=time_limit)
|
||||
else:
|
||||
user_input.question_time_limit_reached = False
|
||||
|
||||
@api.depends('state', 'test_entry', 'survey_id.is_attempts_limited', 'partner_id', 'email', 'invite_token')
|
||||
def _compute_attempts_info(self):
|
||||
attempts_to_compute = self.filtered(
|
||||
lambda user_input: user_input.state == 'done' and not user_input.test_entry and user_input.survey_id.is_attempts_limited
|
||||
)
|
||||
|
||||
for user_input in (self - attempts_to_compute):
|
||||
user_input.attempts_count = 1
|
||||
user_input.attempts_number = 1
|
||||
|
||||
if attempts_to_compute:
|
||||
self.flush_model(['email', 'invite_token', 'partner_id', 'state', 'survey_id', 'test_entry'])
|
||||
|
||||
self.env.cr.execute("""
|
||||
SELECT user_input.id,
|
||||
COUNT(all_attempts_user_input.id) AS attempts_count,
|
||||
COUNT(CASE WHEN all_attempts_user_input.id < user_input.id THEN all_attempts_user_input.id END) + 1 AS attempts_number
|
||||
FROM survey_user_input user_input
|
||||
LEFT OUTER JOIN survey_user_input all_attempts_user_input
|
||||
ON user_input.survey_id = all_attempts_user_input.survey_id
|
||||
AND all_attempts_user_input.state = 'done'
|
||||
AND all_attempts_user_input.test_entry IS NOT TRUE
|
||||
AND (user_input.invite_token IS NULL OR user_input.invite_token = all_attempts_user_input.invite_token)
|
||||
AND (user_input.partner_id = all_attempts_user_input.partner_id OR user_input.email = all_attempts_user_input.email)
|
||||
WHERE user_input.id IN %s
|
||||
GROUP BY user_input.id;
|
||||
""", (tuple(attempts_to_compute.ids),))
|
||||
|
||||
attempts_number_results = self.env.cr.dictfetchall()
|
||||
|
||||
attempts_number_results = {
|
||||
attempts_number_result['id']: {
|
||||
'attempts_number': attempts_number_result['attempts_number'],
|
||||
'attempts_count': attempts_number_result['attempts_count'],
|
||||
}
|
||||
for attempts_number_result in attempts_number_results
|
||||
}
|
||||
|
||||
for user_input in attempts_to_compute:
|
||||
attempts_number_result = attempts_number_results.get(user_input.id, {})
|
||||
user_input.attempts_number = attempts_number_result.get('attempts_number', 1)
|
||||
user_input.attempts_count = attempts_number_result.get('attempts_count', 1)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if 'predefined_question_ids' not in vals:
|
||||
suvey_id = vals.get('survey_id', self.env.context.get('default_survey_id'))
|
||||
survey = self.env['survey.survey'].browse(suvey_id)
|
||||
vals['predefined_question_ids'] = [(6, 0, survey._prepare_user_input_predefined_questions().ids)]
|
||||
return super(SurveyUserInput, self).create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# ACTIONS / BUSINESS
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def action_resend(self):
|
||||
partners = self.env['res.partner']
|
||||
emails = []
|
||||
for user_answer in self:
|
||||
if user_answer.partner_id:
|
||||
partners |= user_answer.partner_id
|
||||
elif user_answer.email:
|
||||
emails.append(user_answer.email)
|
||||
|
||||
return self.survey_id.with_context(
|
||||
default_existing_mode='resend',
|
||||
default_partner_ids=partners.ids,
|
||||
default_emails=','.join(emails)
|
||||
).action_send_survey()
|
||||
|
||||
def action_print_answers(self):
|
||||
""" Open the website page with the survey form """
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'name': "View Answers",
|
||||
'target': 'self',
|
||||
'url': '/survey/print/%s?answer_token=%s' % (self.survey_id.access_token, self.access_token)
|
||||
}
|
||||
|
||||
def action_redirect_to_attempts(self):
|
||||
self.ensure_one()
|
||||
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('survey.action_survey_user_input')
|
||||
context = dict(self.env.context or {})
|
||||
|
||||
context['create'] = False
|
||||
context['search_default_survey_id'] = self.survey_id.id
|
||||
context['search_default_group_by_survey'] = False
|
||||
if self.partner_id:
|
||||
context['search_default_partner_id'] = self.partner_id.id
|
||||
elif self.email:
|
||||
context['search_default_email'] = self.email
|
||||
|
||||
action['context'] = context
|
||||
return action
|
||||
|
||||
@api.model
|
||||
def _generate_invite_token(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _mark_in_progress(self):
|
||||
""" marks the state as 'in_progress' and updates the start_datetime accordingly. """
|
||||
self.write({
|
||||
'start_datetime': fields.Datetime.now(),
|
||||
'state': 'in_progress'
|
||||
})
|
||||
|
||||
def _mark_done(self):
|
||||
""" This method will:
|
||||
1. mark the state as 'done'
|
||||
2. send the certification email with attached document if
|
||||
- The survey is a certification
|
||||
- It has a certification_mail_template_id set
|
||||
- The user succeeded the test
|
||||
Will also run challenge Cron to give the certification badge if any."""
|
||||
self.write({
|
||||
'end_datetime': fields.Datetime.now(),
|
||||
'state': 'done',
|
||||
})
|
||||
|
||||
Challenge = self.env['gamification.challenge'].sudo()
|
||||
badge_ids = []
|
||||
for user_input in self:
|
||||
if user_input.survey_id.certification and user_input.scoring_success:
|
||||
if user_input.survey_id.certification_mail_template_id and not user_input.test_entry:
|
||||
user_input.survey_id.certification_mail_template_id.send_mail(user_input.id, email_layout_xmlid="mail.mail_notification_light")
|
||||
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:
|
||||
Challenge._cron_update(ids=challenges.ids, commit=False)
|
||||
|
||||
def get_start_url(self):
|
||||
self.ensure_one()
|
||||
return '%s?answer_token=%s' % (self.survey_id.get_start_url(), self.access_token)
|
||||
|
||||
def get_print_url(self):
|
||||
self.ensure_one()
|
||||
return '%s?answer_token=%s' % (self.survey_id.get_print_url(), self.access_token)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# CREATE / UPDATE LINES FROM SURVEY FRONTEND INPUT
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def save_lines(self, question, answer, comment=None):
|
||||
""" Save answers to questions, depending on question type
|
||||
|
||||
If an answer already exists for question and user_input_id, it will be
|
||||
overwritten (or deleted for 'choice' questions) (in order to maintain data consistency).
|
||||
"""
|
||||
old_answers = self.env['survey.user_input.line'].search([
|
||||
('user_input_id', '=', self.id),
|
||||
('question_id', '=', question.id)
|
||||
])
|
||||
|
||||
if question.question_type in ['char_box', 'text_box', 'numerical_box', 'date', 'datetime']:
|
||||
self._save_line_simple_answer(question, old_answers, answer)
|
||||
if question.save_as_email and answer:
|
||||
self.write({'email': answer})
|
||||
if question.save_as_nickname and answer:
|
||||
self.write({'nickname': answer})
|
||||
|
||||
elif question.question_type in ['simple_choice', 'multiple_choice']:
|
||||
self._save_line_choice(question, old_answers, answer, comment)
|
||||
elif question.question_type == 'matrix':
|
||||
self._save_line_matrix(question, old_answers, answer, comment)
|
||||
else:
|
||||
raise AttributeError(question.question_type + ": This type of question has no saving function")
|
||||
|
||||
def _save_line_simple_answer(self, question, old_answers, answer):
|
||||
vals = self._get_line_answer_values(question, answer, question.question_type)
|
||||
if old_answers:
|
||||
old_answers.write(vals)
|
||||
return old_answers
|
||||
else:
|
||||
return self.env['survey.user_input.line'].create(vals)
|
||||
|
||||
def _save_line_choice(self, question, old_answers, answers, comment):
|
||||
if not (isinstance(answers, list)):
|
||||
answers = [answers]
|
||||
|
||||
if not answers:
|
||||
# add a False answer to force saving a skipped line
|
||||
# this will make this question correctly considered as skipped in statistics
|
||||
answers = [False]
|
||||
|
||||
vals_list = []
|
||||
|
||||
if question.question_type == 'simple_choice':
|
||||
if not question.comment_count_as_answer or not question.comments_allowed or not comment:
|
||||
vals_list = [self._get_line_answer_values(question, answer, 'suggestion') for answer in answers]
|
||||
elif question.question_type == 'multiple_choice':
|
||||
vals_list = [self._get_line_answer_values(question, answer, 'suggestion') for answer in answers]
|
||||
|
||||
if comment:
|
||||
vals_list.append(self._get_line_comment_values(question, comment))
|
||||
|
||||
old_answers.sudo().unlink()
|
||||
return self.env['survey.user_input.line'].create(vals_list)
|
||||
|
||||
def _save_line_matrix(self, question, old_answers, answers, comment):
|
||||
vals_list = []
|
||||
|
||||
if not answers and question.matrix_row_ids:
|
||||
# add a False answer to force saving a skipped line
|
||||
# this will make this question correctly considered as skipped in statistics
|
||||
answers = {question.matrix_row_ids[0].id: [False]}
|
||||
|
||||
if answers:
|
||||
for row_key, row_answer in answers.items():
|
||||
for answer in row_answer:
|
||||
vals = self._get_line_answer_values(question, answer, 'suggestion')
|
||||
vals['matrix_row_id'] = int(row_key)
|
||||
vals_list.append(vals.copy())
|
||||
|
||||
if comment:
|
||||
vals_list.append(self._get_line_comment_values(question, comment))
|
||||
|
||||
old_answers.sudo().unlink()
|
||||
return self.env['survey.user_input.line'].create(vals_list)
|
||||
|
||||
def _get_line_answer_values(self, question, answer, answer_type):
|
||||
vals = {
|
||||
'user_input_id': self.id,
|
||||
'question_id': question.id,
|
||||
'skipped': False,
|
||||
'answer_type': answer_type,
|
||||
}
|
||||
if not answer or (isinstance(answer, str) and not answer.strip()):
|
||||
vals.update(answer_type=None, skipped=True)
|
||||
return vals
|
||||
|
||||
if answer_type == 'suggestion':
|
||||
vals['suggested_answer_id'] = int(answer)
|
||||
elif answer_type == 'numerical_box':
|
||||
vals['value_numerical_box'] = float(answer)
|
||||
else:
|
||||
vals['value_%s' % answer_type] = answer
|
||||
return vals
|
||||
|
||||
def _get_line_comment_values(self, question, comment):
|
||||
return {
|
||||
'user_input_id': self.id,
|
||||
'question_id': question.id,
|
||||
'skipped': False,
|
||||
'answer_type': 'char_box',
|
||||
'value_char_box': comment,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# STATISTICS / RESULTS
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def _prepare_statistics(self):
|
||||
""" Prepares survey.user_input's statistics to display various charts on the frontend.
|
||||
Returns a structure containing answers statistics "by section" and "totals" for every input in self.
|
||||
|
||||
e.g returned structure:
|
||||
{
|
||||
survey.user_input(1,): {
|
||||
'by_section': {
|
||||
'Uncategorized': {
|
||||
'question_count': 2,
|
||||
'correct': 2,
|
||||
'partial': 0,
|
||||
'incorrect': 0,
|
||||
'skipped': 0,
|
||||
},
|
||||
'Mathematics': {
|
||||
'question_count': 3,
|
||||
'correct': 1,
|
||||
'partial': 1,
|
||||
'incorrect': 0,
|
||||
'skipped': 1,
|
||||
},
|
||||
'Geography': {
|
||||
'question_count': 4,
|
||||
'correct': 2,
|
||||
'partial': 0,
|
||||
'incorrect': 2,
|
||||
'skipped': 0,
|
||||
}
|
||||
},
|
||||
'totals' [{
|
||||
'text': 'Correct',
|
||||
'count': 5,
|
||||
}, {
|
||||
'text': 'Partially',
|
||||
'count': 1,
|
||||
}, {
|
||||
'text': 'Incorrect',
|
||||
'count': 2,
|
||||
}, {
|
||||
'text': 'Unanswered',
|
||||
'count': 1,
|
||||
}]
|
||||
}
|
||||
}"""
|
||||
res = dict((user_input, {
|
||||
'by_section': {}
|
||||
}) for user_input in self)
|
||||
|
||||
scored_questions = self.mapped('predefined_question_ids').filtered(lambda question: question.is_scored_question)
|
||||
|
||||
for question in scored_questions:
|
||||
if question.question_type in ['simple_choice', 'multiple_choice']:
|
||||
question_correct_suggested_answers = question.suggested_answer_ids.filtered(lambda answer: answer.is_correct)
|
||||
|
||||
question_section = question.page_id.title or _('Uncategorized')
|
||||
for user_input in self:
|
||||
user_input_lines = user_input.user_input_line_ids.filtered(lambda line: line.question_id == question)
|
||||
if question.question_type in ['simple_choice', 'multiple_choice']:
|
||||
answer_result_key = self._choice_question_answer_result(user_input_lines, question_correct_suggested_answers)
|
||||
else:
|
||||
answer_result_key = self._simple_question_answer_result(user_input_lines)
|
||||
|
||||
if question_section not in res[user_input]['by_section']:
|
||||
res[user_input]['by_section'][question_section] = {
|
||||
'question_count': 0,
|
||||
'correct': 0,
|
||||
'partial': 0,
|
||||
'incorrect': 0,
|
||||
'skipped': 0,
|
||||
}
|
||||
|
||||
res[user_input]['by_section'][question_section]['question_count'] += 1
|
||||
res[user_input]['by_section'][question_section][answer_result_key] += 1
|
||||
|
||||
for user_input in self:
|
||||
correct_count = 0
|
||||
partial_count = 0
|
||||
incorrect_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for section_counts in res[user_input]['by_section'].values():
|
||||
correct_count += section_counts.get('correct', 0)
|
||||
partial_count += section_counts.get('partial', 0)
|
||||
incorrect_count += section_counts.get('incorrect', 0)
|
||||
skipped_count += section_counts.get('skipped', 0)
|
||||
|
||||
res[user_input]['totals'] = [
|
||||
{'text': _("Correct"), 'count': correct_count},
|
||||
{'text': _("Partially"), 'count': partial_count},
|
||||
{'text': _("Incorrect"), 'count': incorrect_count},
|
||||
{'text': _("Unanswered"), 'count': skipped_count}
|
||||
]
|
||||
|
||||
return res
|
||||
|
||||
def _choice_question_answer_result(self, user_input_lines, question_correct_suggested_answers):
|
||||
correct_user_input_lines = user_input_lines.filtered(lambda line: line.answer_is_correct and not line.skipped).mapped('suggested_answer_id')
|
||||
incorrect_user_input_lines = user_input_lines.filtered(lambda line: not line.answer_is_correct and not line.skipped)
|
||||
if question_correct_suggested_answers and correct_user_input_lines == question_correct_suggested_answers:
|
||||
return 'correct'
|
||||
elif correct_user_input_lines and correct_user_input_lines < question_correct_suggested_answers:
|
||||
return 'partial'
|
||||
elif not correct_user_input_lines and incorrect_user_input_lines:
|
||||
return 'incorrect'
|
||||
else:
|
||||
return 'skipped'
|
||||
|
||||
def _simple_question_answer_result(self, user_input_line):
|
||||
if user_input_line.skipped:
|
||||
return 'skipped'
|
||||
elif user_input_line.answer_is_correct:
|
||||
return 'correct'
|
||||
else:
|
||||
return 'incorrect'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 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 their mind unchecking depending answer and changed again their 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
|
||||
|
||||
def _get_print_questions(self):
|
||||
""" Get the questions to display : the ones that should have been answered = active questions
|
||||
In case of session, active questions are based on most voted answers
|
||||
:return: active survey.question browse records
|
||||
"""
|
||||
survey = self.survey_id
|
||||
if self.is_session_answer:
|
||||
most_voted_answers = survey._get_session_most_voted_answers()
|
||||
inactive_questions = most_voted_answers._get_inactive_conditional_questions()
|
||||
else:
|
||||
inactive_questions = self._get_inactive_conditional_questions()
|
||||
return survey.question_ids - inactive_questions
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# MESSAGING
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def _message_get_suggested_recipients(self):
|
||||
recipients = super()._message_get_suggested_recipients()
|
||||
for user_input in self:
|
||||
if user_input.partner_id:
|
||||
user_input._message_add_suggested_recipient(
|
||||
recipients,
|
||||
partner=user_input.partner_id,
|
||||
reason=_('Survey Participant')
|
||||
)
|
||||
return recipients
|
||||
|
||||
|
||||
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, index=True)
|
||||
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_box', 'Free Text'),
|
||||
('char_box', 'Text'),
|
||||
('numerical_box', 'Number'),
|
||||
('date', 'Date'),
|
||||
('datetime', 'Datetime'),
|
||||
('suggestion', 'Suggestion')], string='Answer Type')
|
||||
value_char_box = fields.Char('Text answer')
|
||||
value_numerical_box = fields.Float('Numerical answer')
|
||||
value_date = fields.Date('Date answer')
|
||||
value_datetime = fields.Datetime('Datetime answer')
|
||||
value_text_box = fields.Text('Free Text answer')
|
||||
suggested_answer_id = fields.Many2one('survey.question.answer', string="Suggested answer")
|
||||
matrix_row_id = fields.Many2one('survey.question.answer', string="Row answer")
|
||||
# scoring
|
||||
answer_score = fields.Float('Score')
|
||||
answer_is_correct = fields.Boolean('Correct')
|
||||
|
||||
@api.depends('answer_type')
|
||||
def _compute_display_name(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box':
|
||||
line.display_name = line.value_char_box
|
||||
elif line.answer_type == 'text_box' and line.value_text_box:
|
||||
line.display_name = textwrap.shorten(line.value_text_box, width=50, placeholder=" [...]")
|
||||
elif line.answer_type == 'numerical_box':
|
||||
line.display_name = line.value_numerical_box
|
||||
elif line.answer_type == 'date':
|
||||
line.display_name = fields.Date.to_string(line.value_date)
|
||||
elif line.answer_type == 'datetime':
|
||||
line.display_name = fields.Datetime.to_string(line.value_datetime)
|
||||
elif line.answer_type == 'suggestion':
|
||||
if line.matrix_row_id:
|
||||
line.display_name = '%s: %s' % (
|
||||
line.suggested_answer_id.value,
|
||||
line.matrix_row_id.value)
|
||||
else:
|
||||
line.display_name = line.suggested_answer_id.value
|
||||
|
||||
if not line.display_name:
|
||||
line.display_name = _('Skipped')
|
||||
|
||||
@api.constrains('skipped', 'answer_type')
|
||||
def _check_answer_type_skipped(self):
|
||||
for line in self:
|
||||
if (line.skipped == bool(line.answer_type)):
|
||||
raise ValidationError(_('A question can either be skipped or answered, not both.'))
|
||||
|
||||
# allow 0 for numerical box
|
||||
if line.answer_type == 'numerical_box' and float_is_zero(line['value_numerical_box'], precision_digits=6):
|
||||
continue
|
||||
if line.answer_type == 'suggestion':
|
||||
field_name = 'suggested_answer_id'
|
||||
elif line.answer_type:
|
||||
field_name = 'value_%s' % line.answer_type
|
||||
else: # skipped
|
||||
field_name = False
|
||||
|
||||
if field_name and not line[field_name]:
|
||||
raise ValidationError(_('The answer must be in the right type'))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('answer_score'):
|
||||
score_vals = self._get_answer_score_values(vals)
|
||||
vals.update(score_vals)
|
||||
return super(SurveyUserInputLine, self).create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
res = True
|
||||
for line in self:
|
||||
vals_copy = {**vals}
|
||||
getter_params = {
|
||||
'user_input_id': line.user_input_id.id,
|
||||
'answer_type': line.answer_type,
|
||||
'question_id': line.question_id.id,
|
||||
**vals_copy
|
||||
}
|
||||
if not vals_copy.get('answer_score'):
|
||||
score_vals = self._get_answer_score_values(getter_params, compute_speed_score=False)
|
||||
vals_copy.update(score_vals)
|
||||
res = super(SurveyUserInputLine, line).write(vals_copy) and res
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_answer_score_values(self, vals, compute_speed_score=True):
|
||||
""" Get values for: answer_is_correct and associated answer_score.
|
||||
|
||||
Requires vals to contain 'answer_type', 'question_id', and 'user_input_id'.
|
||||
Depending on 'answer_type' additional value of 'suggested_answer_id' may also be
|
||||
required.
|
||||
|
||||
Calculates whether an answer_is_correct and its score based on 'answer_type' and
|
||||
corresponding question. Handles choice (answer_type == 'suggestion') questions
|
||||
separately from other question types. Each selected choice answer is handled as an
|
||||
individual answer.
|
||||
|
||||
If score depends on the speed of the answer, it is adjusted as follows:
|
||||
- If the user answers in less than 2 seconds, they receive 100% of the possible points.
|
||||
- If user answers after that, they receive 50% of the possible points + the remaining
|
||||
50% scaled by the time limit and time taken to answer [i.e. a minimum of 50% of the
|
||||
possible points is given to all correct answers]
|
||||
|
||||
Example of returned values:
|
||||
* {'answer_is_correct': False, 'answer_score': 0} (default)
|
||||
* {'answer_is_correct': True, 'answer_score': 2.0}
|
||||
"""
|
||||
user_input_id = vals.get('user_input_id')
|
||||
answer_type = vals.get('answer_type')
|
||||
question_id = vals.get('question_id')
|
||||
if not question_id:
|
||||
raise ValueError(_('Computing score requires a question in arguments.'))
|
||||
question = self.env['survey.question'].browse(int(question_id))
|
||||
|
||||
# default and non-scored questions
|
||||
answer_is_correct = False
|
||||
answer_score = 0
|
||||
|
||||
# record selected suggested choice answer_score (can be: pos, neg, or 0)
|
||||
if question.question_type in ['simple_choice', 'multiple_choice']:
|
||||
if answer_type == 'suggestion':
|
||||
suggested_answer_id = vals.get('suggested_answer_id')
|
||||
if suggested_answer_id:
|
||||
question_answer = self.env['survey.question.answer'].browse(int(suggested_answer_id))
|
||||
answer_score = question_answer.answer_score
|
||||
answer_is_correct = question_answer.is_correct
|
||||
# for all other scored question cases, record question answer_score (can be: pos or 0)
|
||||
elif question.question_type in ['date', 'datetime', 'numerical_box']:
|
||||
answer = vals.get('value_%s' % answer_type)
|
||||
if answer_type == 'numerical_box':
|
||||
answer = float(answer)
|
||||
elif answer_type == 'date':
|
||||
answer = fields.Date.from_string(answer)
|
||||
elif answer_type == 'datetime':
|
||||
answer = fields.Datetime.from_string(answer)
|
||||
if answer and answer == question['answer_%s' % answer_type]:
|
||||
answer_is_correct = True
|
||||
answer_score = question.answer_score
|
||||
|
||||
if compute_speed_score and answer_score > 0:
|
||||
user_input = self.env['survey.user_input'].browse(user_input_id)
|
||||
session_speed_rating = user_input.exists() and user_input.is_session_answer and user_input.survey_id.session_speed_rating
|
||||
if session_speed_rating:
|
||||
max_score_delay = 2
|
||||
time_limit = question.time_limit
|
||||
now = fields.Datetime.now()
|
||||
seconds_to_answer = (now - user_input.survey_id.session_question_start_time).total_seconds()
|
||||
question_remaining_time = time_limit - seconds_to_answer
|
||||
# if answered within the max_score_delay => leave score as is
|
||||
if question_remaining_time < 0: # if no time left
|
||||
answer_score /= 2
|
||||
elif seconds_to_answer > max_score_delay:
|
||||
time_limit -= max_score_delay # we remove the max_score_delay to have all possible values
|
||||
score_proportion = (time_limit - seconds_to_answer) / time_limit
|
||||
answer_score = (answer_score / 2) * (1 + score_proportion)
|
||||
|
||||
return {
|
||||
'answer_is_correct': answer_is_correct,
|
||||
'answer_score': answer_score
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue