19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -19,41 +19,18 @@ pip install odoo-bringout-oca-ocb-survey
## Dependencies
This addon depends on:
- auth_signup
- http_routing
- mail
- web_tour
- gamification
## Manifest Information
- **Name**: Surveys
- **Version**: 3.5
- **Category**: Marketing/Surveys
- **License**: LGPL-3
- **Installable**: True
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `survey`.
- Repository: https://github.com/OCA/OCB
- Branch: 19.0
- Path: addons/survey
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md
This package preserves the original LGPL-3 license.

View file

@ -1,16 +1,18 @@
[project]
name = "odoo-bringout-oca-ocb-survey"
version = "16.0.0"
description = "Surveys - Send your surveys or share them live."
description = "Surveys -
Send your surveys or share them live.
"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-auth_signup>=16.0.0",
"odoo-bringout-oca-ocb-http_routing>=16.0.0",
"odoo-bringout-oca-ocb-mail>=16.0.0",
"odoo-bringout-oca-ocb-web_tour>=16.0.0",
"odoo-bringout-oca-ocb-gamification>=16.0.0",
"odoo-bringout-oca-ocb-auth_signup>=19.0.0",
"odoo-bringout-oca-ocb-http_routing>=19.0.0",
"odoo-bringout-oca-ocb-mail>=19.0.0",
"odoo-bringout-oca-ocb-web_tour>=19.0.0",
"odoo-bringout-oca-ocb-gamification>=19.0.0",
"requests>=2.25.1"
]
readme = "README.md"
@ -20,7 +22,7 @@ classifiers = [
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]

View file

@ -3,4 +3,5 @@
from . import controllers
from . import models
from . import report
from . import wizard

View file

@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Surveys',
'version': '3.5',
'version': '3.7',
'category': 'Marketing/Surveys',
'description': """
Create beautiful surveys and visualize answers
@ -23,9 +23,12 @@ sent mails with personal token for the invitation of the survey.
'web_tour',
'gamification'],
'data': [
'views/survey_report_templates.xml',
'views/survey_reports.xml',
'report/survey_templates.xml',
'report/survey_reports.xml',
'data/ir_actions_server_data.xml',
'data/mail_message_subtype_data.xml',
'data/mail_template_data.xml',
'data/survey_tour.xml',
'security/survey_security.xml',
'security/ir.model.access.csv',
'views/survey_menus.xml',
@ -50,9 +53,6 @@ sent mails with personal token for the invitation of the survey.
'data/survey_demo_certification.xml',
'data/survey_demo_certification_user_input.xml',
'data/survey_demo_certification_user_input_line.xml',
'data/survey_demo_quiz.xml',
'data/survey_demo_quiz_user_input.xml',
'data/survey_demo_quiz_user_input_line.xml',
'data/survey_demo_conditional.xml',
],
'installable': True,
@ -60,31 +60,28 @@ sent mails with personal token for the invitation of the survey.
'sequence': 220,
'assets': {
'survey.survey_assets': [
'web/static/lib/Chart/Chart.js',
'survey/static/src/js/survey_image_zoomer.js',
'/survey/static/src/xml/survey_image_zoomer_templates.xml',
'survey/static/src/js/survey_quick_access.js',
'survey/static/src/js/survey_timer.js',
'survey/static/src/js/survey_breadcrumb.js',
'survey/static/src/js/survey_form.js',
('include', "web.chartjs_lib"),
'survey/static/src/utils.js',
'/survey/static/src/interactions/survey_image_zoomer_templates.xml',
'survey/static/src/js/survey_preload_image_mixin.js',
'survey/static/src/js/survey_print.js',
'survey/static/src/js/survey_result.js',
('include', 'web._assets_helpers'),
('include', 'web._assets_frontend_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'web/static/lib/bootstrap/scss/_variables-dark.scss',
'web/static/lib/bootstrap/scss/_maps.scss',
'survey/static/src/scss/survey_templates_form.scss',
'survey/static/src/scss/survey_templates_results.scss',
'survey/static/src/xml/survey_breadcrumb_templates.xml',
'survey/static/src/interactions/survey_breadcrumb_templates.xml',
'survey/static/src/xml/survey_paginated_results_rows_template.xml',
'survey/static/src/interactions/*',
],
'survey.survey_user_input_session_assets': [
'survey/static/src/js/libs/chartjs-plugin-datalabels.min.js',
'survey/static/src/js/survey_session_colors.js',
'survey/static/src/js/survey_session_chart.js',
'survey/static/src/js/survey_session_text_answers.js',
'survey/static/src/js/survey_session_leaderboard.js',
'survey/static/src/js/survey_session_manage.js',
'survey/static/src/interactions/survey_session_colors.js',
'survey/static/src/interactions/survey_session_chart.js',
'survey/static/src/interactions/survey_session_text_answers.js',
'survey/static/src/interactions/survey_session_leaderboard.js',
'survey/static/src/interactions/survey_session_manage.js',
'survey/static/src/xml/survey_session_text_answer_template.xml',
],
'web.report_assets_common': [
@ -92,27 +89,26 @@ sent mails with personal token for the invitation of the survey.
],
'web.assets_backend': [
'survey/static/src/question_page/*',
'survey/static/src/js/fields_section_one2many.js',
'survey/static/src/js/fields_form_page_description.js',
'survey/static/src/views/*.js',
'survey/static/src/views/**/*.js',
'survey/static/src/views/**/*.xml',
'survey/static/src/scss/survey_survey_views.scss',
'survey/static/src/scss/survey_question_views.scss',
'survey/static/src/js/tours/survey_tour.js',
],
"web.dark_mode_assets_backend": [
"web.assets_web_dark": [
'survey/static/src/scss/*.dark.scss',
],
'web.assets_tests': [
'survey/static/tests/tours/*.js',
],
'web.qunit_suite_tests': [
'survey/static/tests/components/*.js',
],
'web.assets_common': [
'survey/static/src/js/tours/survey_tour.js',
'web.assets_unit_tests': [
'survey/static/tests/components/*.test.js',
'survey/static/tests/fields/*.test.js',
],
'web.assets_frontend': [
'survey/static/src/js/tours/survey_tour.js',
],
},
'author': 'Odoo S.A.',
'license': 'LGPL-3',
}

View file

@ -1,17 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import werkzeug
from collections import defaultdict
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from odoo import fields, http, SUPERUSER_ID, _
from odoo.exceptions import UserError
from odoo.fields import Domain
from odoo.http import request, content_disposition
from odoo.osv import expression
from odoo.tools import format_datetime, format_date, is_html_empty
from odoo.addons.base.models.ir_qweb import keep_query
@ -28,18 +28,22 @@ class Survey(http.Controller):
""" 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
issues now that access is granted through token. """
survey_sudo = request.env['survey.survey'].with_context(active_test=False).sudo().search([('access_token', '=', survey_token)])
if not answer_token:
answer_sudo = request.env['survey.user_input'].sudo()
else:
answer_sudo = request.env['survey.user_input'].sudo().search([
('survey_id', '=', survey_sudo.id),
('access_token', '=', answer_token)
], limit=1)
return survey_sudo, answer_sudo
SurveySudo, UserInputSudo = request.env['survey.survey'].sudo(), request.env['survey.user_input'].sudo()
if not survey_token:
return SurveySudo, UserInputSudo
if answer_token:
answer_sudo = UserInputSudo.search(
Domain('survey_id', 'any',
Domain('access_token', '=', survey_token)
& Domain('active', 'in', (True, False)) # keeping active test for UserInput
) & Domain('access_token', '=', answer_token), limit=1)
if answer_sudo:
return answer_sudo.survey_id, answer_sudo
def _check_validity(self, survey_token, answer_token, ensure_token=True, check_partner=True):
""" Check survey is open and can be taken. This does not checks for
return SurveySudo.with_context(active_test=False).search([('access_token', '=', survey_token)]), UserInputSudo
def _check_validity(self, survey_sudo, answer_sudo, answer_token, ensure_token=True, check_partner=True):
""" Check survey is open and can be taken. This does not check for
security rules, only functional / business rules. It returns a string key
allowing further manipulation of validity issues
@ -48,8 +52,7 @@ class Survey(http.Controller):
* survey_closed: survey is closed and does not accept input anymore;
* survey_void: survey is void and should not be taken;
* token_wrong: given token not recognized;
* token_required: no token given although it is necessary to access the
survey;
* token_required: no token given, but it is required to access the survey;
* answer_deadline: token linked to an expired answer;
:param ensure_token: whether user input existence based on given access token
@ -59,17 +62,15 @@ class Survey(http.Controller):
:param check_partner: Whether we must check that the partner associated to the target
answer corresponds to the active user.
"""
survey_sudo, answer_sudo = self._fetch_from_access_token(survey_token, answer_token)
if not survey_sudo.exists():
if not survey_sudo:
return 'survey_wrong'
if answer_token and not answer_sudo:
return 'token_wrong'
if not answer_sudo and ensure_token is True:
if not answer_sudo and ensure_token:
return 'token_required'
if not answer_sudo and ensure_token != 'survey_only' and survey_sudo.access_mode == 'token':
if not answer_sudo and survey_sudo.access_mode == 'token':
return 'token_required'
if survey_sudo.users_login_required and request.env.user._is_public():
@ -81,6 +82,9 @@ class Survey(http.Controller):
if (not survey_sudo.page_ids and survey_sudo.questions_layout == 'page_per_section') or not survey_sudo.question_ids:
return 'survey_void'
if answer_sudo and answer_sudo.deadline and answer_sudo.deadline < datetime.now():
return 'answer_deadline'
if answer_sudo and check_partner:
if request.env.user._is_public() and answer_sudo.partner_id and not answer_token:
# answers from public user should not have any partner_id; this indicates probably a cookie issue
@ -89,9 +93,6 @@ class Survey(http.Controller):
# partner mismatch, probably a cookie issue
return 'answer_wrong_user'
if answer_sudo and answer_sudo.deadline and answer_sudo.deadline < datetime.now():
return 'answer_deadline'
return True
def _get_access_data(self, survey_token, answer_token, ensure_token=True, check_partner=True):
@ -101,24 +102,16 @@ class Survey(http.Controller):
: param ensure_token: whether user input existence should be enforced or not(see ``_check_validity``)
: param check_partner: whether the partner of the target answer should be checked (see ``_check_validity``)
"""
survey_sudo, answer_sudo = request.env['survey.survey'].sudo(), request.env['survey.user_input'].sudo()
survey_sudo, answer_sudo = self._fetch_from_access_token(survey_token, answer_token)
has_survey_access, can_answer = False, False
validity_code = self._check_validity(survey_token, answer_token, ensure_token=ensure_token, check_partner=check_partner)
validity_code = self._check_validity(
survey_sudo, answer_sudo, answer_token, ensure_token=ensure_token, check_partner=check_partner)
if validity_code != 'survey_wrong':
survey_sudo, answer_sudo = self._fetch_from_access_token(survey_token, answer_token)
try:
survey_user = survey_sudo.with_user(request.env.user)
survey_user.check_access_rights('read', raise_exception=True)
survey_user.check_access_rule('read')
except:
pass
else:
has_survey_access = True
has_survey_access = survey_sudo.with_user(request.env.user).has_access('read')
can_answer = bool(answer_sudo)
if not can_answer:
can_answer = survey_sudo.access_mode == 'public' or (
has_survey_access and ensure_token == 'survey_only')
can_answer = survey_sudo.access_mode == 'public'
return {
'survey_sudo': survey_sudo,
@ -144,13 +137,15 @@ class Survey(http.Controller):
if answer_sudo.partner_id.user_ids:
answer_sudo.partner_id.signup_cancel()
else:
answer_sudo.partner_id.signup_prepare(expiration=fields.Datetime.now() + relativedelta(days=1))
answer_sudo.partner_id.signup_prepare()
redirect_url = answer_sudo.partner_id._get_signup_url_for_action(url='/survey/start/%s?answer_token=%s' % (survey_sudo.access_token, answer_sudo.access_token))[answer_sudo.partner_id.id]
else:
redirect_url = '/web/login?redirect=%s' % ('/survey/start/%s?answer_token=%s' % (survey_sudo.access_token, answer_sudo.access_token))
return request.render("survey.survey_auth_required", {'survey': survey_sudo, 'redirect_url': redirect_url})
elif error_key == 'answer_deadline' and answer_sudo.access_token:
return request.render("survey.survey_closed_expired", {'survey': survey_sudo})
elif error_key in ['answer_wrong_user', 'token_wrong']:
return request.render("survey.survey_access_error", {'survey': survey_sudo})
return request.redirect("/")
@ -198,14 +193,13 @@ class Survey(http.Controller):
def _prepare_retry_additional_values(self, answer):
return {
'deadline': answer.deadline,
'nickname': answer.nickname,
}
def _prepare_survey_finished_values(self, survey, answer, token=False):
values = {'survey': survey, 'answer': answer}
if token:
values['token'] = token
if survey.scoring_type != 'no_scoring':
values['graph_data'] = json.dumps(answer._prepare_statistics()[answer])
return values
# ------------------------------------------------------------
@ -221,7 +215,7 @@ class Survey(http.Controller):
# Get the current answer token from cookie
answer_from_cookie = False
if not answer_token:
answer_token = request.httprequest.cookies.get('survey_%s' % survey_token)
answer_token = request.cookies.get('survey_%s' % survey_token)
answer_from_cookie = bool(answer_token)
access_data = self._get_access_data(survey_token, answer_token, ensure_token=False)
@ -244,24 +238,29 @@ class Survey(http.Controller):
if not answer_sudo:
try:
survey_sudo.with_user(request.env.user).check_access_rights('read')
survey_sudo.with_user(request.env.user).check_access_rule('read')
survey_sudo.with_user(request.env.user).check_access('read')
except:
return request.redirect("/")
else:
return request.render("survey.survey_403_page", {'survey': survey_sudo})
return request.redirect('/survey/%s/%s' % (survey_sudo.access_token, answer_sudo.access_token))
# When resuming survey, restore language + always enforce that the language is supported by the survey
lang = self._get_lang_with_fallback(answer_sudo.sudo(False))
response = request.redirect(self.env['ir.http']._url_for(f'/survey/{survey_sudo.access_token}', lang.code))
response.set_cookie(f'survey_{survey_sudo.access_token}', answer_sudo.access_token, max_age=60 * 60 * 24)
return response
def _prepare_survey_data(self, survey_sudo, answer_sudo, **post):
""" This method prepares all the data needed for template rendering, in function of the survey user input state.
:param post:
- previous_page_id : come from the breadcrumb or the back button and force the next questions to load
to be the previous ones. """
to be the previous ones.
- next_skipped_page : force the display of next skipped question or page if any."""
data = {
'is_html_empty': is_html_empty,
'survey': survey_sudo,
'answer': answer_sudo,
'skipped_questions': answer_sudo._get_skipped_questions(),
'breadcrumb_pages': [{
'id': page.id,
'title': page.title,
@ -269,16 +268,22 @@ class Survey(http.Controller):
'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False),
'format_date': lambda date: format_date(request.env, date)
}
if answer_sudo.state == 'new':
# Data for the language selector
supported_lang_codes = survey_sudo._get_supported_lang_codes()
data['languages'] = [(lang_code, self.env['res.lang']._get_data(code=lang_code)['name'])
for lang_code in supported_lang_codes]
data['lang_code'] = self._get_lang_with_fallback(answer_sudo.sudo(False)).code
triggering_answers_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values()
if survey_sudo.questions_layout != 'page_per_question':
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = answer_sudo._get_conditional_values()
data.update({
'triggering_answer_by_question': {
question.id: triggering_answer_by_question[question].id for question in triggering_answer_by_question.keys()
if triggering_answer_by_question[question]
'triggering_answers_by_question': {
question.id: triggering_answers.ids
for question, triggering_answers in triggering_answers_by_question.items() if triggering_answers
},
'triggered_questions_by_answer': {
answer.id: triggered_questions_by_answer[answer].ids
for answer in triggered_questions_by_answer.keys()
answer.id: triggered_questions.ids
for answer, triggered_questions in triggered_questions_by_answer.items()
},
'selected_answers': selected_answers.ids
})
@ -306,17 +311,41 @@ class Survey(http.Controller):
return data
if answer_sudo.state == 'in_progress':
next_page_or_question = None
if answer_sudo.is_session_answer:
next_page_or_question = survey_sudo.session_question_id
else:
next_page_or_question = survey_sudo._get_next_page_or_question(
answer_sudo,
answer_sudo.last_displayed_page_id.id if answer_sudo.last_displayed_page_id else 0)
if 'next_skipped_page' in post:
next_page_or_question = answer_sudo._get_next_skipped_page_or_question()
if not next_page_or_question:
next_page_or_question = survey_sudo._get_next_page_or_question(
answer_sudo,
answer_sudo.last_displayed_page_id.id if answer_sudo.last_displayed_page_id else 0)
# fallback to skipped page so that there is a next_page_or_question otherwise this should be a submit
if not next_page_or_question:
next_page_or_question = answer_sudo._get_next_skipped_page_or_question()
if next_page_or_question:
data.update({
'survey_last': survey_sudo._is_last_page_or_question(answer_sudo, next_page_or_question)
})
if answer_sudo.survey_first_submitted:
survey_last = answer_sudo._is_last_skipped_page_or_question(next_page_or_question)
else:
survey_last = survey_sudo._is_last_page_or_question(answer_sudo, next_page_or_question)
values = {'survey_last': survey_last}
# On the last survey page, get the suggested answers which are triggering questions on the following pages
# to dynamically update the survey button to "submit" or "continue" depending on the selected answers.
# NB: Not in the skipped questions flow as conditionals aren't handled.
if not answer_sudo.survey_first_submitted and survey_last and survey_sudo.questions_layout != 'one_page':
pages_or_questions = survey_sudo._get_pages_or_questions(answer_sudo)
following_questions = pages_or_questions.filtered(lambda page_or_question: page_or_question.sequence > next_page_or_question.sequence)
next_page_questions_suggested_answers = next_page_or_question.suggested_answer_ids
if survey_sudo.questions_layout == 'page_per_section':
following_questions = following_questions.question_ids
next_page_questions_suggested_answers = next_page_or_question.question_ids.suggested_answer_ids
values['survey_last_triggering_answers'] = [
answer.id for answer in triggered_questions_by_answer
if answer in next_page_questions_suggested_answers and any(q in following_questions for q in triggered_questions_by_answer[answer])
]
data.update(values)
if answer_sudo.is_session_answer and next_page_or_question.is_time_limited:
data.update({
@ -346,16 +375,19 @@ class Survey(http.Controller):
object at frontend side."""
survey_data = self._prepare_survey_data(survey_sudo, answer_sudo, **post)
IrQweb = request.env['ir.qweb'].with_context(
lang=self.env['res.lang']._get_data(id=answer_sudo.lang_id.id).code
or self._get_lang_with_fallback(answer_sudo.sudo(False)).code)
if answer_sudo.state == 'done':
survey_content = request.env['ir.qweb']._render('survey.survey_fill_form_done', survey_data)
survey_content = IrQweb._render('survey.survey_fill_form_done', survey_data)
else:
survey_content = request.env['ir.qweb']._render('survey.survey_fill_form_in_progress', survey_data)
survey_content = IrQweb._render('survey.survey_fill_form_in_progress', survey_data)
survey_progress = False
if answer_sudo.state == 'in_progress' and not survey_data.get('question', request.env['survey.question']).is_page:
if survey_sudo.questions_layout == 'page_per_section':
page_ids = survey_sudo.page_ids.ids
survey_progress = request.env['ir.qweb']._render('survey.survey_progression', {
survey_progress = IrQweb._render('survey.survey_progression', {
'survey': survey_sudo,
'page_ids': page_ids,
'page_number': page_ids.index(survey_data['page'].id) + (1 if survey_sudo.progression_mode == 'number' else 0)
@ -364,7 +396,7 @@ class Survey(http.Controller):
page_ids = (answer_sudo.predefined_question_ids.ids
if not answer_sudo.is_session_answer and survey_sudo.questions_selection == 'random'
else survey_sudo.question_ids.ids)
survey_progress = request.env['ir.qweb']._render('survey.survey_progression', {
survey_progress = IrQweb._render('survey.survey_progression', {
'survey': survey_sudo,
'page_ids': page_ids,
'page_number': page_ids.index(survey_data['question'].id)
@ -377,14 +409,20 @@ class Survey(http.Controller):
background_image_url = survey_data['page'].background_image_url
return {
'has_skipped_questions': any(answer_sudo._get_skipped_questions()),
'survey_content': survey_content,
'survey_progress': survey_progress,
'survey_navigation': request.env['ir.qweb']._render('survey.survey_navigation', survey_data),
'survey_navigation': IrQweb._render('survey.survey_navigation', survey_data),
'background_image_url': background_image_url
}
@http.route('/survey/<string:survey_token>/<string:answer_token>', type='http', auth='public', website=True)
def survey_display_page(self, survey_token, answer_token, **post):
@http.route([
'/survey/<string:survey_token>',
'/survey/<string:survey_token>/<string:answer_token>',
], type='http', auth='public', website=True)
def survey_display_page(self, survey_token, answer_token=None, **post):
if not answer_token:
answer_token = request.httprequest.cookies.get('survey_%s' % survey_token)
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return self._redirect_with_error(access_data, access_data['validity_code'])
@ -449,48 +487,52 @@ class Survey(http.Controller):
# JSON ROUTES to begin / continue survey (ajax navigation) + Tools
# ----------------------------------------------------------------
@http.route('/survey/begin/<string:survey_token>/<string:answer_token>', type='json', auth='public', website=True)
@http.route('/survey/begin/<string:survey_token>/<string:answer_token>', type='jsonrpc', auth='public', website=True)
def survey_begin(self, survey_token, answer_token, **post):
""" Route used to start the survey user input and display the first survey page. """
""" Route used to start the survey user input and display the first survey page.
Returns an empty dict for the correct answers and the first page html. """
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return {'error': access_data['validity_code']}
return {}, {'error': access_data['validity_code']}
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
if answer_sudo.state != "new":
return {'error': _("The survey has already started.")}
return {}, {'error': _("The survey has already started.")}
if 'lang_code' in post:
answer_sudo.lang_id = self.env['res.lang']._lang_get(post['lang_code'])
answer_sudo._mark_in_progress()
return self._prepare_question_html(survey_sudo, answer_sudo, **post)
return {}, self._prepare_question_html(survey_sudo, answer_sudo, **post)
@http.route('/survey/next_question/<string:survey_token>/<string:answer_token>', type='json', auth='public', website=True)
@http.route('/survey/next_question/<string:survey_token>/<string:answer_token>', type='jsonrpc', auth='public', website=True)
def survey_next_question(self, survey_token, answer_token, **post):
""" Method used to display the next survey question in an ongoing session.
Triggered on all attendees screens when the host goes to the next question. """
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return {'error': access_data['validity_code']}
return {}, {'error': access_data['validity_code']}
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
if answer_sudo.state == 'new' and answer_sudo.is_session_answer:
answer_sudo._mark_in_progress()
return self._prepare_question_html(survey_sudo, answer_sudo, **post)
return {}, self._prepare_question_html(survey_sudo, answer_sudo, **post)
@http.route('/survey/submit/<string:survey_token>/<string:answer_token>', type='json', auth='public', website=True)
@http.route('/survey/submit/<string:survey_token>/<string:answer_token>', type='jsonrpc', auth='public', website=True)
def survey_submit(self, survey_token, answer_token, **post):
""" Submit a page from the survey.
This will take into account the validation errors and store the answers to the questions.
If the time limit is reached, errors will be skipped, answers will be ignored and
survey state will be forced to 'done'"""
survey state will be forced to 'done'.
Also returns the correct answers if the scoring type is 'scoring_with_answers_after_page'."""
# Survey Validation
access_data = self._get_access_data(survey_token, answer_token, ensure_token=True)
if access_data['validity_code'] is not True:
return {'error': access_data['validity_code']}
return {}, {'error': access_data['validity_code']}
survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo']
if answer_sudo.state == 'done':
return {'error': 'unauthorized'}
return {}, {'error': 'unauthorized'}
questions, page_or_question_id = survey_sudo._get_survey_questions(answer=answer_sudo,
page_id=post.get('page_id'),
@ -498,7 +540,7 @@ class Survey(http.Controller):
if not answer_sudo.test_entry and not survey_sudo._has_attempts_left(answer_sudo.partner_id, answer_sudo.email, answer_sudo.invite_token):
# prevent cheating with users creating multiple 'user_input' before their last attempt
return {'error': 'unauthorized'}
return {}, {'error': 'unauthorized'}
if answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached:
if answer_sudo.question_time_limit_reached:
@ -511,7 +553,7 @@ class Survey(http.Controller):
time_limit += timedelta(seconds=10)
if fields.Datetime.now() > time_limit:
# prevent cheating with users blocking the JS timer and taking all their time to answer
return {'error': 'unauthorized'}
return {}, {'error': 'unauthorized'}
errors = {}
# Prepare answers / comment by question, validate and save answers
@ -522,30 +564,51 @@ class Survey(http.Controller):
answer, comment = self._extract_comment_from_answers(question, post.get(str(question.id)))
errors.update(question.validate_question(answer, comment))
if not errors.get(question.id):
answer_sudo.save_lines(question, answer, comment)
answer_sudo._save_lines(question, answer, comment, overwrite_existing=survey_sudo.users_can_go_back or question.save_as_nickname or question.save_as_email)
if errors and not (answer_sudo.survey_time_limit_reached or answer_sudo.question_time_limit_reached):
return {'error': 'validation', 'fields': errors}
return {}, {'error': 'validation', 'fields': errors}
if not answer_sudo.is_session_answer:
answer_sudo._clear_inactive_conditional_answers()
# Get the page questions correct answers if scoring type is scoring after page
correct_answers = {}
if survey_sudo.scoring_type == 'scoring_with_answers_after_page':
scorable_questions = (questions - answer_sudo._get_inactive_conditional_questions()).filtered('is_scored_question')
correct_answers = scorable_questions._get_correct_answers()
if answer_sudo.survey_time_limit_reached or survey_sudo.questions_layout == 'one_page':
answer_sudo._mark_done()
elif 'previous_page_id' in post:
# when going back, save the last displayed to reload the survey where the user left it.
answer_sudo.write({'last_displayed_page_id': post['previous_page_id']})
answer_sudo.last_displayed_page_id = post['previous_page_id']
# Go back to specific page using the breadcrumb. Lines are saved and survey continues
return self._prepare_question_html(survey_sudo, answer_sudo, **post)
return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo, **post)
elif 'next_skipped_page_or_question' in post:
answer_sudo.last_displayed_page_id = page_or_question_id
return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True)
else:
if not answer_sudo.is_session_answer:
next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id)
page_or_question = request.env['survey.question'].sudo().browse(page_or_question_id)
if answer_sudo.survey_first_submitted and answer_sudo._is_last_skipped_page_or_question(page_or_question):
next_page = request.env['survey.question']
else:
next_page = survey_sudo._get_next_page_or_question(answer_sudo, page_or_question_id)
if not next_page:
answer_sudo._mark_done()
if survey_sudo.users_can_go_back and answer_sudo.user_input_line_ids.filtered(
lambda a: a.skipped and a.question_id.constr_mandatory):
answer_sudo.write({
'last_displayed_page_id': page_or_question_id,
'survey_first_submitted': True,
})
return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo, next_skipped_page=True)
else:
answer_sudo._mark_done()
answer_sudo.write({'last_displayed_page_id': page_or_question_id})
answer_sudo.last_displayed_page_id = page_or_question_id
return self._prepare_question_html(survey_sudo, answer_sudo)
return correct_answers, self._prepare_question_html(survey_sudo, answer_sudo)
def _extract_comment_from_answers(self, question, answers):
""" Answers is a custom structure depending of the question type
@ -600,9 +663,9 @@ class Survey(http.Controller):
def survey_print(self, survey_token, review=False, answer_token=None, **post):
'''Display an survey in printable view; if <answer_token> is set, it will
grab the answers of the user_input_id that has <answer_token>.'''
access_data = self._get_access_data(survey_token, answer_token, ensure_token='survey_only', check_partner=False)
access_data = self._get_access_data(survey_token, answer_token, ensure_token=False, check_partner=False)
if access_data['validity_code'] is not True and (
access_data['has_survey_access'] or
not access_data['has_survey_access'] or
access_data['validity_code'] not in ['token_required', 'survey_closed', 'survey_void', 'answer_deadline']):
return self._redirect_with_error(access_data, access_data['validity_code'])
@ -613,9 +676,11 @@ class Survey(http.Controller):
'survey': survey_sudo,
'answer': answer_sudo if survey_sudo.scoring_type != 'scoring_without_answers' else answer_sudo.browse(),
'questions_to_display': answer_sudo._get_print_questions(),
'scoring_display_correction': survey_sudo.scoring_type == 'scoring_with_answers' and answer_sudo,
'scoring_display_correction': survey_sudo.scoring_type in ['scoring_with_answers', 'scoring_with_answers_after_page'] and answer_sudo,
'format_datetime': lambda dt: format_datetime(request.env, dt, dt_format=False),
'format_date': lambda date: format_date(request.env, date),
'graph_data': json.dumps(answer_sudo._prepare_statistics()[answer_sudo])
if answer_sudo and survey_sudo.scoring_type in ['scoring_with_answers', 'scoring_with_answers_after_page'] else False,
})
@http.route('/survey/<model("survey.survey"):survey>/certification_preview', type="http", auth="user", website=True)
@ -713,55 +778,153 @@ class Survey(http.Controller):
('Content-Disposition', report_content_disposition),
])
def _get_user_input_domain(self, survey, line_filter_domain, **post):
user_input_domain = ['&', ('test_entry', '=', False), ('survey_id', '=', survey.id)]
if line_filter_domain:
matching_line_ids = request.env['survey.user_input.line'].sudo().search(line_filter_domain).ids
user_input_domain = expression.AND([
[('user_input_line_ids', 'in', matching_line_ids)],
user_input_domain
])
def _get_results_page_user_input_domain(self, survey, **post):
user_input_domains = []
if post.get('finished'):
user_input_domain = expression.AND([[('state', '=', 'done')], user_input_domain])
user_input_domains.append(Domain('state', '=', 'done'))
else:
user_input_domain = expression.AND([[('state', '!=', 'new')], user_input_domain])
user_input_domains.append(Domain('state', '!=', 'new'))
if post.get('failed'):
user_input_domain = expression.AND([[('scoring_success', '=', False)], user_input_domain])
user_input_domains.append(Domain('scoring_success', '=', False))
elif post.get('passed'):
user_input_domain = expression.AND([[('scoring_success', '=', True)], user_input_domain])
user_input_domains.append(Domain('scoring_success', '=', True))
return user_input_domain
user_input_domains.extend((Domain('test_entry', '=', False), Domain('survey_id', '=', survey.id)))
return Domain.AND(user_input_domains)
def _extract_filters_data(self, survey, post):
search_filters = []
line_filter_domain, line_choices = [], []
for data in post.get('filters', '').split('|'):
try:
row_id, answer_id = (int(item) for item in data.split(','))
except:
pass
else:
if row_id and answer_id:
line_filter_domain = expression.AND([
['&', ('matrix_row_id', '=', row_id), ('suggested_answer_id', '=', answer_id)],
line_filter_domain
])
answers = request.env['survey.question.answer'].browse([row_id, answer_id])
elif answer_id:
line_choices.append(answer_id)
answers = request.env['survey.question.answer'].browse([answer_id])
if answer_id:
question_id = answers[0].matrix_question_id or answers[0].question_id
search_filters.append({
'row_id': row_id,
'answer_id': answer_id,
'question': question_id.title,
'answers': '%s%s' % (answers[0].value, ': %s' % answers[1].value if len(answers) > 1 else '')
})
if line_choices:
line_filter_domain = expression.AND([[('suggested_answer_id', 'in', line_choices)], line_filter_domain])
""" Extracts the filters from the URL to returns the related user_input_lines and
the parameters used to render/remove the filters on the results page (search_filters).
user_input_domain = self._get_user_input_domain(survey, line_filter_domain, **post)
user_input_lines = request.env['survey.user_input'].sudo().search(user_input_domain).mapped('user_input_line_ids')
The matching user_input_lines are all the lines tied to the user inputs which respect
the survey base domain and which have lines matching all the filters.
For example, with the filter 'Where do you live?|Brussels', we need to display ALL the lines
of the survey user inputs which have answered 'Brussels' to this question.
:return (recordset, List[dict]): all matching user input lines, each search filter data
"""
user_input_line_subdomains = []
search_filters = []
answer_by_column, user_input_lines_ids = self._get_filters_from_post(post)
# Matrix, Multiple choice, Simple choice filters
if answer_by_column:
answer_ids, row_ids = [], []
for answer_column_id, answer_row_ids in answer_by_column.items():
answer_ids.append(answer_column_id)
row_ids += answer_row_ids
answers_and_rows = request.env['survey.question.answer'].browse(answer_ids+row_ids)
# For performance, accessing 'a.matrix_question_id' caches all useful fields of the
# answers and rows records, avoiding unnecessary queries.
answers = answers_and_rows.filtered(lambda a: not a.matrix_question_id)
for answer in answers:
if not answer_by_column[answer.id]:
# Simple/Multiple choice
user_input_line_subdomains.append(answer._get_answer_matching_domain())
search_filters.append(self._prepare_search_filter_answer(answer))
else:
# Matrix
for row_id in answer_by_column[answer.id]:
row = answers_and_rows.filtered(lambda answer_or_row: answer_or_row.id == row_id)
user_input_line_subdomains.append(answer._get_answer_matching_domain(row_id))
search_filters.append(self._prepare_search_filter_answer(answer, row))
# Char_box, Text_box, Numerical_box, Date, Datetime filters
if user_input_lines_ids:
user_input_lines = request.env['survey.user_input.line'].browse(user_input_lines_ids)
for input_line in user_input_lines:
user_input_line_subdomains.append(input_line._get_answer_matching_domain())
search_filters.append(self._prepare_search_filter_input_line(input_line))
# Compute base domain
user_input_domain = self._get_results_page_user_input_domain(survey, **post)
# Add filters domain to the base domain
if user_input_line_subdomains:
all_required_lines_domains = [
[('user_input_line_ids', 'in', request.env['survey.user_input.line'].sudo()._search(subdomain))]
for subdomain in user_input_line_subdomains
]
user_input_domain = Domain.AND([user_input_domain, *all_required_lines_domains])
# Get the matching user input lines
user_inputs_query = request.env['survey.user_input'].sudo()._search(user_input_domain)
user_input_lines = request.env['survey.user_input.line'].search([('user_input_id', 'in', user_inputs_query)])
return user_input_lines, search_filters
def _get_filters_from_post(self, post):
""" Extract the filters from post depending on the model that needs to be called to retrieve the filtered answer data.
Simple choice and multiple choice question types are mapped onto empty row_id.
Input/output example with respectively matrix, simple_choice and char_box filters:
input: 'A,1,24|A,0,13|L,0,36'
output:
answer_by_column: {24: [1], 13: []}
user_input_lines_ids: [36]
* Model short key = 'A' : Match a `survey.question.answer` record (simple_choice, multiple_choice, matrix)
* Model short key = 'L' : Match a `survey.user_input.line` record (char_box, text_box, numerical_box, date, datetime)
:rtype: (collections.defaultdict[int, list[int]], list[int])
"""
answer_by_column = defaultdict(list)
user_input_lines_ids = []
for data in post.get('filters', '').split('|'):
if not data:
break
model_short_key, row_id, answer_id = data.split(',')
row_id, answer_id = int(row_id), int(answer_id)
if model_short_key == 'A':
if row_id:
answer_by_column[answer_id].append(row_id)
else:
answer_by_column[answer_id] = []
elif model_short_key == 'L' and not row_id:
user_input_lines_ids.append(answer_id)
return answer_by_column, user_input_lines_ids
def _prepare_search_filter_answer(self, answer, row=False):
""" Format parameters used to render/remove this filter on the results page."""
return {
'question_id': answer.question_id.id,
'question': answer.question_id.title,
'row_id': row.id if row else 0,
'answer': '%s : %s' % (row.value, answer.value) if row else answer.value,
'model_short_key': 'A',
'record_id': answer.id,
}
def _prepare_search_filter_input_line(self, user_input_line):
""" Format parameters used to render/remove this filter on the results page."""
return {
'question_id': user_input_line.question_id.id,
'question': user_input_line.question_id.title,
'row_id': 0,
'answer': user_input_line._get_answer_value(),
'model_short_key': 'L',
'record_id': user_input_line.id,
}
def _get_lang_with_fallback(self, user_input):
""" :return: the most suitable language for the user that is supported by the survey. """
user_input.ensure_one()
user_input_sudo = user_input.sudo()
if user_input_sudo.lang_id:
return user_input_sudo.lang_id.sudo(False)
lang_code = self.env.context.get('lang') or self.env['ir.http']._get_default_lang().code
ResLang = self.env['res.lang']
supported_lang_codes = user_input_sudo.survey_id._get_supported_lang_codes()
supported_lang_codes_set = set(supported_lang_codes)
if lang_code in supported_lang_codes_set:
return ResLang._lang_get(lang_code)
# Take the first frontend language supported by the survey and if there are none, the first survey language
return ResLang._lang_get(
next((
lang.code
for lang in self.env['res.lang']._get_frontend().values()
if lang['code'] in supported_lang_codes_set
), supported_lang_codes[0]))

View file

@ -65,7 +65,7 @@ class UserInputSession(http.Controller):
# Note that at this stage survey.session_state can be False meaning that the survey has ended (session closed)
return request.render('survey.user_input_session_manage', self._prepare_manage_session_values(survey))
@http.route('/survey/session/next_question/<string:survey_token>', type='json', auth='user', website=True)
@http.route('/survey/session/next_question/<string:survey_token>', type='jsonrpc', auth='user', website=True)
def survey_session_next_question(self, survey_token, go_back=False, **kwargs):
""" This route is called when the host goes to the next question of the session.
@ -120,7 +120,7 @@ class UserInputSession(http.Controller):
else:
return {}
@http.route('/survey/session/results/<string:survey_token>', type='json', auth='user', website=True)
@http.route('/survey/session/results/<string:survey_token>', type='jsonrpc', auth='user', website=True)
def survey_session_results(self, survey_token, **kwargs):
""" This route is called when the host shows the current question's results.
@ -141,7 +141,7 @@ class UserInputSession(http.Controller):
return self._prepare_question_results_values(survey, user_input_lines)
@http.route('/survey/session/leaderboard/<string:survey_token>', type='json', auth='user', website=True)
@http.route('/survey/session/leaderboard/<string:survey_token>', type='jsonrpc', auth='user', website=True)
def survey_session_leaderboard(self, survey_token, **kwargs):
""" This route is called when the host shows the current question's attendees leaderboard.
@ -182,7 +182,7 @@ class UserInputSession(http.Controller):
dict(**survey_error, session_code=session_code))
return request.redirect(survey.get_start_url())
@http.route('/survey/check_session_code/<string:session_code>', type='json', auth='public', website=True)
@http.route('/survey/check_session_code/<string:session_code>', type='jsonrpc', auth='public', website=True)
def survey_check_session_code(self, session_code):
""" Checks if the given code is matching a survey session_code.
If yes, redirect to /s/code route.
@ -206,6 +206,14 @@ class UserInputSession(http.Controller):
'is_session_closed': not survey.session_state,
}
if is_last_question:
_, triggered_questions_by_answer = survey._get_conditional_maps()
next_question = survey.session_question_id
values['survey_last_triggering_answers'] = [
answer.id for answer in triggered_questions_by_answer
if answer in next_question.suggested_answer_ids and any(q.sequence > next_question.sequence for q in triggered_questions_by_answer[answer])
]
values.update(self._prepare_question_results_values(survey, request.env['survey.user_input.line']))
return values
@ -224,9 +232,14 @@ class UserInputSession(http.Controller):
(and not the id or anything else we can identify).
In other words, we need to know if the answer at index 2 is correct or not.
- answer_count
The number of answers to the current question. """
The number of answers to the current question.
- selected_answers
The current question selected answers.
"""
question = survey.session_question_id
if not question:
return {}
answers_validity = []
if (any(answer.is_correct for answer in question.suggested_answer_ids)):
answers_validity = [answer.is_correct for answer in question.suggested_answer_ids]
@ -248,4 +261,5 @@ class UserInputSession(http.Controller):
'answers_validity': json.dumps(answers_validity),
'answer_count': survey.session_question_answer_count,
'attendees_count': survey.session_answer_count,
'selected_answers': user_input_lines.suggested_answer_id.ids,
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_survey_print" model="ir.actions.server">
<field name="name">Print Survey</field>
<field name="model_id" ref="survey.model_survey_survey"/>
<field name="binding_model_id" ref="survey.model_survey_survey" />
<field name="binding_view_types">form</field>
<field name="state">code</field>
<field name="code">
if record:
action = record.action_print_survey()
</field>
</record>
</odoo>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="mt_survey_user_input_completed" model="mail.message.subtype">
<field name="name">Participation completed</field>
<field name="res_model">survey.user_input</field>
<field name="default" eval="False"/>
<field name="description">Participation completed.</field>
<field name="hidden" eval="True"/>
</record>
<record id="mt_survey_survey_user_input_completed" model="mail.message.subtype">
<field name="name">Participation completed</field>
<field name="res_model">survey.survey</field>
<field name="default" eval="False"/>
<field name="hidden" eval="False"/>
<field name="description">New participation completed.</field>
<field name="parent_id" ref="mt_survey_user_input_completed"/>
<field name="relation_field">survey_id</field>
</record>
</odoo>

View file

@ -6,7 +6,8 @@
<field name="model_id" ref="model_survey_user_input" />
<field name="subject">Participate to {{ object.survey_id.display_name }} survey</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ (object.partner_id.email_formatted or object.email) }}</field>
<field name="email_to" eval="False"/>
<field name="use_default_to" eval="True"/>
<field name="description">Sent to participant when you share a survey</field>
<field name="body_html" type="html">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
@ -20,7 +21,7 @@
</t>
<div style="margin: 16px 0px 16px 0px;">
<a t-att-href="(object.get_start_url())"
style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
t-attf-style="background-color: {{user.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{user.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
<t t-if="object.survey_id.certification">
Start Certification
</t>
@ -41,7 +42,6 @@
</p>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
@ -51,7 +51,8 @@
<field name="model_id" ref="survey.model_survey_user_input"/>
<field name="subject">Certification: {{ object.survey_id.display_name }}</field>
<field name="email_from">{{ (object.survey_id.create_uid.email_formatted or user.email_formatted or user.company_id.catchall_formatted) }}</field>
<field name="email_to">{{ (object.partner_id.email_formatted or object.email) }}</field>
<field name="email_to" eval="False"/>
<field name="use_default_to" eval="True"/>
<field name="description">Sent to participant if they succeeded the certification</field>
<field name="body_html" type="html">
<div style="background:#F0F0F0;color:#515166;padding:10px 0px;font-family:Arial,Helvetica,sans-serif;font-size:14px;">
@ -59,7 +60,9 @@
<tbody>
<tr><td>
<!-- We use the logo of the company that created the survey (to handle multi company cases) -->
<a href="/"><img t-attf-src="/logo.png?company={{ object.survey_id.create_uid.company_id.id }}" style="vertical-align:baseline;max-width:100px;" /></a>
<a href="/"><img t-if="not object.survey_id.create_uid.company_id.uses_default_logo"
t-attf-src="/logo.png?company={{ object.survey_id.create_uid.company_id.id }}"
style="vertical-align:baseline;max-width:100px;" /></a>
</td><td style="text-align:right;vertical-align:middle;">
Certification: <t t-out="object.survey_id.display_name or ''">Feedback Form</t>
</td></tr>
@ -74,15 +77,13 @@
<strong t-out="object.survey_id.display_name or ''">Furniture Creation</strong>
certification
</p>
<p>Congratulations for passing the test with a score of <strong t-out="object.scoring_percentage"/>% !</p>
<p>Congratulations for passing the test with a score of <strong t-out="object.scoring_percentage"/>%!</p>
</td></tr>
</tbody>
</table>
</div>
</field>
<field name="report_template" ref="certification_report"/>
<field name="report_name">Certification Document</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="report_template_ids" eval="[(4, ref('survey.certification_report'))]"/>
<field name="auto_delete" eval="True"/>
</record>
</data>

View file

@ -1,9 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Grant survey permissions to demo user -->
<record id="base.user_demo" model="res.users">
<field eval="[(4, ref('group_survey_manager'))]" name="groups_id"/>
<field name="group_ids" eval="[(4, ref('survey.group_survey_user'))]"/>
</record>
<record id="base.default_user_group" model="res.groups">
<field name="implied_ids" eval="[(4, ref('survey.group_survey_user'))]"/>
</record>
</data>
</odoo>

View file

@ -21,6 +21,7 @@
<field name="certification_give_badge">True</field>
<field name="certification_badge_id" ref="vendor_certification_badge"/>
<field name="background_image" type="base64" file="survey/static/src/img/survey_background_2.jpg"/>
<field name="lang_ids" eval="False"/>
</record>
<!-- Page 1 -->
<record model="survey.question" id="vendor_certification_page_1">
@ -336,7 +337,7 @@
<field name="is_scored_question" eval="True" />
<field name="answer_datetime">2021-01-07 00:00:01</field>
<field name="answer_score">1.0</field>
<field name="question_placeholder">Beware of leap years !</field>
<field name="question_placeholder">Beware of leap years!</field>
</record>
<!-- Question and predefined answer 12 -->
<record model="survey.question" id="vendor_certification_page_3_question_4">

View file

@ -12,9 +12,10 @@
<field name="is_time_limited" >limited</field>
<field name="time_limit" >10.0</field>
<field name="questions_layout">page_per_question</field>
<field name="lang_ids" eval="False"/>
<field name="description" type="html">
<p>Choose your favourite subject and show how good you are. Ready ?</p></field>
<field name="background_image" type="base64" file="survey/static/src/img/burger_quiz_background.jpg"/>
<p>Choose your favourite subject and show how good you are. Ready?</p></field>
<field name="background_image" type="base64" file="survey/static/src/img/burger_quiz_background.webp"/>
</record>
<!-- Page 1: Start -->
@ -67,9 +68,7 @@
<field name="title">How long is the White Nile river?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
</record>
<record id="survey_demo_burger_quiz_p2_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q1"/>
@ -93,12 +92,10 @@
<record id="survey_demo_burger_quiz_p2_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">120</field>
<field name="title">What is the biggest city in the world ?</field>
<field name="title">What is the biggest city in the world?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
</record>
<record id="survey_demo_burger_quiz_p2_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q2"/>
@ -126,12 +123,10 @@
<record id="survey_demo_burger_quiz_p2_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">130</field>
<field name="title">Which is the highest volcano in Europe ?</field>
<field name="title">Which is the highest volcano in Europe?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug1"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug1'))]"/>
</record>
<record id="survey_demo_burger_quiz_p2_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p2_q3"/>
@ -167,12 +162,10 @@
<record id="survey_demo_burger_quiz_p3_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">210</field>
<field name="title">When did Genghis Khan die ?</field>
<field name="title">When did Genghis Khan die?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
</record>
<record id="survey_demo_burger_quiz_p3_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q1"/>
@ -195,12 +188,10 @@
<record id="survey_demo_burger_quiz_p3_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">220</field>
<field name="title">Who is the architect of the Great Pyramid of Giza ?</field>
<field name="title">Who is the architect of the Great Pyramid of Giza?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
</record>
<record id="survey_demo_burger_quiz_p3_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q2"/>
@ -228,12 +219,10 @@
<record id="survey_demo_burger_quiz_p3_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">230</field>
<field name="title">How many years did the 100 years war last ?</field>
<field name="title">How many years did the 100 years war last?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug2"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug2'))]"/>
</record>
<record id="survey_demo_burger_quiz_p3_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p3_q3"/>
@ -269,12 +258,10 @@
<record id="survey_demo_burger_quiz_p4_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">310</field>
<field name="title">Who received a Nobel prize in Physics for the discovery of neutrino oscillations, which shows that neutrinos have mass ?</field>
<field name="title">Who received a Nobel prize in Physics for the discovery of neutrino oscillations, which shows that neutrinos have mass?</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
</record>
<record id="survey_demo_burger_quiz_p4_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q1"/>
@ -304,12 +291,10 @@
<record id="survey_demo_burger_quiz_p4_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">320</field>
<field name="title">What is, approximately, the critical mass of plutonium-239 ?</field>
<field name="title">What is, approximately, the critical mass of plutonium-239?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
</record>
<record id="survey_demo_burger_quiz_p4_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q2"/>
@ -337,12 +322,10 @@
<record id="survey_demo_burger_quiz_p4_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">330</field>
<field name="title">Can Humans ever directly see a photon ?</field>
<field name="title">Can Humans ever directly see a photon?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug3"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug3'))]"/>
</record>
<record id="survey_demo_burger_quiz_p4_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
@ -354,7 +337,7 @@
<record id="survey_demo_burger_quiz_p4_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p4_q3"/>
<field name="sequence">2</field>
<field name="value">No, it's to small for the human eye.</field>
<field name="value">No, it's too small for the human eye.</field>
</record>
<!-- Page 5 : Art & Culture -->
@ -368,12 +351,10 @@
<record id="survey_demo_burger_quiz_p5_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">410</field>
<field name="title">Which Musician is not in the 27th Club ?</field>
<field name="title">Which Musician is not in the 27th Club?</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
</record>
<record id="survey_demo_burger_quiz_p5_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q1"/>
@ -403,12 +384,10 @@
<record id="survey_demo_burger_quiz_p5_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_burger_quiz"/>
<field name="sequence">420</field>
<field name="title">Which painting/drawing was not made by Pablo Picasso ?</field>
<field name="title">Which painting/drawing was not made by Pablo Picasso?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
</record>
<record id="survey_demo_burger_quiz_p5_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q2"/>
@ -443,9 +422,7 @@
<field name="title">Which quote is from Jean-Claude Van Damme</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="is_conditional" eval="True"/>
<field name="triggering_question_id" ref="survey_demo_burger_quiz_p1_q1"/>
<field name="triggering_answer_id" ref="survey_demo_burger_quiz_p1_q1_sug4"/>
<field name="triggering_answer_ids" eval="[Command.link(ref('survey.survey_demo_burger_quiz_p1_q1_sug4'))]"/>
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
@ -462,7 +439,7 @@
<record id="survey_demo_burger_quiz_p5_q3_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
<field name="sequence">3</field>
<field name="value">I've been noticing gravity since I was very young !</field> <!-- Cameron Diaz -->
<field name="value">I've been noticing gravity since I was very young!</field> <!-- Cameron Diaz -->
</record>
<record id="survey_demo_burger_quiz_p5_q3_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_burger_quiz_p5_q3"/>
@ -470,4 +447,111 @@
<field name="value">I actually don't like thinking. I think people think I like to think a lot. And I don't. I do not like to think at all.</field> <!-- Kanye West -->
</record>
<!-- Multiple triggers -->
<record id="survey_demo_food_preferences" model="survey.survey">
<field name="title">Food Preferences</field>
<field name="survey_type">survey</field>
<field name="access_token">foodpref-eren-ces1-abcd-344ca2tgb31e</field>
<field name="user_id" ref="base.user_demo"/>
<field name="access_mode">public</field>
<field name="questions_layout">one_page</field>
<field name="lang_ids" eval="False"/>
<field name="description" type="html">
<p>Please give us your preferences for this event's dinner!</p>
</field>
<field name="description_done" type="html">
<p>Got it!</p>
<p>See you soon!</p>
</field>
</record>
<record id="survey_demo_food_preferences_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">1</field>
<field name="title">Are you vegetarian?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_food_preferences_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q1"/>
<field name="sequence">1</field>
<field name="value">Yes</field>
</record>
<record id="survey_demo_food_preferences_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q1"/>
<field name="sequence">2</field>
<field name="value">No</field>
</record>
<record id="survey_demo_food_preferences_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q1"/>
<field name="sequence">3</field>
<field name="value">It depends</field>
</record>
<record id="survey_demo_food_preferences_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">2</field>
<field name="title">Would you prefer a veggie meal if possible?</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="triggering_answer_ids" eval="[
Command.link(ref('survey.survey_demo_food_preferences_q1_sug3')),
]"/>
</record>
<record id="survey_demo_food_preferences_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q2"/>
<field name="sequence">1</field>
<field name="value">Yes</field>
</record>
<record id="survey_demo_food_preferences_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q2"/>
<field name="sequence">2</field>
<field name="value">No</field>
</record>
<record id="survey_demo_food_preferences_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">3</field>
<field name="title">Choose your green meal</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="triggering_answer_ids" eval="[
Command.link(ref('survey.survey_demo_food_preferences_q1_sug1')),
Command.link(ref('survey.survey_demo_food_preferences_q2_sug1')),
]"/>
</record>
<record id="survey_demo_food_preferences_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q3"/>
<field name="sequence">1</field>
<field name="value">Vegetarian pizza</field>
</record>
<record id="survey_demo_food_preferences_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q3"/>
<field name="sequence">2</field>
<field name="value">Vegetarian burger</field>
</record>
<record id="survey_demo_food_preferences_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_food_preferences"/>
<field name="sequence">4</field>
<field name="title">Choose your meal</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="triggering_answer_ids" eval="[
Command.link(ref('survey.survey_demo_food_preferences_q1_sug2')),
Command.link(ref('survey.survey_demo_food_preferences_q2_sug2')),
]"/>
</record>
<record id="survey_demo_food_preferences_q4_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q4"/>
<field name="sequence">1</field>
<field name="value">Steak with french fries</field>
</record>
<record id="survey_demo_food_preferences_q4_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_food_preferences_q4"/>
<field name="sequence">2</field>
<field name="value">Fish</field>
</record>
</data></odoo>

View file

@ -8,6 +8,7 @@
<field name="access_mode">public</field>
<field name="users_can_go_back" eval="True" />
<field name="questions_layout">page_per_section</field>
<field name="lang_ids" eval="False"/>
<field name="description" type="html">
<p>This survey allows you to give a feedback about your experience with our products.
Filling it helps us improving your experience.</p></field>
@ -26,7 +27,7 @@
<record model="survey.question" id="survey_feedback_p1_q1">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">2</field>
<field name="title">Where do you live ?</field>
<field name="title">Where do you live?</field>
<field name="question_type">char_box</field>
<field name="question_placeholder">Brussels</field>
<field name="constr_mandatory" eval="False"/>
@ -34,14 +35,14 @@
<record model="survey.question" id="survey_feedback_p1_q2">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">3</field>
<field name="title">When is your date of birth ?</field>
<field name="title">When is your date of birth?</field>
<field name="question_type">date</field>
<field name="constr_mandatory" eval="False"/>
</record>
<record model="survey.question" id="survey_feedback_p1_q3">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">4</field>
<field name="title">How frequently do you buy products online ?</field>
<field name="title">How frequently do you buy products online?</field>
<field name="question_type">simple_choice</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="True"/>
@ -70,7 +71,7 @@
<record model="survey.question" id="survey_feedback_p1_q4">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">5</field>
<field name="title">How many times did you order products on our website ?</field>
<field name="title">How many times did you order products on our website?</field>
<field name="question_type">numerical_box</field>
<field name="constr_mandatory" eval="True"/>
</record>
@ -88,7 +89,7 @@
<record model="survey.question" id="survey_feedback_p2_q1">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">7</field>
<field name="title">Which of the following words would you use to describe our products ?</field>
<field name="title">Which of the following words would you use to describe our products?</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="comments_allowed" eval="True"/>
@ -137,7 +138,7 @@
<record model="survey.question" id="survey_feedback_p2_q2">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">8</field>
<field name="title">What do you think about our new eCommerce ?</field>
<field name="title">What do you think about our new eCommerce?</field>
<field name="question_type">matrix</field>
<field name="matrix_subtype">multiple</field>
<field name="constr_mandatory" eval="True"/>
@ -190,7 +191,7 @@
<record model="survey.question" id="survey_feedback_p2_q3">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">9</field>
<field name="title">Do you have any other comments, questions, or concerns ?</field>
<field name="title">Do you have any other comments, questions, or concerns?</field>
<field name="question_type">text_box</field>
<field name="constr_mandatory" eval="False"/>
</record>

View file

@ -240,7 +240,7 @@
<field name="user_input_id" ref="survey_answer_3"/>
<field name="question_id" ref="survey_feedback_p2_q3"/>
<field name="answer_type">text_box</field>
<field name="value_text_box">The customizable desk received is not the one I ordered on your website and the quality is very poor ! Really disappointed.</field>
<field name="value_text_box">The customizable desk received is not the one I ordered on your website and the quality is very poor! Really disappointed.</field>
</record>
</data></odoo>

View file

@ -1,576 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="survey_demo_quiz" model="survey.survey">
<field name="title">Quiz about our Company</field>
<field name="access_token">b137640d-9876-1234-abcd-344ca256531e</field>
<field name="user_id" ref="base.user_admin"/>
<field name="access_mode">public</field>
<field name="users_can_go_back" eval="False"/>
<field name="scoring_type">scoring_with_answers</field>
<field name="scoring_success_min">55</field>
<field name="questions_layout">page_per_question</field>
<field name="description" type="html">
<p>This small quiz will test your knowledge about our Company. Be prepared !</p></field>
<field name="background_image" type="base64" file="survey/static/src/img/survey_background.jpg"/>
</record>
<!-- Page 1: general informations -->
<record id="survey_demo_quiz_p1" model="survey.question">
<field name="title">Who are you ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">1</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
<field name="description" type="html">
<p>Some general information about you. It will be used internally for statistics only.</p></field>
</record>
<record id="survey_demo_quiz_p1_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">2</field>
<field name="title">What is your email ?</field>
<field name="question_type">char_box</field>
<field name="constr_mandatory" eval="True"/>
<field name="validation_email" eval="True"/>
<field name="save_as_email" eval="True"/>
<field name="question_placeholder">ex@mple.com</field>
</record>
<record id="survey_demo_quiz_p1_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">3</field>
<field name="title">What is your nickname ?</field>
<field name="question_type">char_box</field>
<field name="question_placeholder">Don't be shy, be wild!</field>
<field name="constr_mandatory" eval="True"/>
<field name="save_as_nickname" eval="True"/>
</record>
<record id="survey_demo_quiz_p1_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">4</field>
<field name="title">Where are you from ?</field>
<field name="question_type">char_box</field>
<field name="question_placeholder">Brussels, Belgium</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p1_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">5</field>
<field name="title">How old are you ?</field>
<field name="description" type="html"><p>Just to categorize your answers, don't worry.</p></field>
<field name="question_type">numerical_box</field>
<field name="constr_mandatory" eval="True"/>
</record>
<!-- Page 2: quiz about company -->
<record id="survey_demo_quiz_p2" model="survey.question">
<field name="title">Our Company in a few questions ...</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">10</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
<field name="description" type="html">
<p>Some questions about our company. Do you really know us?</p></field>
</record>
<record id="survey_demo_quiz_p2_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">11</field>
<field name="title">When is Mitchell Admin born ?</field>
<field name="description" type="html"><span>Our famous Leader !</span></field>
<field name="question_type">date</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p2_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">12</field>
<field name="title">When did precisely Marc Demo crop its first apple tree ?</field>
<field name="question_type">datetime</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p2_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">13</field>
<field name="title">Give the list of all types of wood we sell.</field>
<field name="question_type">text_box</field>
<field name="constr_mandatory" eval="False"/>
</record>
<!-- Page 3: quiz about fruits and vegetables -->
<record id="survey_demo_quiz_p3" model="survey.question">
<field name="title">Fruits and vegetables</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">20</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
<field name="description" type="html">
<p>An apple a day keeps the doctor away.</p></field>
</record>
<record id="survey_demo_quiz_p3_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">21</field>
<field name="title">Which category does a tomato belong to</field>
<field name="description" type="html"><span>"Red" is not a category, I know what you are trying to do ;)</span></field>
<field name="question_type">simple_choice</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="True"/>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p3_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="sequence">1</field>
<field name="value">Fruits</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="sequence">2</field>
<field name="value">Vegetables</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_quiz_p3_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="sequence">3</field>
<field name="value">Space stations</field>
</record>
<record id="survey_demo_quiz_p3_q2" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">22</field>
<field name="title">Which of the following would you use to pollinate</field>
<field name="question_type">simple_choice</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="False"/>
<field name="constr_mandatory" eval="True"/>
<field name="is_time_limited" eval="True"/>
<field name="time_limit">15</field>
</record>
<record id="survey_demo_quiz_p3_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="sequence">1</field>
<field name="value">Bees</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="sequence">2</field>
<field name="value">Dogs</field>
</record>
<record id="survey_demo_quiz_p3_q2_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="sequence">3</field>
<field name="value">Mooses</field>
</record>
<record id="survey_demo_quiz_p3_q3" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">23</field>
<field name="title">Select trees that made more than 20K sales this year</field>
<field name="description" type="html"><span>Our sales people have an advantage, but you can do it !</span></field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="False"/>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="True"/>
<field name="is_time_limited" eval="True"/>
<field name="time_limit">20</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">1</field>
<field name="value">Apple Trees</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">2</field>
<field name="value">Lemon Trees</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">3</field>
<field name="value">Baobab Trees</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q3_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="sequence">4</field>
<field name="value">Cookies</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q4" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">24</field>
<field name="title">A "Citrus" could give you ...</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="False"/>
<field name="is_time_limited" eval="True"/>
<field name="time_limit">20</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">1</field>
<field name="value">Pomelos</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">2</field>
<field name="value">Grapefruits</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">3</field>
<field name="value">Cosmic rays</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q4_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="sequence">4</field>
<field name="value">Bricks</field>
<field name="answer_score">-10</field>
</record>
<record id="survey_demo_quiz_p3_q5" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">25</field>
<field name="title">How often should you water those plants</field>
<field name="question_type">matrix</field>
<field name="matrix_subtype">simple</field>
<field name="constr_mandatory" eval="True"/>
<field name="comments_allowed" eval="True"/>
</record>
<record id="survey_demo_quiz_p3_q5_row1" model="survey.question.answer">
<field name="matrix_question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="sequence">1</field>
<field name="value">Cactus</field>
</record>
<record id="survey_demo_quiz_p3_q5_row2" model="survey.question.answer">
<field name="matrix_question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="sequence">2</field>
<field name="value">Ficus</field>
</record>
<record id="survey_demo_quiz_p3_q5_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="sequence">1</field>
<field name="value">Once a month</field>
</record>
<record id="survey_demo_quiz_p3_q5_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="sequence">2</field>
<field name="value">Once a week</field>
</record>
<record id="survey_demo_quiz_p3_q6" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">26</field>
<field name="title">When do you harvest those fruits</field>
<field name="description" type="html"><span>Best time to do it, is the right time to do it.</span></field>
<field name="question_type">matrix</field>
<field name="matrix_subtype">multiple</field>
<field name="constr_mandatory" eval="True"/>
<field name="comments_allowed" eval="False"/>
</record>
<record id="survey_demo_quiz_p3_q6_row1" model="survey.question.answer">
<field name="matrix_question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">1</field>
<field name="value">Apples</field>
</record>
<record id="survey_demo_quiz_p3_q6_row2" model="survey.question.answer">
<field name="matrix_question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">2</field>
<field name="value">Strawberries</field>
</record>
<record id="survey_demo_quiz_p3_q6_row3" model="survey.question.answer">
<field name="matrix_question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">3</field>
<field name="value">Clementine</field>
</record>
<record id="survey_demo_quiz_p3_q6_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">1</field>
<field name="value">Spring</field>
</record>
<record id="survey_demo_quiz_p3_q6_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">2</field>
<field name="value">Summer</field>
</record>
<record id="survey_demo_quiz_p3_q6_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">3</field>
<field name="value">Autumn</field>
</record>
<record id="survey_demo_quiz_p3_q6_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="sequence">4</field>
<field name="value">Winter</field>
</record>
<!-- Page 4: Trees -->
<record id="survey_demo_quiz_p4" model="survey.question">
<field name="title">Trees</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">30</field>
<field name="is_page" eval="True"/>
<field name="question_type" eval="False"/>
<field name="description" type="html">
<p>
We like to say that the apple doesn't fall far from the tree, so here are trees.
</p>
</field>
</record>
<record id="survey_demo_quiz_p4_q1" model="survey.question">
<field name="title">Dogwood is from which family of trees ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">31</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p4_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="sequence">1</field>
<field name="value">Pinaceae</field>
<field name="value_image" type="base64" file="survey/static/img/pinaceae.jpg"/>
</record>
<record id="survey_demo_quiz_p4_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="sequence">2</field>
<field name="value">Ulmaceae</field>
<field name="value_image" type="base64" file="survey/static/img/ulmaceae.jpg"/>
</record>
<record id="survey_demo_quiz_p4_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="sequence">3</field>
<field name="value">Cornaceae</field>
<field name="value_image" type="base64" file="survey/static/img/cornaceae.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p4_q1_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="sequence">4</field>
<field name="value">Salicaceae</field>
<field name="value_image" type="base64" file="survey/static/img/salicaceae.jpg"/>
</record>
<record id="survey_demo_quiz_p4_q2" model="survey.question">
<field name="title">In which country did the bonsai technique develop ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">32</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p4_q2_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="sequence">1</field>
<field name="value">Japan</field>
<field name="value_image" type="base64" file="survey/static/img/japan.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
</record>
<record id="survey_demo_quiz_p4_q2_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="sequence">2</field>
<field name="value">China</field>
<field name="value_image" type="base64" file="survey/static/img/china.jpg"/>
</record>
<record id="survey_demo_quiz_p4_q2_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="sequence">3</field>
<field name="value">Vietnam</field>
<field name="value_image" type="base64" file="survey/static/img/vietnam.jpg"/>
</record>
<record id="survey_demo_quiz_p4_q2_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="sequence">4</field>
<field name="value">South Korea</field>
<field name="value_image" type="base64" file="survey/static/img/south_korea.jpg"/>
</record>
<record id="survey_demo_quiz_p4_q3" model="survey.question">
<field name="title">Is the wood of a coniferous hard or soft ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">33</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="description" type="html">
<p>
<img class="img-fluid o_we_custom_image d-block mx-auto"
src="/survey/static/img/coniferous.jpg"/><br/>
</p>
</field>
</record>
<record id="survey_demo_quiz_p4_q3_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q3"/>
<field name="sequence">1</field>
<field name="value">Hard</field>
</record>
<record id="survey_demo_quiz_p4_q3_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q3"/>
<field name="sequence">2</field>
<field name="value">Soft</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_quiz_p4_q4" model="survey.question">
<field name="title">From which continent is native the Scots pine (pinus sylvestris) ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">34</field>
<field name="question_type">simple_choice</field>
<field name="constr_mandatory" eval="True"/>
<field name="description" type="html">
<p>
<img class="img-fluid o_we_custom_image d-block mx-auto"
src="/survey/static/img/pinus_sylvestris.jpg" style="width: 100%;"/><br/>
</p>
</field>
</record>
<record id="survey_demo_quiz_p4_q4_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="sequence">1</field>
<field name="value">Africa</field>
<field name="value_image" type="base64" file="survey/static/img/africa.png"/>
</record>
<record id="survey_demo_quiz_p4_q4_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="sequence">2</field>
<field name="value">Asia</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="value_image" type="base64" file="survey/static/img/asia.png"/>
</record>
<record id="survey_demo_quiz_p4_q4_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="sequence">3</field>
<field name="value">Europe</field>
<field name="value_image" type="base64" file="survey/static/img/europe.png"/>
</record>
<record id="survey_demo_quiz_p4_q4_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="sequence">4</field>
<field name="value">South America</field>
<field name="value_image" type="base64" file="survey/static/img/south_america.png"/>
</record>
<record id="survey_demo_quiz_p4_q5" model="survey.question">
<field name="title">In the list below, select all the coniferous.</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">35</field>
<field name="question_type">multiple_choice</field>
<field name="constr_mandatory" eval="True"/>
</record>
<record id="survey_demo_quiz_p4_q5_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="sequence">1</field>
<field name="value">Douglas Fir</field>
<field name="value_image" type="base64" file="survey/static/img/douglas_fir.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_quiz_p4_q5_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="sequence">2</field>
<field name="value">Norway Spruce</field>
<field name="value_image" type="base64" file="survey/static/img/norway_spruce.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_quiz_p4_q5_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="sequence">3</field>
<field name="value">European Yew</field>
<field name="value_image" type="base64" file="survey/static/img/european_yew.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_quiz_p4_q5_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="sequence">4</field>
<field name="value">Mountain Pine</field>
<field name="value_image" type="base64" file="survey/static/img/mountain_pine.jpg"/>
<field name="is_correct" eval="True"/>
<field name="answer_score">5</field>
</record>
<record id="survey_demo_quiz_p4_q6" model="survey.question">
<field name="title">After watching this video, will you swear that you are not going to procrastinate to trim your hedge this year ?</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">36</field>
<field name="question_type">simple_choice</field>
<field name="description" type="html">
<div class="text-center">
<div class="media_iframe_video" data-oe-expression="//www.youtube.com/embed/7y4T6yv5L1k?autoplay=0&amp;rel=0" style="width: 50%;">
<div class="css_editable_mode_display"/>
<div class="media_iframe_video_size" contenteditable="false"/>
<iframe src="//www.youtube.com/embed/7y4T6yv5L1k?autoplay=0&amp;rel=0" frameborder="0" contenteditable="false"></iframe>
</div><br/>
</div>
</field>
</record>
<record id="survey_demo_quiz_p4_q6_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q6"/>
<field name="sequence">1</field>
<field name="value">Yes</field>
<field name="is_correct" eval="True"/>
<field name="answer_score">10</field>
</record>
<record id="survey_demo_quiz_p4_q6_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q6"/>
<field name="sequence">2</field>
<field name="value">No</field>
</record>
<record id="survey_demo_quiz_p4_q6_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p4_q6"/>
<field name="sequence">3</field>
<field name="value">Perhaps</field>
<field name="answer_score">-10</field>
</record>
<!-- Page 5: Feedback - non scored question -->
<record id="survey_demo_quiz_p5" model="survey.question">
<field name="title">Your feeling</field>
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">40</field>
<field name="question_type" eval="False"/>
<field name="is_page" eval="True"/>
<field name="description" type="html">
<p>We may be interested by your input.</p></field>
</record>
<record id="survey_demo_quiz_p5_q1" model="survey.question">
<field name="survey_id" ref="survey_demo_quiz"/>
<field name="sequence">41</field>
<field name="title">What do you think about this survey ?</field>
<field name="description" type="html"><span>If you don't like us, please try to be as objective as possible.</span></field>
<field name="question_type">simple_choice</field>
<field name="comments_allowed" eval="True"/>
<field name="comment_count_as_answer" eval="True"/>
<field name="constr_mandatory" eval="False"/>
</record>
<record id="survey_demo_quiz_p5_q1_sug1" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p5_q1"/>
<field name="sequence">1</field>
<field name="value">Good</field>
</record>
<record id="survey_demo_quiz_p5_q1_sug2" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p5_q1"/>
<field name="sequence">2</field>
<field name="value">Not Good, Not Bad</field>
</record>
<record id="survey_demo_quiz_p5_q1_sug3" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p5_q1"/>
<field name="sequence">3</field>
<field name="value">Iznogoud</field>
</record>
<record id="survey_demo_quiz_p5_q1_sug4" model="survey.question.answer">
<field name="question_id" ref="survey_demo_quiz_p5_q1"/>
<field name="sequence">4</field>
<field name="value">I have no idea, I'm a dog!</field>
</record>
</data></odoo>

View file

@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<record id="survey_demo_quiz_answer_1" model="survey.user_input">
<field name="survey_id" ref="survey.survey_demo_quiz"/>
<field name="partner_id" ref="base.partner_demo"/>
<field name="email">mark.brown23@example.com</field>
<field name="end_datetime" eval="datetime.now() - timedelta(days=1, hours=3, minutes=30)"/>
<field name="start_datetime" eval="datetime.now() - timedelta(days=1, hours=4, minutes=50)"/>
<field name="state">done</field>
</record>
<record id="survey_demo_quiz_answer_2" model="survey.user_input">
<field name="survey_id" ref="survey.survey_demo_quiz"/>
<field name="partner_id" ref="base.partner_admin"/>
<field name="email">admin@yourcompany.example.com</field>
<field name="end_datetime" eval="datetime.now() - timedelta(days=1, hours=2, minutes=50)"/>
<field name="start_datetime" eval="datetime.now() - timedelta(days=1, hours=3, minutes=50)"/>
<field name="state">done</field>
</record>
<record id="survey_demo_quiz_answer_3" model="survey.user_input">
<field name="survey_id" ref="survey.survey_demo_quiz"/>
<field name="partner_id" ref="base.partner_demo_portal"/>
<field name="email">joel.willis63@example.com</field>
<field name="end_datetime" eval="datetime.now() - timedelta(days=1, hours=2, minutes=10)"/>
<field name="start_datetime" eval="datetime.now() - timedelta(days=1, hours=2, minutes=50)"/>
<field name="state">done</field>
</record>
<record id="survey_demo_quiz_answer_4" model="survey.user_input">
<field name="survey_id" ref="survey.survey_demo_quiz"/>
<field name="partner_id" ref="base.res_partner_address_28"/>
<field name="email">colleen.diaz83@example.com</field>
<field name="start_datetime" eval="datetime.now() - timedelta(days=1, hours=0, minutes=50)"/>
<field name="state">in_progress</field>
</record>
<record id="survey_demo_quiz_answer_5" model="survey.user_input">
<field name="survey_id" ref="survey.survey_demo_quiz"/>
<field name="partner_id" ref="base.res_partner_address_34"/>
<field name="email">travis.mendoza24@example.com</field>
<field name="state">new</field>
</record>
</data></odoo>

View file

@ -1,560 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="0">
<!-- Page 1: general informations -->
<record id="survey_demo_quiz_answer_1_p1_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">mark.brown23@example.com</field>
</record>
<record id="survey_demo_quiz_answer_1_p1_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Mark Brown</field>
</record>
<record id="survey_demo_quiz_answer_1_p1_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Brussels</field>
</record>
<record id="survey_demo_quiz_answer_1_p1_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p1_q4"/>
<field name="answer_type">numerical_box</field>
<field name="value_numerical_box">36</field>
</record>
<!-- Page 2: quiz about company -->
<record id="survey_demo_quiz_answer_1_p2_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p2_q1"/>
<field name="answer_type">date</field>
<field name="value_date" eval="DateTime.today() - relativedelta(years=36)"/>
</record>
<record id="survey_demo_quiz_answer_1_p2_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p2_q2"/>
<field name="answer_type">datetime</field>
<field name="value_datetime" eval="DateTime.now().replace(year=2017, month=10, day=2, hour=2, minute=27, second=0)"/>
</record>
<record id="survey_demo_quiz_answer_1_p2_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p2_q3"/>
<field name="answer_type">text_box</field>
<field name="value_text_box">Oak, ash, pine</field>
</record>
<!-- Page 3: quiz about fruits and vegetables -->
<record id="survey_demo_quiz_answer_1_p3_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q1_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q2_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q2_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Mooses ?? Really ?</field>
</record>
<record id="survey_demo_quiz_answer_1_p3_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q3_sug2"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q3_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_score">-10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q3_sug3"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q4_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q4_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_score">-10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q4_sug3"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q5_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q5_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q5_row1"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q5_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q5_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q5_row2"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q6_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row1"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q6_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row2"/>
</record>
<record id="survey_demo_quiz_answer_1_p3_q6_l3" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row3"/>
</record>
<!-- Page 4: trees -->
<record id="survey_demo_quiz_answer_1_p4_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q1_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p4_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q2_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p4_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q3"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q3_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p4_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q4_sug2"/>
</record>
<record id="survey_demo_quiz_answer_1_p4_q5_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q5_sug1"/>
</record>
<record id="survey_demo_quiz_answer_1_p4_q5_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q5_sug2"/>
</record>
<record id="survey_demo_quiz_answer_1_p4_q6_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_1"/>
<field name="question_id" ref="survey_demo_quiz_p4_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q6_sug1"/>
</record>
<!-- Page 1: general informations -->
<record id="survey_demo_quiz_answer_2_p1_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">admin@yourcompany.example.com</field>
</record>
<record id="survey_demo_quiz_answer_2_p1_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Mitchell Admin</field>
</record>
<record id="survey_demo_quiz_answer_2_p1_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Ottawa</field>
</record>
<record id="survey_demo_quiz_answer_2_p1_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p1_q4"/>
<field name="answer_type">numerical_box</field>
<field name="value_numerical_box">48</field>
</record>
<!-- Page 2: quiz about company -->
<record id="survey_demo_quiz_answer_2_p2_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p2_q1"/>
<field name="answer_type">date</field>
<field name="value_date" eval="DateTime.today() + relativedelta(years=24)"/>
</record>
<record id="survey_demo_quiz_answer_2_p2_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p2_q2"/>
<field name="answer_type">datetime</field>
<field name="value_datetime" eval="DateTime.now().replace(year=2011, month=8, day=21, hour=15, minute=34, second=0)"/>
</record>
<record id="survey_demo_quiz_answer_2_p2_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p2_q3"/>
<field name="skipped" eval="True"/>
</record>
<!-- Page 3: quiz about fruits and vegetables -->
<record id="survey_demo_quiz_answer_2_p3_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q1_sug2"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q2_sug3"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q2_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Mooses are best pollinators of the world !</field>
</record>
<record id="survey_demo_quiz_answer_2_p3_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q3_sug1"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q3_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">I sold a 30K raspberry tree once.</field>
</record>
<record id="survey_demo_quiz_answer_2_p3_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q4_sug1"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q5_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q5_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q5_row1"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q5_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q5_sug2"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q5_row2"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q6_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row1"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q6_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug2"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row2"/>
</record>
<record id="survey_demo_quiz_answer_2_p3_q6_l3" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug3"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row3"/>
</record>
<!-- Page 4: trees -->
<record id="survey_demo_quiz_answer_2_p4_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q1_sug1"/>
</record>
<record id="survey_demo_quiz_answer_2_p4_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q2_sug1"/>
</record>
<record id="survey_demo_quiz_answer_2_p4_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q3"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q3_sug2"/>
</record>
<record id="survey_demo_quiz_answer_2_p4_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q4_sug1"/>
</record>
<record id="survey_demo_quiz_answer_2_p4_q5_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q5_sug1"/>
</record>
<record id="survey_demo_quiz_answer_2_p4_q5_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q5_sug3"/>
</record>
<record id="survey_demo_quiz_answer_2_p4_q6_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_2"/>
<field name="question_id" ref="survey_demo_quiz_p4_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q6_sug1"/>
</record>
<!-- Page 1: general informations -->
<record id="survey_demo_quiz_answer_3_p1_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">joel.willis63@example.com</field>
</record>
<record id="survey_demo_quiz_answer_3_p1_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q2"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Joël Willis</field>
</record>
<record id="survey_demo_quiz_answer_3_p1_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Brussels</field>
</record>
<record id="survey_demo_quiz_answer_3_p1_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p1_q4"/>
<field name="answer_type">numerical_box</field>
<field name="value_numerical_box">28</field>
</record>
<!-- Page 2: quiz about company -->
<record id="survey_demo_quiz_answer_3_p2_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p2_q1"/>
<field name="answer_type">date</field>
<field name="value_date" eval="DateTime.today() - relativedelta(years=38)"/>
</record>
<record id="survey_demo_quiz_answer_3_p2_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p2_q2"/>
<field name="answer_type">datetime</field>
<field name="value_datetime" eval="DateTime.now().replace(year=2005, month=4, day=18, hour=10, minute=0, second=0)"/>
</record>
<record id="survey_demo_quiz_answer_3_p2_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p2_q3"/>
<field name="answer_type">text_box</field>
<field name="value_text_box">Oak, fur, pine, red pine</field>
</record>
<!-- Page 3: quiz about fruits and vegetables -->
<record id="survey_demo_quiz_answer_3_p3_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q1"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Both fruit (seeds, par of the plant) and vegetable (culinary use), obviously.</field>
</record>
<record id="survey_demo_quiz_answer_3_p3_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q2"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q2_sug1"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q3_sug1"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q3_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q3_sug2"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q3_l3" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q3"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">You forgot the strawberry tree.</field>
</record>
<record id="survey_demo_quiz_answer_3_p3_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q4_sug1"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q4_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_is_correct" eval="True"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q4_sug2"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q4_l3" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_score">-10</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q4_sug3"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q4_l4" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q4"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Gives the beeest cosmics rays man. So juicy.</field>
</record>
<record id="survey_demo_quiz_answer_3_p3_q5_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q5_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q5_row1"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q5_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q5_sug2"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q5_row2"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q5_l3" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q5"/>
<field name="answer_type">char_box</field>
<field name="value_char_box">Well sometimes I forget them, they survived. Almost.</field>
</record>
<record id="survey_demo_quiz_answer_3_p3_q6_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug3"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row1"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q6_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug4"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row1"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q6_l3" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug1"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row2"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q6_l4" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug2"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row2"/>
</record>
<record id="survey_demo_quiz_answer_3_p3_q6_l5" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p3_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p3_q6_sug4"/>
<field name="matrix_row_id" ref="survey_demo_quiz_p3_q6_row3"/>
</record>
<!-- Page 4: trees -->
<record id="survey_demo_quiz_answer_3_p4_q1_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q1"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q1_sug3"/>
</record>
<record id="survey_demo_quiz_answer_3_p4_q2_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q2"/>
<field name="answer_score">20</field>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q2_sug2"/>
</record>
<record id="survey_demo_quiz_answer_3_p4_q3_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q3"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q3_sug1"/>
</record>
<record id="survey_demo_quiz_answer_3_p4_q4_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q4"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q4_sug3"/>
</record>
<record id="survey_demo_quiz_answer_3_p4_q5_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q5_sug2"/>
</record>
<record id="survey_demo_quiz_answer_3_p4_q5_l2" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q5"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q5_sug3"/>
</record>
<record id="survey_demo_quiz_answer_3_p4_q6_l1" model="survey.user_input.line">
<field name="user_input_id" ref="survey_demo_quiz_answer_3"/>
<field name="question_id" ref="survey_demo_quiz_p4_q6"/>
<field name="answer_type">suggestion</field>
<field name="suggested_answer_id" ref="survey_demo_quiz_p4_q6_sug1"/>
</record>
</data></odoo>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_tour" model="web_tour.tour">
<field name="name">survey_tour</field>
<field name="sequence">225</field>
<field name="rainbow_man_message"><![CDATA[
Congratulations! You are now ready to collect feedback like a pro :-)
]]></field>
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,12 @@
# -*- 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
from . import res_lang
from . import res_partner
from . import survey_question
from . import survey_survey
from . import survey_user_input
from . import templates

View file

@ -4,7 +4,7 @@
from odoo import models, fields
class Challenge(models.Model):
class GamificationChallenge(models.Model):
_inherit = 'gamification.challenge'
challenge_category = fields.Selection(selection_add=[

View file

@ -1,12 +1,37 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
from odoo import models
from odoo import api, models
from odoo.http import request
SURVEY_URL_PREFIX_REGEX = re.compile(r"""
^
( # Optional locale part of the URL
/[a-z]{2,3} # Language (only 2- or 3-letter ISO 639 code)
# e.g. fr, kab
(_([A-Z]{2}|[0-9]{3}))? # [Optional] Region (2-letter ISO 3166-1 code or 3-digit UN M.49 code)
# e.g. fr_BE, es_419
(@[a-zA-Z]+)? # [Optional] Script (ISO 15924 code)
# e.g. sr@Cyrl
)?
/survey/
""", re.VERBOSE)
class IrHttp(models.AbstractModel):
_inherit = ["ir.http"]
_inherit = 'ir.http'
@api.model
def get_nearest_lang(self, lang_code):
if request and self._is_survey_frontend(request.httprequest.path):
return super(IrHttp, self.with_context(web_force_installed_langs=True)).get_nearest_lang(lang_code)
return super().get_nearest_lang(lang_code)
@classmethod
def _get_translation_frontend_modules_name(cls):
modules = super()._get_translation_frontend_modules_name()
return modules + ["survey"]
mods = super()._get_translation_frontend_modules_name()
return mods + ['survey']
@api.model
def _is_survey_frontend(self, path):
return bool(SURVEY_URL_PREFIX_REGEX.match(path))

View file

@ -0,0 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command, models, _
from odoo.exceptions import UserError
class ResLang(models.Model):
_inherit = "res.lang"
def write(self, vals):
""" When languages are disabled, clear corresponding survey languages. """
if 'active' in vals and not vals['active']:
self.env['survey.user_input'].sudo().search([('lang_id', 'in', self.ids)]).lang_id = False
surveys_sudo = self.env['survey.survey'].sudo().search([('lang_ids', 'in', self.ids)])
if will_be_all_lang_survey_sudo := surveys_sudo.filtered(lambda survey: survey.lang_ids <= self):
if len(self) > 1:
error = _("Cannot deactivate languages currently used by survey(s) only supporting those languages.")
else:
error = _("Cannot deactivate a language currently used by survey(s) only supporting that language.")
if self.env['survey.survey'].search(
[('id', 'in', will_be_all_lang_survey_sudo.ids)]) == will_be_all_lang_survey_sudo:
error += '\n'
error += _("Survey(s): %(surveys_list)s",
surveys_list=', '.join(f'"{survey.title}"' for survey in will_be_all_lang_survey_sudo))
raise UserError(error)
surveys_sudo.write({'lang_ids': [Command.unlink(lang.id) for lang in self]})
return super().write(vals)

View file

@ -14,9 +14,9 @@ class ResPartner(models.Model):
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'
['partner_id'], ['__count']
)
data = dict((res['partner_id'][0], res['partner_id_count']) for res in read_group_res)
data = {partner.id: count for partner, count in read_group_res}
for partner in self:
partner.certifications_count = data.get(partner.id, 0)
@ -26,7 +26,7 @@ class ResPartner(models.Model):
def action_view_certifications(self):
action = self.env["ir.actions.actions"]._for_xml_id("survey.res_partner_action_certifications")
action['view_mode'] = 'tree'
action['view_mode'] = 'list'
action['domain'] = ['|', ('partner_id', 'in', self.ids), ('partner_id', 'in', self.child_ids.ids)]
return action

View file

@ -3,9 +3,10 @@
import collections
import contextlib
import json
import itertools
import json
import operator
from textwrap import shorten
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError, ValidationError
@ -30,7 +31,7 @@ class SurveyQuestion(models.Model):
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.
links on the list 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
@ -46,6 +47,17 @@ class SurveyQuestion(models.Model):
_rec_name = 'title'
_order = 'sequence,id'
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if default_survey_id := self.env.context.get('default_survey_id'):
survey = self.env['survey.survey'].browse(default_survey_id)
if 'is_time_limited' in fields and 'is_time_limited' not in res:
res['is_time_limited'] = survey.session_speed_rating
if 'time_limit' in fields and 'time_limit' not in res:
res['time_limit'] = survey.session_speed_rating_time_limit
return res
# question generic data
title = fields.Char('Title', required=True, translate=True)
description = fields.Html(
@ -54,9 +66,13 @@ class SurveyQuestion(models.Model):
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')
survey_id = fields.Many2one('survey.survey', string='Survey', ondelete='cascade', index='btree_not_null')
scoring_type = fields.Selection(related='survey_id.scoring_type', string='Scoring Type', readonly=True)
sequence = fields.Integer('Sequence', default=10)
session_available = fields.Boolean(related='survey_id.session_available', string='Live Session available', readonly=True)
survey_session_speed_rating = fields.Boolean(related="survey_id.session_speed_rating")
survey_session_speed_rating_time_limit = fields.Integer(related="survey_id.session_speed_rating_time_limit", string="General Time limit (seconds)")
# page specific
is_page = fields.Boolean('Is a page?')
question_ids = fields.One2many('survey.question', string='Questions', compute="_compute_question_ids")
@ -74,6 +90,7 @@ class SurveyQuestion(models.Model):
('text_box', 'Multiple Lines Text Box'),
('char_box', 'Single Line Text Box'),
('numerical_box', 'Numerical Value'),
('scale', 'Scale'),
('date', 'Date'),
('datetime', 'Datetime'),
('matrix', 'Matrix')], string='Question Type',
@ -82,6 +99,8 @@ class SurveyQuestion(models.Model):
'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.")
has_image_only_suggested_answer = fields.Boolean(
"Has image only suggested answer", compute='_compute_has_image_only_suggested_answer')
# -- 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.")
@ -105,9 +124,16 @@ class SurveyQuestion(models.Model):
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')
# -- scale
scale_min = fields.Integer("Scale Minimum Value", default=0)
scale_max = fields.Integer("Scale Maximum Value", default=10)
scale_min_label = fields.Char("Scale Minimum Label", translate=True)
scale_mid_label = fields.Char("Scale Middle Label", translate=True)
scale_max_label = fields.Char("Scale Maximum Label", translate=True)
# -- display & timing options
is_time_limited = fields.Boolean("The question is limited in time",
help="Currently only supported for live sessions.")
is_time_customized = fields.Boolean("Customized speed rewards")
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')
@ -124,7 +150,7 @@ class SurveyQuestion(models.Model):
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)
validation_error_msg = fields.Char('Validation Error', translate=True)
constr_mandatory = fields.Boolean('Mandatory Answer')
constr_error_msg = fields.Char('Error message', translate=True)
# answers
@ -132,36 +158,73 @@ class SurveyQuestion(models.Model):
'survey.user_input.line', 'question_id', string='Answers',
domain=[('skipped', '=', False)], groups='survey.group_survey_user')
# Conditional display
is_conditional = fields.Boolean(
string='Conditional Display', copy=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)]")
# Not stored, convenient for trigger display computation.
triggering_question_ids = fields.Many2many(
'survey.question', string="Triggering Questions", compute="_compute_triggering_question_ids",
store=False, help="Questions containing the triggering answer(s) to display the current question.")
_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')
]
allowed_triggering_question_ids = fields.Many2many(
'survey.question', string="Allowed Triggering Questions", copy=False, compute="_compute_allowed_triggering_question_ids")
is_placed_before_trigger = fields.Boolean(
string='Is misplaced?', help="Is this question placed before any of its trigger questions?",
compute="_compute_allowed_triggering_question_ids")
triggering_answer_ids = fields.Many2many(
'survey.question.answer', string="Triggering Answers", copy=False, store=True,
readonly=False, help="Picking any of these answers will trigger this question.\n"
"Leave the field empty if the question should always be displayed.",
domain="""[
('question_id.survey_id', '=', survey_id),
'&', ('question_id.question_type', 'in', ['simple_choice', 'multiple_choice']),
'|',
('question_id.sequence', '<', sequence),
'&', ('question_id.sequence', '=', sequence), ('question_id.id', '<', id)
]"""
)
_positive_len_min = models.Constraint(
'CHECK (validation_length_min >= 0)',
'A length must be positive!',
)
_positive_len_max = models.Constraint(
'CHECK (validation_length_max >= 0)',
'A length must be positive!',
)
_validation_length = models.Constraint(
'CHECK (validation_length_min <= validation_length_max)',
'Max length cannot be smaller than min length!',
)
_validation_float = models.Constraint(
'CHECK (validation_min_float_value <= validation_max_float_value)',
'Max value cannot be smaller than min value!',
)
_validation_date = models.Constraint(
'CHECK (validation_min_date <= validation_max_date)',
'Max date cannot be smaller than min date!',
)
_validation_datetime = models.Constraint(
'CHECK (validation_min_datetime <= validation_max_datetime)',
'Max datetime cannot be smaller than min datetime!',
)
_positive_answer_score = models.Constraint(
'CHECK (answer_score >= 0)',
'An answer score for a non-multiple choice question cannot be negative!',
)
_scored_datetime_have_answers = models.Constraint(
"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 = models.Constraint(
"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',
)
_scale = models.Constraint(
"CHECK (question_type != 'scale' OR (scale_min >= 0 AND scale_max <= 10 AND scale_min < scale_max))",
'The scale must be a growing non-empty range between 0 and 10 (inclusive)',
)
_is_time_limited_have_time_limit = models.Constraint(
'CHECK (is_time_limited != TRUE OR time_limit IS NOT NULL AND time_limit > 0)',
'All time-limited questions need a positive time limit',
)
# -------------------------------------------------------------------------
# CONSTRAINT METHODS
@ -177,6 +240,13 @@ class SurveyQuestion(models.Model):
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('suggested_answer_ids', 'suggested_answer_ids.value')
def _compute_has_image_only_suggested_answer(self):
questions_with_image_only_answer = self.env['survey.question'].search(
[('id', 'in', self.ids), ('suggested_answer_ids.value', 'in', [False, ''])])
questions_with_image_only_answer.has_image_only_suggested_answer = True
(self - questions_with_image_only_answer).has_image_only_suggested_answer = False
@api.depends('question_type')
def _compute_question_placeholder(self):
for question in self:
@ -220,19 +290,10 @@ class SurveyQuestion(models.Model):
@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)
)
question.question_ids = question.survey_id.question_ids\
.filtered(lambda q: q.page_id == question).sorted(lambda q: q._index())
else:
question.question_ids = self.env['survey.question']
@ -269,33 +330,59 @@ class SurveyQuestion(models.Model):
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('survey_id', 'survey_id.question_ids', 'triggering_answer_ids')
def _compute_allowed_triggering_question_ids(self):
"""Although the question (and possible trigger questions) sequence
is used here, we do not add these fields to the dependency list to
avoid cascading rpc calls when reordering questions via the webclient.
"""
possible_trigger_questions = self.search([
('is_page', '=', False),
('question_type', 'in', ['simple_choice', 'multiple_choice']),
('suggested_answer_ids', '!=', False),
('survey_id', 'in', self.survey_id.ids)
])
# Using the sequence stored in db is necessary for existing questions that are passed as
# NewIds because the sequence provided by the JS client can be incorrect.
(self | possible_trigger_questions).flush_recordset()
self.env.cr.execute(
"SELECT id, sequence FROM survey_question WHERE id =ANY(%s)",
[self.ids]
)
conditional_questions_sequences = dict(self.env.cr.fetchall()) # id: sequence mapping
@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
question_id = question._origin.id
if not question_id: # New question
question.allowed_triggering_question_ids = possible_trigger_questions.filtered(
lambda q: q.survey_id.id == question.survey_id._origin.id)
question.is_placed_before_trigger = False
continue
@api.depends('question_type', 'scoring_type', 'answer_date', 'answer_datetime', 'answer_numerical_box')
question_sequence = conditional_questions_sequences[question_id]
question.allowed_triggering_question_ids = possible_trigger_questions.filtered(
lambda q: q.survey_id.id == question.survey_id._origin.id
and (q.sequence < question_sequence or q.sequence == question_sequence and q.id < question_id)
)
question.is_placed_before_trigger = bool(
set(question.triggering_answer_ids.question_id.ids)
- set(question.allowed_triggering_question_ids.ids) # .ids necessary to match ids with newIds
)
@api.depends('triggering_answer_ids')
def _compute_triggering_question_ids(self):
for question in self:
question.triggering_question_ids = question.triggering_answer_ids.question_id
@api.depends('question_type', 'scoring_type', 'answer_date', 'answer_datetime', 'answer_numerical_box', 'suggested_answer_ids.is_correct')
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)
- 'simple_choice / multiple_choice': set to True if any of suggested answers are marked as correct
- question_type isn't scoreable (note: choice questions scoring logic handled separately) => False
"""
for question in self:
@ -308,14 +395,45 @@ class SurveyQuestion(models.Model):
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
question.is_scored_question = any(question.suggested_answer_ids.mapped('is_correct'))
else:
question.is_scored_question = False
@api.onchange('question_type', 'validation_required')
def _onchange_validation_parameters(self):
"""Ensure no value stays set but not visible on form,
preventing saving (+consistency with question type)."""
self.validation_email = False
self.validation_length_min = 0
self.validation_length_max = 0
self.validation_min_date = False
self.validation_max_date = False
self.validation_min_datetime = False
self.validation_max_datetime = False
self.validation_min_float_value = 0
self.validation_max_float_value = 0
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
def copy(self, default=None):
new_questions = super().copy(default)
for old_question, new_question in zip(self, new_questions):
if old_question.triggering_answer_ids:
new_question.triggering_answer_ids = old_question.triggering_answer_ids
return new_questions
@api.model_create_multi
def create(self, vals_list):
questions = super().create(vals_list)
questions.filtered(
lambda q: q.survey_id
and (q.survey_id.session_speed_rating != q.is_time_limited
or q.is_time_limited and q.survey_id.session_speed_rating_time_limit != q.time_limit)
).is_time_customized = True
return questions
@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')
@ -331,24 +449,26 @@ class SurveyQuestion(models.Model):
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, ...] }
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-):
return dict {question.id (int): error (str)} -> empty dict if no validation error.
"""
- 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, ...] }``
:returns: A dict ``{question.id: error}``, or an empty dict if no validation error.
:rtype: dict[int, str]
"""
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 not answer and self.question_type not in ['simple_choice', 'multiple_choice']:
if self.constr_mandatory and not self.survey_id.users_can_go_back:
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
else:
if self.question_type == 'char_box':
return self._validate_char_box(answer)
elif self.question_type == 'numerical_box':
@ -359,6 +479,8 @@ class SurveyQuestion(models.Model):
return self._validate_choice(answer, comment)
elif self.question_type == 'matrix':
return self._validate_matrix(answer)
elif self.question_type == 'scale':
return self._validate_scale(answer)
return {}
def _validate_char_box(self, answer):
@ -413,11 +535,22 @@ class SurveyQuestion(models.Model):
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):
""" Validates choice-based questions.
- Checks that mandatory questions have at least one answer.
- For 'simple_choice', ensures that exactly one answer is provided.
"""
answers = answer if isinstance(answer, list) else ([answer] if answer else [])
valid_answers_count = len(answers)
if comment and self.comment_count_as_answer:
valid_answers_count += 1
if valid_answers_count == 0 and self.constr_mandatory and not self.survey_id.users_can_go_back:
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
if valid_answers_count > 1 and self.question_type == 'simple_choice':
return {self.id: _('For this question, you can only select one answer.')}
return {}
def _validate_matrix(self, answers):
@ -426,6 +559,13 @@ class SurveyQuestion(models.Model):
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
return {}
def _validate_scale(self, answer):
if not self.survey_id.users_can_go_back \
and self.constr_mandatory \
and not answer:
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.
@ -434,6 +574,36 @@ class SurveyQuestion(models.Model):
self.ensure_one()
return list(self.survey_id.question_and_page_ids).index(self)
# ------------------------------------------------------------
# SPEED RATING
# ------------------------------------------------------------
def _update_time_limit_from_survey(self, is_time_limited=None, time_limit=None):
"""Update the speed rating values after a change in survey's speed rating configuration.
* Questions that were not customized will take the new default values from the survey
* Questions that were customized will not change their values, but this method will check
and update the `is_time_customized` flag if necessary (to `False`) such that the user
won't need to "actively" do it to make the question sensitive to change in survey values.
This is not done with `_compute`s because `is_time_limited` (and `time_limit`) would depend
on `is_time_customized` and vice versa.
"""
write_vals = {}
if is_time_limited is not None:
write_vals['is_time_limited'] = is_time_limited
if time_limit is not None:
write_vals['time_limit'] = time_limit
non_time_customized_questions = self.filtered(lambda s: not s.is_time_customized)
non_time_customized_questions.write(write_vals)
# Reset `is_time_customized` as necessary
customized_questions = self - non_time_customized_questions
back_to_default_questions = customized_questions.filtered(
lambda q: q.is_time_limited == q.survey_id.session_speed_rating
and (q.is_time_limited is False or q.time_limit == q.survey_id.session_speed_rating_time_limit))
back_to_default_questions.is_time_customized = False
# ------------------------------------------------------------
# STATISTICS / REPORTING
# ------------------------------------------------------------
@ -466,7 +636,7 @@ class SurveyQuestion(models.Model):
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'),
answer_input_ids=answer_lines.mapped('user_input_id'),
comment_line_ids=comment_line_ids)
question_data.update(question._get_stats_summary_data(answer_lines))
@ -474,7 +644,12 @@ class SurveyQuestion(models.Model):
table_data, graph_data = question._get_stats_data(answer_lines)
question_data['table_data'] = table_data
question_data['graph_data'] = json.dumps(graph_data)
if question.question_type in ["text_box", "char_box", "numerical_box", "date", "datetime"]:
answers_data = [
[input_line.id, input_line._get_answer_value(), input_line.user_input_id.get_print_url()]
for input_line in table_data if not input_line.skipped
]
question_data["answers_data"] = json.dumps(answers_data, default=str)
all_questions_data.append(question_data)
return all_questions_data
@ -486,6 +661,9 @@ class SurveyQuestion(models.Model):
return table_data, [{'key': self.title, 'values': graph_data}]
elif self.question_type == 'matrix':
return self._get_stats_graph_data_matrix(user_input_lines)
elif self.question_type == 'scale':
table_data, graph_data = self._get_stats_data_scale(user_input_lines)
return table_data, [{'key': self.title, 'values': graph_data}]
return [line for line in user_input_lines], []
def _get_stats_data_answers(self, user_input_lines):
@ -504,17 +682,17 @@ class SurveyQuestion(models.Model):
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]),
'value': _('Other (see comments)') if not suggested_answer else suggested_answer.value_label,
'suggested_answer': suggested_answer,
'count': count_data[suggested_answer],
'count_text': self.env._("%s Votes", count_data[suggested_answer]),
}
for sug_answer in suggested_answers]
for suggested_answer in suggested_answers]
graph_data = [{
'text': _('Other (see comments)') if not sug_answer else sug_answer.value,
'count': count_data[sug_answer]
'text': self.env._('Other (see comments)') if not suggested_answer else suggested_answer.value_label,
'count': count_data[suggested_answer]
}
for sug_answer in suggested_answers]
for suggested_answer in suggested_answers]
return table_data, graph_data
@ -530,19 +708,42 @@ class SurveyQuestion(models.Model):
table_data = [{
'row': row,
'columns': [{
'suggested_answer': sug_answer,
'count': count_data[(row, sug_answer)]
} for sug_answer in suggested_answers],
'suggested_answer': suggested_answer,
'count': count_data[(row, suggested_answer)]
} for suggested_answer in suggested_answers],
} for row in matrix_rows]
graph_data = [{
'key': sug_answer.value,
'key': suggested_answer.value,
'values': [{
'text': row.value,
'count': count_data[(row, sug_answer)]
'count': count_data[(row, suggested_answer)]
}
for row in matrix_rows
]
} for sug_answer in suggested_answers]
} for suggested_answer in suggested_answers]
return table_data, graph_data
def _get_stats_data_scale(self, user_input_lines):
suggested_answers = range(self.scale_min, self.scale_max + 1)
# Scale doesn't support comment as answer, so no extra value added
count_data = dict.fromkeys(suggested_answers, 0)
for line in user_input_lines:
if not line.skipped and line.value_scale in count_data:
count_data[line.value_scale] += 1
table_data = []
graph_data = []
for sug_answer in suggested_answers:
table_data.append({'value': str(sug_answer),
'suggested_answer': self.env['survey.question.answer'],
'count': count_data[sug_answer],
'count_text': _("%s Votes", count_data[sug_answer]),
})
graph_data.append({'text': str(sug_answer),
'count': count_data[sug_answer]
})
return table_data, graph_data
@ -552,8 +753,10 @@ class SurveyQuestion(models.Model):
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))
elif self.question_type == 'scale':
stats.update(self._get_stats_summary_data_numerical(user_input_lines, 'value_scale'))
if self.question_type in ['numerical_box', 'date', 'datetime']:
if self.question_type in ['numerical_box', 'date', 'datetime', 'scale']:
stats.update(self._get_stats_summary_data_scored(user_input_lines))
return stats
@ -575,8 +778,8 @@ class SurveyQuestion(models.Model):
'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')
def _get_stats_summary_data_numerical(self, user_input_lines, fname='value_numerical_box'):
all_values = user_input_lines.filtered(lambda line: not line.skipped).mapped(fname)
lines_sum = sum(all_values)
return {
'numerical_max': max(all_values, default=0),
@ -592,6 +795,42 @@ class SurveyQuestion(models.Model):
'right_inputs_count': len(user_input_lines.filtered(lambda line: line.answer_is_correct).mapped('user_input_id'))
}
# ------------------------------------------------------------
# OTHERS
# ------------------------------------------------------------
def _get_correct_answers(self):
""" Return a dictionary linking the scorable question ids to their correct answers.
The questions without correct answers are not considered.
"""
correct_answers = {}
# Simple and multiple choice
choices_questions = self.filtered(lambda q: q.question_type in ['simple_choice', 'multiple_choice'])
if choices_questions:
suggested_answers_data = self.env['survey.question.answer'].search_read(
[('question_id', 'in', choices_questions.ids), ('is_correct', '=', True)],
['question_id', 'id'],
load='', # prevent computing display_names
)
for data in suggested_answers_data:
if not data.get('id'):
continue
correct_answers.setdefault(data['question_id'], []).append(data['id'])
# Numerical box, date, datetime
for question in self - choices_questions:
if question.question_type not in ['numerical_box', 'date', 'datetime']:
continue
answer = question[f'answer_{question.question_type}']
if question.question_type == 'date':
answer = tools.format_date(self.env, answer)
elif question.question_type == 'datetime':
answer = tools.format_datetime(self.env, answer, tz='UTC', dt_format=False)
correct_answers[question.id] = answer
return correct_answers
class SurveyQuestionAnswer(models.Model):
""" A preconfigured answer for a question. This model stores values used
@ -604,25 +843,79 @@ class SurveyQuestionAnswer(models.Model):
"""
_name = 'survey.question.answer'
_rec_name = 'value'
_order = 'sequence, id'
_rec_names_search = ['question_id.title', 'value']
_order = 'question_id, sequence, id'
_description = 'Survey Label'
MAX_ANSWER_NAME_LENGTH = 90 # empirically tested in client dropdown
# 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_id = fields.Many2one('survey.question', string='Question', ondelete='cascade', index='btree_not_null')
matrix_question_id = fields.Many2one('survey.question', string='Question (as matrix row)', ondelete='cascade', index='btree_not_null')
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 = fields.Char('Suggested value', translate=True)
value_image = fields.Image('Image', max_width=1024, max_height=1024)
value_image_filename = fields.Char('Image Filename')
value_label = fields.Char('Value Label', compute='_compute_value_label',
help="Answer label as either the value itself if not empty "
"or a letter representing the index of the answer otherwise.")
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")
_value_not_empty = models.Constraint(
'CHECK (value IS NOT NULL OR value_image_filename IS NOT NULL)',
'Suggested answer value must not be empty (a text and/or an image must be provided).',
)
@api.depends('value_label', 'question_id.question_type', 'question_id.title', 'matrix_question_id')
def _compute_display_name(self):
"""Render an answer name as "Question title : Answer label value", making sure it is not too long.
Unless the answer is part of a matrix-type question, this implementation makes sure we have
at least 30 characters for the question title, then we elide it, leaving the rest of the
space for the answer.
"""
for answer in self:
answer_label = answer.value_label
if not answer.question_id or answer.question_id.question_type == 'matrix':
answer.display_name = answer_label
continue
title = answer.question_id.title or _("[Question Title]")
n_extra_characters = len(title) + len(answer_label) + 3 - self.MAX_ANSWER_NAME_LENGTH # 3 for `" : "`
if n_extra_characters <= 0:
answer.display_name = f'{title} : {answer_label}'
else:
answer.display_name = shorten(
f'{shorten(title, max(30, len(title) - n_extra_characters), placeholder="...")} : {answer_label}',
self.MAX_ANSWER_NAME_LENGTH,
placeholder="..."
)
@api.depends('question_id.suggested_answer_ids', 'sequence', 'value')
def _compute_value_label(self):
""" Compute the label as the value if not empty or a letter representing the index of the answer otherwise. """
for answer in self:
# using image -> use a letter to represent the value
if not answer.value and answer.question_id and answer.id:
answer_idx = answer.question_id.suggested_answer_ids.ids.index(answer.id)
answer.value_label = chr(65 + answer_idx) if answer_idx < 27 else ''
else:
answer.value_label = answer.value or ''
@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."))
def _get_answer_matching_domain(self, row_id=False):
self.ensure_one()
if self.question_type == "matrix":
return ['&', '&', ('question_id', '=', self.question_id.id), ('matrix_row_id', '=', row_id), ('suggested_answer_id', '=', self.id)]
elif self.question_type in ('multiple_choice', 'simple_choice'):
return ['&', ('question_id', '=', self.question_id.id), ('suggested_answer_id', '=', self.id)]
return []

View file

@ -1,18 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import random
import uuid
from collections import defaultdict
import werkzeug
from odoo import api, exceptions, fields, models, _
from odoo.exceptions import AccessError, UserError
from odoo.osv import expression
from odoo.tools import clean_context, is_html_empty
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.fields import Domain
from odoo.tools import is_html_empty
from odoo.tools.urls import urljoin as url_join
class Survey(models.Model):
class SurveySurvey(models.Model):
""" Settings for a multi-page/multi-question survey. Each survey can have one or more attached pages
and each page can display one or more questions. """
_name = 'survey.survey'
@ -21,40 +22,35 @@ class Survey(models.Model):
_rec_name = 'title'
_inherit = ['mail.thread', 'mail.activity.mixin']
@api.model
def _get_default_access_token(self):
return str(uuid.uuid4())
def _get_default_session_code(self):
""" Attempt to generate a session code for our survey.
The method will first try to generate 20 codes with 4 digits each and check if any are colliding.
If we have at least one non-colliding code, we use it.
If all 20 generated codes are colliding, we try with 20 codes of 5 digits,
then 6, ... up to 10 digits. """
for digits_count in range(4, 10):
range_lower_bound = 1 * (10 ** (digits_count - 1))
range_upper_bound = (range_lower_bound * 10) - 1
code_candidates = set([str(random.randint(range_lower_bound, range_upper_bound)) for i in range(20)])
colliding_codes = self.sudo().search_read(
[('session_code', 'in', list(code_candidates))],
['session_code']
)
code_candidates -= set([colliding_code['session_code'] for colliding_code in colliding_codes])
if code_candidates:
return list(code_candidates)[0]
return False # could not generate a code
@api.model
def default_get(self, fields_list):
result = super().default_get(fields_list)
def default_get(self, fields):
result = super().default_get(fields)
# allows to propagate the text one write in a many2one widget after
# clicking on 'Create and Edit...' to the popup form.
if 'title' in fields_list and not result.get('title') and self.env.context.get('default_name'):
if 'title' in fields and not result.get('title') and self.env.context.get('default_name'):
result['title'] = self.env.context.get('default_name')
return result
# description
survey_type = fields.Selection([
('survey', 'Survey'),
('live_session', 'Live session'),
('assessment', 'Assessment'),
('custom', 'Custom'),
],
string='Survey Type', required=True, default='custom')
lang_ids = fields.Many2many(
'res.lang', string='Languages',
default=lambda self: self.env['res.lang']._lang_get(
self.env.context.get('lang') or self.env['res.lang'].get_installed()[0][0]),
domain=lambda self: [('id', 'in', [lang.id for lang in self.env['res.lang']._get_active_by('code').values()])],
help="Leave the field empty to support all installed languages."
)
allowed_survey_types = fields.Json(string='Allowed survey types', compute="_compute_allowed_survey_types")
title = fields.Char('Survey Title', required=True, translate=True)
color = fields.Integer('Color Index', default=0)
description = fields.Html(
@ -68,8 +64,9 @@ class Survey(models.Model):
active = fields.Boolean("Active", default=True)
user_id = fields.Many2one(
'res.users', string='Responsible',
domain=[('share', '=', False)], tracking=True,
domain=[('share', '=', False)], tracking=1,
default=lambda self: self.env.user)
restrict_user_ids = fields.Many2many('res.users', string='Restricted to', domain=[('share', '=', False)], tracking=2)
# questions
question_and_page_ids = fields.One2many('survey.question', 'survey_id', string='Sections and Questions', copy=True)
page_ids = fields.One2many('survey.question', string='Pages', compute="_compute_page_and_question_ids")
@ -90,7 +87,7 @@ class Survey(models.Model):
('number', 'Number')], string='Display Progress as', default='percent',
help="If Number is selected, it will display the number of questions answered on the total number of question to answer.")
# attendees
user_input_ids = fields.One2many('survey.user_input', 'survey_id', string='User responses', readonly=True, groups='survey.group_survey_user')
user_input_ids = fields.One2many('survey.user_input', 'survey_id', string='User responses', readonly=True)
# security / access
access_mode = fields.Selection([
('public', 'Anyone with the link'),
@ -110,10 +107,12 @@ class Survey(models.Model):
# scoring
scoring_type = fields.Selection([
('no_scoring', 'No scoring'),
('scoring_with_answers_after_page', 'Scoring with answers after each page'),
('scoring_with_answers', 'Scoring with answers at the end'),
('scoring_without_answers', 'Scoring without answers at the end')],
string="Scoring", required=True, store=True, readonly=False, compute="_compute_scoring_type", precompute=True)
('scoring_without_answers', 'Scoring without answers')],
string='Scoring', required=True, store=True, readonly=False, compute='_compute_scoring_type', precompute=True)
scoring_success_min = fields.Float('Required Score (%)', default=80.0)
scoring_max_obtainable = fields.Float('Maximum obtainable score', compute='_compute_scoring_max_obtainable')
# attendees context: attempts and time limitation
is_attempts_limited = fields.Boolean('Limited number of attempts', help="Check this option if you want to limit the number of attempts per user",
compute="_compute_is_attempts_limited", store=True, readonly=False)
@ -142,14 +141,16 @@ class Survey(models.Model):
# So it can be edited but not removed or replaced.
certification_give_badge = fields.Boolean('Give Badge', compute='_compute_certification_give_badge',
readonly=False, store=True, copy=False)
certification_badge_id = fields.Many2one('gamification.badge', 'Certification Badge', copy=False)
certification_badge_id = fields.Many2one('gamification.badge', 'Certification Badge', copy=False, index='btree_not_null')
certification_badge_id_dummy = fields.Many2one(related='certification_badge_id', string='Certification Badge ')
# live sessions
session_available = fields.Boolean('Live session available', compute='_compute_session_available')
session_state = fields.Selection([
('ready', 'Ready'),
('in_progress', 'In Progress'),
], string="Session State", copy=False)
session_code = fields.Char('Session Code', default=lambda self: self._get_default_session_code(), copy=False,
session_code = fields.Char('Session Code', copy=False, compute="_compute_session_code",
precompute=True, store=True, readonly=False,
help="This code will be used by your attendees to reach your session. Feel free to customize it however you like!")
session_link = fields.Char('Session Link', compute='_compute_session_link')
# live sessions - current question fields
@ -164,29 +165,63 @@ class Survey(models.Model):
session_show_leaderboard = fields.Boolean("Show Session Leaderboard", compute='_compute_session_show_leaderboard',
help="Whether or not we want to show the attendees leaderboard for this survey.")
session_speed_rating = fields.Boolean("Reward quick answers", help="Attendees get more points if they answer quickly")
session_speed_rating_time_limit = fields.Integer(
"Time limit (seconds)", help="Default time given to receive additional points for right answers")
# conditional questions management
has_conditional_questions = fields.Boolean("Contains conditional questions", compute="_compute_has_conditional_questions")
_sql_constraints = [
('access_token_unique', 'unique(access_token)', 'Access token should be unique'),
('session_code_unique', 'unique(session_code)', 'Session code should be unique'),
('certification_check', "CHECK( scoring_type!='no_scoring' OR certification=False )",
'You can only create certifications for surveys that have a scoring mechanism.'),
('scoring_success_min_check', "CHECK( scoring_success_min IS NULL OR (scoring_success_min>=0 AND scoring_success_min<=100) )",
'The percentage of success has to be defined between 0 and 100.'),
('time_limit_check', "CHECK( (is_time_limited=False) OR (time_limit is not null AND time_limit > 0) )",
'The time limit needs to be a positive number if the survey is time limited.'),
('attempts_limit_check', "CHECK( (is_attempts_limited=False) OR (attempts_limit is not null AND attempts_limit > 0) )",
'The attempts limit needs to be a positive number if the survey has a limited number of attempts.'),
('badge_uniq', 'unique (certification_badge_id)', "The badge for each survey should be unique!"),
]
_access_token_unique = models.Constraint(
'unique(access_token)',
'Access token should be unique',
)
_session_code_unique = models.Constraint(
'unique(session_code)',
'Session code should be unique',
)
_certification_check = models.Constraint(
"CHECK( scoring_type!='no_scoring' OR certification=False )",
'You can only create certifications for surveys that have a scoring mechanism.',
)
_scoring_success_min_check = models.Constraint(
'CHECK( scoring_success_min IS NULL OR (scoring_success_min>=0 AND scoring_success_min<=100) )',
'The percentage of success has to be defined between 0 and 100.',
)
_time_limit_check = models.Constraint(
'CHECK( (is_time_limited=False) OR (time_limit is not null AND time_limit > 0) )',
'The time limit needs to be a positive number if the survey is time limited.',
)
_attempts_limit_check = models.Constraint(
'CHECK( (is_attempts_limited=False) OR (attempts_limit is not null AND attempts_limit > 0) )',
'The attempts limit needs to be a positive number if the survey has a limited number of attempts.',
)
_badge_uniq = models.Constraint(
'unique (certification_badge_id)',
'The badge for each survey should be unique!',
)
_session_speed_rating_has_time_limit = models.Constraint(
'CHECK (session_speed_rating != TRUE OR session_speed_rating_time_limit IS NOT NULL AND session_speed_rating_time_limit > 0)',
'A positive default time limit is required when the session rewards quick answers.',
)
@api.depends('background_image', 'access_token')
def _compute_background_image_url(self):
self.background_image_url = False
for survey in self.filtered(lambda survey: survey.background_image and survey.access_token):
for survey in self.filtered(lambda s: s.background_image and s.access_token):
survey.background_image_url = "/survey/%s/get_background_image" % survey.access_token
@api.depends(
'question_and_page_ids',
'question_and_page_ids.suggested_answer_ids',
'question_and_page_ids.suggested_answer_ids.answer_score',
)
def _compute_scoring_max_obtainable(self):
for survey in self:
survey.scoring_max_obtainable = sum(
question.answer_score
or sum(answer.answer_score for answer in question.suggested_answer_ids if answer.answer_score > 0)
for question in survey.question_ids
)
def _compute_users_can_signup(self):
signup_allowed = self.env['res.users'].sudo()._get_signup_invitation_scope() == 'b2c'
for survey in self:
@ -202,14 +237,14 @@ class Survey(models.Model):
UserInput = self.env['survey.user_input']
base_domain = [('survey_id', 'in', self.ids)]
read_group_res = UserInput._read_group(base_domain, ['survey_id', 'state'], ['survey_id', 'state', 'scoring_percentage', 'scoring_success'], lazy=False)
for item in read_group_res:
stat[item['survey_id'][0]]['answer_count'] += item['__count']
stat[item['survey_id'][0]]['answer_score_avg_total'] += item['scoring_percentage']
if item['state'] == 'done':
stat[item['survey_id'][0]]['answer_done_count'] += item['__count']
if item['scoring_success']:
stat[item['survey_id'][0]]['success_count'] += item['__count']
read_group_res = UserInput._read_group(base_domain, ['survey_id', 'state', 'scoring_percentage', 'scoring_success'], ['__count'])
for survey, state, scoring_percentage, scoring_success, count in read_group_res:
stat[survey.id]['answer_count'] += count
stat[survey.id]['answer_score_avg_total'] += scoring_percentage
if state == 'done':
stat[survey.id]['answer_done_count'] += count
if scoring_success:
stat[survey.id]['success_count'] += count
for survey_stats in stat.values():
avg_total = survey_stats.pop('answer_score_avg_total')
@ -239,7 +274,6 @@ class Survey(models.Model):
# as avg returns None if nothing found, set 0 if it's the case.
survey.answer_duration_avg = (result_per_survey_id.get(survey.id) or 0) / 3600
@api.depends('question_and_page_ids')
def _compute_page_and_question_ids(self):
for survey in self:
@ -247,11 +281,12 @@ class Survey(models.Model):
survey.question_ids = survey.question_and_page_ids - survey.page_ids
survey.question_count = len(survey.question_ids)
@api.depends('users_login_required', 'access_mode')
@api.depends('question_and_page_ids.triggering_answer_ids', 'users_login_required', 'access_mode')
def _compute_is_attempts_limited(self):
for survey in self:
if not survey.is_attempts_limited or \
(survey.access_mode == 'public' and not survey.users_login_required):
(survey.access_mode == 'public' and not survey.users_login_required) or \
any(question.triggering_answer_ids for question in survey.question_and_page_ids):
survey.is_attempts_limited = False
@api.depends('session_start_time', 'user_input_ids')
@ -261,18 +296,13 @@ class Survey(models.Model):
context of sessions, so it should not matter too much. """
for survey in self:
answer_count = 0
input_count = self.env['survey.user_input']._read_group(
[answer_count] = self.env['survey.user_input']._read_group(
[('survey_id', '=', survey.id),
('is_session_answer', '=', True),
('state', '!=', 'done'),
('create_date', '>=', survey.session_start_time)],
['create_uid:count'],
['survey_id'],
)
if input_count:
answer_count = input_count[0].get('create_uid', 0)
aggregates=['create_uid:count'],
)[0]
survey.session_answer_count = answer_count
@api.depends('session_question_id', 'session_start_time', 'user_input_ids.user_input_line_ids')
@ -282,28 +312,33 @@ class Survey(models.Model):
This field is currently used to display the count about a single survey, in the
context of sessions, so it should not matter too much. """
for survey in self:
answer_count = 0
input_line_count = self.env['survey.user_input.line']._read_group(
[answer_count] = self.env['survey.user_input.line']._read_group(
[('question_id', '=', survey.session_question_id.id),
('survey_id', '=', survey.id),
('create_date', '>=', survey.session_start_time)],
['user_input_id:count_distinct'],
['question_id'],
)
if input_line_count:
answer_count = input_line_count[0].get('user_input_id', 0)
aggregates=['user_input_id:count_distinct'],
)[0]
survey.session_question_answer_count = answer_count
@api.depends('access_token')
def _compute_session_code(self):
survey_without_session_code = self.filtered(lambda survey: not survey.session_code)
session_codes = self._generate_session_codes(
code_count=len(survey_without_session_code),
excluded_codes=set((self - survey_without_session_code).mapped('session_code'))
)
for survey, session_code in zip(survey_without_session_code, session_codes):
survey.session_code = session_code
@api.depends('session_code')
def _compute_session_link(self):
for survey in self:
if survey.session_code:
survey.session_link = werkzeug.urls.url_join(
survey.session_link = url_join(
survey.get_base_url(),
'/s/%s' % survey.session_code)
else:
survey.session_link = werkzeug.urls.url_join(
survey.session_link = url_join(
survey.get_base_url(),
survey.get_start_url())
@ -313,10 +348,10 @@ class Survey(models.Model):
survey.session_show_leaderboard = survey.scoring_type != 'no_scoring' and \
any(question.save_as_nickname for question in survey.question_and_page_ids)
@api.depends('question_and_page_ids.is_conditional')
@api.depends('question_and_page_ids.triggering_answer_ids')
def _compute_has_conditional_questions(self):
for survey in self:
survey.has_conditional_questions = any(question.is_conditional for question in survey.question_and_page_ids)
survey.has_conditional_questions = any(question.triggering_answer_ids for question in survey.question_and_page_ids)
@api.depends('scoring_type')
def _compute_certification(self):
@ -335,72 +370,163 @@ class Survey(models.Model):
@api.depends('certification')
def _compute_scoring_type(self):
for survey in self:
if survey.certification and survey.scoring_type not in ['scoring_without_answers', 'scoring_with_answers']:
if survey.certification and survey.scoring_type in {False, 'no_scoring'}:
survey.scoring_type = 'scoring_without_answers'
elif not survey.scoring_type:
survey.scoring_type = 'no_scoring'
@api.depends('survey_type', 'certification')
def _compute_session_available(self):
for survey in self:
survey.session_available = survey.survey_type in {'live_session', 'custom'} and not survey.certification
@api.depends_context('uid')
def _compute_allowed_survey_types(self):
self.allowed_survey_types = [
'survey',
'live_session',
'assessment',
'custom',
] if self.env.user.has_group('survey.group_survey_user') else False
@api.onchange('survey_type')
def _onchange_survey_type(self):
if self.survey_type == 'survey':
self.write({
'certification': False,
'is_time_limited': False,
'scoring_type': 'no_scoring',
})
elif self.survey_type == 'live_session':
self.write({
'access_mode': 'public',
'is_attempts_limited': False,
'is_time_limited': False,
'progression_mode': 'percent',
'questions_layout': 'page_per_question',
'questions_selection': 'all',
'scoring_type': 'scoring_with_answers',
'users_can_go_back': False,
})
elif self.survey_type == 'assessment':
self.write({
'access_mode': 'token',
'scoring_type': 'scoring_with_answers',
})
@api.onchange('session_speed_rating', 'session_speed_rating_time_limit')
def _onchange_session_speed_rating(self):
"""Show impact on questions in the form view (before survey is saved)."""
for survey in self.filtered('question_ids'):
survey.question_ids._update_time_limit_from_survey(
is_time_limited=survey.session_speed_rating, time_limit=survey.session_speed_rating_time_limit)
@api.onchange('restrict_user_ids', 'user_id')
def _onchange_restrict_user_ids(self):
"""
Add survey user_id to restrict_user_ids when:
- restrict_user_ids is not False
- user_id is not part of restrict_user_ids
- user_id is not a survey manager
"""
surveys_to_check = self.filtered(lambda s: s.restrict_user_ids and bool(s.user_id - s.restrict_user_ids))
users_are_managers = surveys_to_check.user_id.filtered(lambda user: user.has_group('survey.group_survey_manager'))
for survey in surveys_to_check.filtered(lambda s: s.user_id not in users_are_managers):
survey.restrict_user_ids += survey.user_id
@api.constrains('scoring_type', 'users_can_go_back')
def _check_scoring_after_page_availability(self):
failing = self.filtered(lambda survey: survey.scoring_type == 'scoring_with_answers_after_page' and survey.users_can_go_back)
if failing:
raise ValidationError(
_('Combining roaming and "Scoring with answers after each page" is not possible; please update the following surveys:\n- %(survey_names)s',
survey_names="\n- ".join(failing.mapped('title')))
)
@api.constrains('user_id', 'restrict_user_ids')
def _check_survey_responsible_access(self):
""" When:
- a survey access is restricted to a list of users
- and there is a survey responsible,
- and this responsible is not survey manager (just survey officer),
check the responsible is part of the list."""
for user_id, surveys in self.filtered(lambda s: bool(s.user_id - s.restrict_user_ids)).grouped('user_id').items():
accessible = surveys.with_user(user_id)._filtered_access("write")
if len(accessible) < len(surveys):
failing_surveys_sudo = (self - accessible).sudo()
raise ValidationError(
_('The access of the following surveys is restricted. Make sure their responsible still has access to it: \n%(survey_names)s\n',
survey_names='\n'.join(f'- {survey.title}: {survey.user_id.name}' for survey in failing_surveys_sudo)))
# ------------------------------------------------------------
# CRUD
# ------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
surveys = super(Survey, self).create(vals_list)
surveys = super().create(vals_list)
for survey_sudo in surveys.filtered(lambda survey: survey.certification_give_badge).sudo():
survey_sudo._create_certification_badge_trigger()
return surveys
def write(self, vals):
result = super(Survey, self).write(vals)
speed_rating, speed_limit = vals.get('session_speed_rating'), vals.get('session_speed_rating_time_limit')
surveys_to_update = self.filtered(lambda s: (
speed_rating is not None and s.session_speed_rating != speed_rating
or speed_limit is not None and s.session_speed_rating_time_limit != speed_limit
))
result = super().write(vals)
if 'certification_give_badge' in vals:
return self.sudo().with_context(clean_context(self._context))._handle_certification_badges(vals)
return self.sudo()._handle_certification_badges(vals)
if questions_to_update := surveys_to_update.question_ids:
questions_to_update._update_time_limit_from_survey(is_time_limited=speed_rating, time_limit=speed_limit)
return result
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
""" Correctly copy the 'triggering_question_id' and 'triggering_answer_id' fields from the original
to the clone.
This needs to be done in post-processing to make sure we get references to the newly created
answers/questions from the copy instead of references to the answers/questions of the original.
This implementation assumes that the order of created questions/answers will be kept between
"""Correctly copy the 'triggering_answer_ids' field from the original to the clone.
This needs to be done in post-processing to make sure we get references to the newly
created answers from the copy instead of references to the answers of the original.
This implementation assumes that the order of created answers will be kept between
the original and the clone, using 'zip()' to match the records between the two.
Note that when question_ids is provided in the default parameter, it falls back to the standard copy.
Note that when `question_ids` is provided in the default parameter, it falls back to the
standard copy, meaning that triggering logic will not be maintained.
"""
self.ensure_one()
clone = super(Survey, self).copy(default)
new_surveys = super().copy(default)
if default and 'question_ids' in default:
return clone
return new_surveys
src_questions = self.question_ids
dst_questions = clone.question_ids.sorted()
for old_survey, new_survey in zip(self, new_surveys):
cloned_question_ids = new_survey.question_ids.sorted()
questions_map = {src.id: dst.id for src, dst in zip(src_questions, dst_questions)}
answers_map = {
src_answer.id: dst_answer.id
for src, dst
in zip(src_questions, dst_questions)
for src_answer, dst_answer
in zip(src.suggested_answer_ids, dst.suggested_answer_ids.sorted())
}
for src, dst in zip(src_questions, dst_questions):
if src.is_conditional:
dst.triggering_question_id = questions_map.get(src.triggering_question_id.id)
dst.triggering_answer_id = answers_map.get(src.triggering_answer_id.id)
return clone
answers_map = {
src_answer.id: dst_answer.id
for src, dst
in zip(old_survey.question_ids, cloned_question_ids)
for src_answer, dst_answer
in zip(src.suggested_answer_ids, dst.suggested_answer_ids.sorted())
}
for src, dst in zip(old_survey.question_ids, cloned_question_ids):
if src.triggering_answer_ids:
dst.triggering_answer_ids = [answers_map[src_answer_id.id] for src_answer_id in src.triggering_answer_ids]
return new_surveys
def copy_data(self, default=None):
new_defaults = {'title': _("%s (copy)") % (self.title)}
default = dict(new_defaults, **(default or {}))
return super(Survey, self).copy_data(default)
vals_list = super().copy_data(default=default)
return [dict(vals, title=self.env._("%s (copy)", survey.title)) for survey, vals in zip(self, vals_list)]
def toggle_active(self):
super(Survey, self).toggle_active()
activated = self.filtered(lambda survey: survey.active)
activated.mapped('certification_badge_id').action_unarchive()
(self - activated).mapped('certification_badge_id').action_archive()
def action_archive(self):
super().action_archive()
self.certification_badge_id.action_archive()
def action_unarchive(self):
super().action_unarchive()
self.certification_badge_id.action_unarchive()
# ------------------------------------------------------------
# ANSWER MANAGEMENT
@ -408,15 +534,13 @@ class Survey(models.Model):
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
security.
does check for current user access in order to explicitly validate security.
:param user: target user asking for a token; it might be void or a
public user in which case an email is welcomed;
:param email: email of the person asking the token is no user exists;
"""
self.check_access_rights('read')
self.check_access_rule('read')
self.check_access('read')
user_inputs = self.env['survey.user_input']
for survey in self:
@ -424,6 +548,7 @@ class Survey(models.Model):
user = partner.user_ids[0]
invite_token = additional_vals.pop('invite_token', False)
nickname = additional_vals.pop('nickname', False)
survey._check_answer_creation(user, partner, email, test_entry=test_entry, check_attempts=check_attempts, invite_token=invite_token)
answer_vals = {
'survey_id': survey.id,
@ -439,14 +564,14 @@ class Survey(models.Model):
if user and not user._is_public():
answer_vals['partner_id'] = user.partner_id.id
answer_vals['email'] = user.email
answer_vals['nickname'] = user.name
answer_vals['nickname'] = nickname or user.name
elif partner:
answer_vals['partner_id'] = partner.id
answer_vals['email'] = partner.email
answer_vals['nickname'] = partner.name
answer_vals['nickname'] = nickname or partner.name
else:
answer_vals['email'] = email
answer_vals['nickname'] = email
answer_vals['nickname'] = nickname
if invite_token:
answer_vals['invite_token'] = invite_token
@ -463,9 +588,9 @@ class Survey(models.Model):
lambda q: q.question_type == 'char_box' and (q.save_as_email or q.save_as_nickname)):
for user_input in user_inputs:
if question.save_as_email and user_input.email:
user_input.save_lines(question, user_input.email)
user_input._save_lines(question, user_input.email)
if question.save_as_nickname and user_input.nickname:
user_input.save_lines(question, user_input.nickname)
user_input._save_lines(question, user_input.nickname)
return user_inputs
@ -473,10 +598,12 @@ class Survey(models.Model):
""" Ensure conditions to create new tokens are met. """
self.ensure_one()
if test_entry:
# the current user must have the access rights to survey
if not user.has_group('survey.group_survey_user'):
try:
self.with_user(user).check_access('read')
except AccessError:
raise exceptions.UserError(_('Creating test token is not allowed for you.'))
else:
if not test_entry:
if not self.active:
raise exceptions.UserError(_('Creating token for closed/archived surveys is not allowed.'))
if self.access_mode == 'authentication':
@ -510,7 +637,7 @@ class Survey(models.Model):
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:
if 0 < page.random_questions_count < len(page.question_ids):
questions = questions.concat(*random.sample(page.question_ids, page.random_questions_count))
else:
questions |= page.question_ids
@ -529,17 +656,13 @@ class Survey(models.Model):
(pages are displayed in 'page_per_question' layout when they have a description, see PR#44271)
"""
self.ensure_one()
if self.users_can_go_back and answer.state == 'in_progress':
if self.questions_layout == 'page_per_section' and page_or_question != self.page_ids[0]:
return True
elif self.questions_layout == 'page_per_question' and \
not answer.is_session_answer and \
page_or_question != answer.predefined_question_ids[0] \
and (not self.page_ids or page_or_question != self.page_ids[0]):
return True
return False
if self.questions_layout == "one_page" or not self.users_can_go_back:
return False
if answer.state != 'in_progress' or answer.is_session_answer:
return False
if self.page_ids and page_or_question == self.page_ids[0]:
return False
return self.questions_layout == 'page_per_section' or page_or_question != answer.predefined_question_ids[0]
def _has_attempts_left(self, partner, email, invite_token):
self.ensure_one()
@ -553,19 +676,19 @@ class Survey(models.Model):
""" Returns the number of attempts left. """
self.ensure_one()
domain = [
domain = Domain([
('survey_id', '=', self.id),
('test_entry', '=', False),
('state', '=', 'done')
]
])
if partner:
domain = expression.AND([domain, [('partner_id', '=', partner.id)]])
domain &= Domain('partner_id', '=', partner.id)
else:
domain = expression.AND([domain, [('email', '=', email)]])
domain &= Domain('email', '=', email)
if invite_token:
domain = expression.AND([domain, [('invite_token', '=', invite_token)]])
domain &= Domain('invite_token', '=', invite_token)
return self.attempts_limit - self.env['survey.user_input'].search_count(domain)
@ -591,22 +714,31 @@ class Survey(models.Model):
return result
def _get_pages_and_questions_to_show(self):
"""
:return: survey.question recordset excluding invalid conditional questions and pages without description
"""
"""Filter question_and_pages_ids to include only valid pages and questions.
Pages are invalid if they have no description. Questions are invalid if
they are conditional and all their triggers are invalid.
Triggers are invalid if they:
- Are a page (not a question)
- Have the wrong question type (`simple_choice` and `multiple_choice` are supported)
- Are misplaced (positioned after the conditional question)
- They are themselves conditional and were found invalid
"""
self.ensure_one()
invalid_questions = self.env['survey.question']
questions_and_valid_pages = self.question_and_page_ids.filtered(
lambda question: not question.is_page or not is_html_empty(question.description))
for question in questions_and_valid_pages.filtered(lambda q: q.is_conditional).sorted():
trigger = question.triggering_question_id
if (trigger in invalid_questions
or trigger.is_page
or trigger.question_type not in ['simple_choice', 'multiple_choice']
or not trigger.suggested_answer_ids
or trigger.sequence > question.sequence
or (trigger.sequence == question.sequence and trigger.id > question.id)):
for question in questions_and_valid_pages.filtered('triggering_answer_ids').sorted():
for trigger in question.triggering_question_ids:
if (trigger not in invalid_questions
and not trigger.is_page
and trigger.question_type in ['simple_choice', 'multiple_choice']
and (trigger.sequence < question.sequence
or (trigger.sequence == question.sequence and trigger.id < question.id))):
break
else:
# No valid trigger found
invalid_questions |= question
return questions_and_valid_pages - invalid_questions
@ -650,7 +782,6 @@ class Survey(models.Model):
return Question
# Conditional Questions Management
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values()
inactive_questions = user_input._get_inactive_conditional_questions()
if survey.questions_layout == 'page_per_question':
question_candidates = pages_or_questions[0:current_page_index] if go_back \
@ -663,8 +794,7 @@ class Survey(models.Model):
if contains_active_question or is_description_section:
return question
else:
triggering_answer = triggering_answer_by_question.get(question)
if not triggering_answer or triggering_answer in selected_answers:
if question not in inactive_questions:
# question is visible because not conditioned or conditioned by a selected answer
return question
elif survey.questions_layout == 'page_per_section':
@ -681,7 +811,7 @@ class Survey(models.Model):
""" This method checks if the given question or page is the first one to display.
If the first section of the survey as a description, this will be the first screen to display.
else, the first question will be the first screen to be displayed.
This methods is used for survey session management where the host should not be able to go back on the
This method is used for survey session management where the host should not be able to go back on the
first page or question."""
first_section_has_description = self.page_ids and not is_html_empty(self.page_ids[0].description)
is_first_page_or_question = (first_section_has_description and page_or_question == self.page_ids[0]) or \
@ -689,38 +819,30 @@ class Survey(models.Model):
return is_first_page_or_question
def _is_last_page_or_question(self, user_input, page_or_question):
""" This method checks if the given question or page is the last one.
This includes conditional questions configuration. If the given question is normally not the last one but
every following questions are inactive due to conditional questions configurations (and user choices),
the given question will be the last one, except if the given question is conditioning at least
one of the following questions.
For section, we check in each following section if there is an active question.
If yes, the given page is not the last one.
""" Check if the given question or page is the last one, accounting for conditional questions.
A question/page will be determined as the last one if any of the following is true:
- The survey layout is "one_page",
- There are no more questions/page after `page_or_question` in `user_input`,
- All the following questions are conditional AND were not triggered by previous answers.
Not accounting for the question/page own conditionals.
"""
if self.questions_layout == "one_page":
return True
pages_or_questions = self._get_pages_or_questions(user_input)
current_page_index = pages_or_questions.ids.index(page_or_question.id)
next_page_or_question_candidates = pages_or_questions[current_page_index + 1:]
if next_page_or_question_candidates:
inactive_questions = user_input._get_inactive_conditional_questions()
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = user_input._get_conditional_values()
if self.questions_layout == 'page_per_question':
next_active_question = any(next_question not in inactive_questions for next_question in next_page_or_question_candidates)
is_triggering_question = any(triggering_answer in triggered_questions_by_answer.keys() for triggering_answer in page_or_question.suggested_answer_ids)
return not(next_active_question or is_triggering_question)
elif self.questions_layout == 'page_per_section':
is_triggering_section = False
for question in page_or_question.question_ids:
if any(triggering_answer in triggered_questions_by_answer.keys() for triggering_answer in
question.suggested_answer_ids):
is_triggering_section = True
break
next_active_question = False
for section in next_page_or_question_candidates:
next_active_question = any(next_question not in inactive_questions for next_question in section.question_ids)
if next_active_question:
break
return not(next_active_question or is_triggering_section)
if not next_page_or_question_candidates:
return True
inactive_questions = user_input._get_inactive_conditional_questions()
if self.questions_layout == 'page_per_question':
return not (
any(next_question not in inactive_questions for next_question in next_page_or_question_candidates)
)
elif self.questions_layout == 'page_per_section':
for section in next_page_or_question_candidates:
if any(next_question not in inactive_questions for next_question in section.question_ids):
return False
return True
def _get_survey_questions(self, answer=None, page_id=None, question_id=None):
@ -739,24 +861,21 @@ class Survey(models.Model):
In addition, we cross the returned questions with the answer.predefined_question_ids,
that allows to handle the randomization of questions. """
questions, page_or_question_id = None, None
if answer and answer.is_session_answer:
return self.session_question_id, self.session_question_id.id
if self.questions_layout == 'page_per_section':
if not page_id:
raise ValueError("Page id is needed for question layout 'page_per_section'")
page_id = int(page_id)
questions = self.env['survey.question'].sudo().search([('survey_id', '=', self.id), ('page_id', '=', page_id)])
page_or_question_id = page_id
page_or_question_id = int(page_id)
questions = self.env['survey.question'].sudo().search(
Domain('survey_id', '=', self.id) & Domain('page_id', '=', page_or_question_id))
elif self.questions_layout == 'page_per_question':
if not question_id:
raise ValueError("Question id is needed for question layout 'page_per_question'")
question_id = int(question_id)
questions = self.env['survey.question'].sudo().browse(question_id)
page_or_question_id = question_id
page_or_question_id = int(question_id)
questions = self.env['survey.question'].sudo().browse(page_or_question_id)
else:
page_or_question_id = None
questions = self.question_ids
# we need the intersection of the questions of this page AND the questions prepared for that user_input
@ -770,17 +889,15 @@ class Survey(models.Model):
# ------------------------------------------------------------
def _get_conditional_maps(self):
triggering_answer_by_question = {}
triggered_questions_by_answer = {}
triggering_answers_by_question = defaultdict(lambda: self.env['survey.question.answer'])
triggered_questions_by_answer = defaultdict(lambda: self.env['survey.question'])
for question in self.question_ids:
triggering_answer_by_question[question] = question.is_conditional and question.triggering_answer_id
triggering_answers_by_question[question] |= question.triggering_answer_ids
if question.is_conditional:
if question.triggering_answer_id in triggered_questions_by_answer:
triggered_questions_by_answer[question.triggering_answer_id] |= question
else:
triggered_questions_by_answer[question.triggering_answer_id] = question
return triggering_answer_by_question, triggered_questions_by_answer
for triggering_answer_id in question.triggering_answer_ids:
triggered_questions_by_answer[triggering_answer_id] |= question
return triggering_answers_by_question, triggered_questions_by_answer
# ------------------------------------------------------------
# SESSIONS MANAGEMENT
@ -812,8 +929,8 @@ class Survey(models.Model):
many users, we need to extract the most chosen answers, to determine the next questions to display. """
# get user_inputs from current session
current_user_inputs = self.user_input_ids.filtered(lambda input: input.create_date > self.session_start_time)
current_user_input_lines = current_user_inputs.mapped('user_input_line_ids').filtered(lambda answer: answer.suggested_answer_id)
current_user_inputs = self.user_input_ids.filtered(lambda ui: ui.create_date > self.session_start_time)
current_user_input_lines = current_user_inputs.user_input_line_ids.filtered('suggested_answer_id')
# count the number of vote per answer
votes_by_answer = dict.fromkeys(current_user_input_lines.mapped('suggested_answer_id'), 0)
@ -848,7 +965,7 @@ class Survey(models.Model):
return fake_user_input
def _prepare_leaderboard_values(self):
"""" The leaderboard is descending and takes the total of the attendee points minus the
""" The leaderboard is descending and takes the total of the attendee points minus the
current question score.
We need both the total and the current question points to be able to show the attendees
leaderboard and shift their position based on the score they have on the current question.
@ -900,17 +1017,20 @@ class Survey(models.Model):
return leaderboard
# ------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------
def action_send_survey(self):
""" Open a window to compose an email, pre-filled with the survey message """
def check_validity(self):
# Ensure that this survey has at least one question.
if not self.question_ids:
raise UserError(_('You cannot send an invitation for a survey that has no questions.'))
# Ensure scored survey have a positive total score obtainable.
if self.scoring_type != 'no_scoring' and self.scoring_max_obtainable <= 0:
raise UserError(_("A scored survey needs at least one question that gives points.\n"
"Please check answers and their scores."))
# Ensure that this survey has at least one section with question(s), if question layout is 'One page per section'.
if self.questions_layout == 'page_per_section':
if not self.page_ids:
@ -921,14 +1041,18 @@ class Survey(models.Model):
if not self.active:
raise exceptions.UserError(_("You cannot send invitations for closed surveys."))
def action_send_survey(self):
""" Open a window to compose an email, pre-filled with the survey message """
self.check_validity()
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,
default_email_layout_xmlid='mail.mail_notification_light',
default_send_email=(self.access_mode != 'public'),
)
return {
'type': 'ir.actions.act_window',
@ -957,7 +1081,7 @@ class Survey(models.Model):
return {
'type': 'ir.actions.act_url',
'name': "Print Survey",
'target': 'self',
'target': 'new',
'url': url
}
@ -1009,7 +1133,7 @@ class Survey(models.Model):
return {
'type': 'ir.actions.act_url',
'target': 'new',
'url': '/survey/%s/certification_preview' % (self.id)
'url': f'/survey/{self.id}/certification_preview'
}
def action_start_session(self):
@ -1076,31 +1200,20 @@ class Survey(models.Model):
('state', '=', 'done'),
('test_entry', '=', False)
]
count_data_success = self.env['survey.user_input'].sudo()._read_group(user_input_domain, ['scoring_success', 'id:count_distinct'], ['scoring_success'])
count_data_success = self.env['survey.user_input'].sudo()._read_group(user_input_domain, ['scoring_success'], ['__count'])
completed_count = self.env['survey.user_input'].sudo().search_count(user_input_domain + [('state', "=", "done")])
scoring_success_count = 0
scoring_failed_count = 0
for count_data_item in count_data_success:
if count_data_item['scoring_success']:
scoring_success_count += count_data_item['scoring_success_count']
for scoring_success, count in count_data_success:
if scoring_success:
scoring_success_count += count
else:
scoring_failed_count += count_data_item['scoring_success_count']
success_graph = json.dumps([{
'text': _('Passed'),
'count': scoring_success_count,
'color': '#2E7D32'
}, {
'text': _('Failed'),
'count': scoring_failed_count,
'color': '#C62828'
}])
scoring_failed_count += count
total = scoring_success_count + scoring_failed_count
return {
'global_success_rate': round((scoring_success_count / total) * 100, 1) if total > 0 else 0,
'global_success_graph': success_graph,
'count_all': total,
'count_finished': completed_count,
'count_failed': scoring_failed_count,
@ -1150,9 +1263,7 @@ class Survey(models.Model):
def _handle_certification_badges(self, vals):
if vals.get('certification_give_badge'):
# If badge already set on records, reactivate the ones that are not active.
surveys_with_badge = self.filtered(lambda survey: survey.certification_badge_id and not survey.certification_badge_id.active)
surveys_with_badge.mapped('certification_badge_id').action_unarchive()
self.certification_badge_id.action_unarchive()
# (re-)create challenge and goal
for survey in self:
survey._create_certification_badge_trigger()
@ -1165,3 +1276,38 @@ 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()
# ------------------------------------------------------------
# TOOLING / MISC
# ------------------------------------------------------------
def _generate_session_codes(self, code_count=1, excluded_codes=False):
""" Generate {code_count} session codes for surveys.
We try to generate 4 digits code first and see if we have {code_count} unique ones.
Then we raise up to 5 digits if we need more, etc until up to 10 digits.
(We generate an extra 20 codes per loop to try to mitigate back luck collisions). """
self.flush_model(['session_code'])
session_codes = set()
excluded_codes = excluded_codes or set()
existing_codes = self.sudo().search_read(
[('session_code', '!=', False)],
['session_code']
)
unavailable_codes = excluded_codes | {existing_code['session_code'] for existing_code in existing_codes}
for digits_count in range(4, 10):
range_lower_bound = 10 ** (digits_count - 1)
range_upper_bound = (range_lower_bound * 10) - 1
code_candidates = {str(random.randint(range_lower_bound, range_upper_bound)) for _ in range(code_count + 20)}
session_codes |= code_candidates - unavailable_codes
if len(session_codes) >= code_count:
return list(session_codes)[:code_count]
# could not generate enough codes, fill with False for remainder
return session_codes + [False] * (code_count - len(session_codes))
def _get_supported_lang_codes(self):
self.ensure_one()
return self.lang_ids.mapped('code') or [lg[0] for lg in self.env['res.lang'].get_installed()]

View file

@ -8,28 +8,29 @@ import uuid
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.exceptions import ValidationError, UserError
from odoo.tools import float_is_zero
_logger = logging.getLogger(__name__)
class SurveyUserInput(models.Model):
class SurveyUser_Input(models.Model):
""" Metadata for a set of one user's answers to a particular survey """
_name = "survey.user_input"
_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')
survey_id = fields.Many2one('survey.survey', string='Survey', required=True, readonly=True, index=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")
lang_id = fields.Many2one('res.lang', string='Language')
state = fields.Selection([
('new', 'Not started yet'),
('new', 'New'),
('in_progress', 'In Progress'),
('done', 'Completed')], string='Status', default='new', readonly=True)
test_entry = fields.Boolean(readonly=True)
@ -43,22 +44,24 @@ class SurveyUserInput(models.Model):
# 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)
partner_id = fields.Many2one('res.partner', string='Contact', readonly=True, index='btree_not_null')
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
scoring_total = fields.Float("Total Score", compute="_compute_scoring_values", store=True, compute_sudo=True, digits=(10, 2)) # stored for perf reasons
scoring_success = fields.Boolean('Quiz Passed', compute='_compute_scoring_success', store=True, compute_sudo=True) # stored for perf reasons
survey_first_submitted = fields.Boolean(string='Survey First Submitted')
# 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!'),
]
_unique_token = models.Constraint(
'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):
@ -168,7 +171,7 @@ class SurveyUserInput(models.Model):
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)
return super().create(vals_list)
# ------------------------------------------------------------
# ACTIONS / BUSINESS
@ -192,11 +195,14 @@ class SurveyUserInput(models.Model):
def action_print_answers(self):
""" Open the website page with the survey form """
self.ensure_one()
url = self.env['ir.http']._url_for(
'/survey/print/%s?answer_token=%s' % (self.survey_id.access_token, self.access_token),
self.lang_id.code or None)
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)
'url': url,
}
def action_redirect_to_attempts(self):
@ -234,14 +240,16 @@ class SurveyUserInput(models.Model):
- The survey is a certification
- It has a certification_mail_template_id set
- The user succeeded the test
3. Notify survey subtype subscribers of the newly completed input
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()
Challenge_sudo = self.env['gamification.challenge'].sudo()
badge_ids = []
self._notify_new_participation_subscribers()
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:
@ -253,9 +261,9 @@ class SurveyUserInput(models.Model):
user_input.predefined_question_ids -= user_input._get_inactive_conditional_questions()
if badge_ids:
challenges = Challenge.search([('reward_id', 'in', badge_ids)])
challenges = Challenge_sudo.search([('reward_id', 'in', badge_ids)])
if challenges:
Challenge._cron_update(ids=challenges.ids, commit=False)
Challenge_sudo._cron_update(ids=challenges.ids, commit=False)
def get_start_url(self):
self.ensure_one()
@ -269,18 +277,21 @@ class SurveyUserInput(models.Model):
# CREATE / UPDATE LINES FROM SURVEY FRONTEND INPUT
# ------------------------------------------------------------
def save_lines(self, question, answer, comment=None):
""" Save answers to questions, depending on question type
def _save_lines(self, question, answer, comment=None, overwrite_existing=True):
""" 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).
:param bool overwrite_existing: 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.
:raises UserError: if line exists and overwrite_existing is False
"""
old_answers = self.env['survey.user_input.line'].search([
('user_input_id', '=', self.id),
('question_id', '=', question.id)
])
if old_answers and not overwrite_existing:
raise UserError(_("This answer cannot be overwritten."))
if question.question_type in ['char_box', 'text_box', 'numerical_box', 'date', 'datetime']:
if question.question_type in ['char_box', 'text_box', 'scale', 'numerical_box', 'date', 'datetime']:
self._save_line_simple_answer(question, old_answers, answer)
if question.save_as_email and answer:
self.write({'email': answer})
@ -306,18 +317,12 @@ class SurveyUserInput(models.Model):
if not (isinstance(answers, list)):
answers = [answers]
if not answers:
if not answers and not (comment and question.comment_count_as_answer):
# 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]
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))
@ -361,6 +366,8 @@ class SurveyUserInput(models.Model):
vals['suggested_answer_id'] = int(answer)
elif answer_type == 'numerical_box':
vals['value_numerical_box'] = float(answer)
elif answer_type == 'scale':
vals['value_scale'] = int(answer)
else:
vals['value_%s' % answer_type] = answer
return vals
@ -430,14 +437,20 @@ class SurveyUserInput(models.Model):
scored_questions = self.mapped('predefined_question_ids').filtered(lambda question: question.is_scored_question)
for question in scored_questions:
if question.question_type == 'simple_choice':
question_incorrect_scored_answers = question.suggested_answer_ids.filtered(lambda answer: not answer.is_correct and answer.answer_score > 0)
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)
user_input_lines = user_input.user_input_line_ids.filtered(lambda line:
line.question_id == question and (line.answer_type != 'char_box' or question.comment_count_as_answer))
if question.question_type == 'simple_choice':
answer_result_key = self._simple_choice_question_answer_result(user_input_lines, question_correct_suggested_answers, question_incorrect_scored_answers)
elif question.question_type == 'multiple_choice':
answer_result_key = self._multiple_choice_question_answer_result(user_input_lines, question_correct_suggested_answers)
else:
answer_result_key = self._simple_question_answer_result(user_input_lines)
@ -474,7 +487,7 @@ class SurveyUserInput(models.Model):
return res
def _choice_question_answer_result(self, user_input_lines, question_correct_suggested_answers):
def _multiple_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:
@ -486,6 +499,17 @@ class SurveyUserInput(models.Model):
else:
return 'skipped'
def _simple_choice_question_answer_result(self, user_input_line, question_correct_suggested_answers, question_incorrect_scored_answers):
user_answer = user_input_line.suggested_answer_id if not user_input_line.skipped else self.env['survey.question.answer']
if user_answer in question_correct_suggested_answers:
return 'correct'
elif user_answer in question_incorrect_scored_answers:
return 'partial'
elif user_answer:
return 'incorrect'
else:
return 'skipped'
def _simple_question_answer_result(self, user_input_line):
if user_input_line.skipped:
return 'skipped'
@ -519,20 +543,21 @@ class SurveyUserInput(models.Model):
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)
- triggering_answers_by_question: dict -> for a given question, the answers that triggers it
Used mainly to ease template rendering
- 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
- list of all selected answers: [answer_id1, answer_id2, ...] (for survey reloading, otherwise, this list is
updated at client side)
"""
triggering_answer_by_question, triggered_questions_by_answer = {}, {}
triggering_answers_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()
triggering_answers_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
return triggering_answers_by_question, triggered_questions_by_answer, selected_answers
def _get_selected_suggested_answers(self):
"""
@ -568,14 +593,13 @@ class SurveyUserInput(models.Model):
answers_to_delete.unlink()
def _get_inactive_conditional_questions(self):
triggering_answer_by_question, triggered_questions_by_answer, selected_answers = self._get_conditional_values()
triggering_answers_by_question, _, 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
for question, triggering_answers in triggering_answers_by_question.items():
if triggering_answers and not triggering_answers & selected_answers:
inactive_questions |= question
return inactive_questions
def _get_print_questions(self):
@ -591,23 +615,82 @@ class SurveyUserInput(models.Model):
inactive_questions = self._get_inactive_conditional_questions()
return survey.question_ids - inactive_questions
def _get_next_skipped_page_or_question(self):
"""Get next skipped question or page in case the option 'can_go_back' is set on the survey
It loops to the first skipped question or page if 'last_displayed_page_id' is the last
skipped question or page."""
self.ensure_one()
skipped_mandatory_answer_ids = self.user_input_line_ids.filtered(
lambda answer: answer.skipped and answer.question_id.constr_mandatory)
if not skipped_mandatory_answer_ids:
return self.env['survey.question']
page_or_question_key = 'page_id' if self.survey_id.questions_layout == 'page_per_section' else 'question_id'
page_or_question_ids = skipped_mandatory_answer_ids.mapped(page_or_question_key).sorted()
if self.last_displayed_page_id not in page_or_question_ids\
or self.last_displayed_page_id == page_or_question_ids[-1]:
return page_or_question_ids[0]
current_page_index = page_or_question_ids.ids.index(self.last_displayed_page_id.id)
return page_or_question_ids[current_page_index + 1]
def _get_skipped_questions(self):
self.ensure_one()
return self.user_input_line_ids.filtered(
lambda answer: answer.skipped and answer.question_id.constr_mandatory).question_id
def _is_last_skipped_page_or_question(self, page_or_question):
"""In case of a submitted survey tells if the question or page is the last
skipped page or question.
This is used to :
- Display a Submit button if the actual question is the last skipped question.
- Avoid displaying a Submit button on the last survey question if there are
still skipped questions before.
- Avoid displaying the next page if submitting the latest skipped question.
:param page_or_question: page if survey's layout is page_per_section, question if page_per_question.
"""
if self.survey_id.questions_layout == 'one_page':
return True
skipped = self._get_skipped_questions()
if not skipped:
return True
if self.survey_id.questions_layout == 'page_per_section':
skipped = skipped.page_id
return skipped == page_or_question
# ------------------------------------------------------------
# MESSAGING
# ------------------------------------------------------------
def _message_get_suggested_recipients(self):
recipients = super()._message_get_suggested_recipients()
for user_input in self:
def _notify_new_participation_subscribers(self):
subtype_id = self.env.ref('survey.mt_survey_survey_user_input_completed', raise_if_not_found=False)
if not self.ids or not subtype_id:
return
author_id = self.env.ref('base.partner_root').id if self.env.user.is_public else self.env.user.partner_id.id
# Only post if there are any followers
recipients_data = self.env['mail.followers']._get_recipient_data(self.survey_id, 'notification', subtype_id.id)
followed_survey_ids = [survey_id for survey_id, followers in recipients_data.items() if followers]
for user_input in self.filtered(lambda user_input_: user_input_.survey_id.id in followed_survey_ids):
survey_title = user_input.survey_id.title
if user_input.partner_id:
user_input._message_add_suggested_recipient(
recipients,
partner=user_input.partner_id,
reason=_('Survey Participant')
body = _(
'%(participant)s just participated in "%(survey_title)s".',
participant=user_input.partner_id.display_name,
survey_title=survey_title,
)
return recipients
else:
body = _('Someone just participated in "%(survey_title)s".', survey_title=survey_title)
user_input.message_post(author_id=author_id, body=body, subtype_xmlid='survey.mt_survey_user_input_completed')
class SurveyUserInputLine(models.Model):
class SurveyUser_InputLine(models.Model):
_name = 'survey.user_input.line'
_description = 'Survey User Input Line'
_rec_name = 'user_input_id'
@ -616,30 +699,37 @@ class SurveyUserInputLine(models.Model):
# 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)
question_id = fields.Many2one('survey.question', string='Question', ondelete='cascade', required=True, index=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)
lang_id = fields.Many2one('res.lang', related="user_input_id.lang_id")
# answer
skipped = fields.Boolean('Skipped')
answer_type = fields.Selection([
('text_box', 'Free Text'),
('char_box', 'Text'),
('numerical_box', 'Number'),
('scale', '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_scale = fields.Integer('Scale value')
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')
answer_score = fields.Float('Score', compute='_compute_answer_score', precompute=True, store=True)
answer_is_correct = fields.Boolean('Correct', compute='_compute_answer_score', precompute=True, store=True)
@api.depends('answer_type')
@api.depends(
'answer_type', 'value_text_box', 'value_numerical_box',
'value_char_box', 'value_date', 'value_datetime',
'suggested_answer_id.value', 'matrix_row_id.value',
)
def _compute_display_name(self):
for line in self:
if line.answer_type == 'char_box':
@ -651,69 +741,23 @@ class SurveyUserInputLine(models.Model):
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)
line.display_name = fields.Datetime.to_string(fields.Datetime.context_timestamp(self.env.user, line.value_datetime))
elif line.answer_type == 'scale':
line.display_name = line.value_scale
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)
line.display_name = f'{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):
@api.depends('answer_type', 'value_text_box', 'value_numerical_box', 'value_date', 'value_datetime',
'suggested_answer_id', 'user_input_id')
def _compute_answer_score(self):
""" 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
@ -725,60 +769,111 @@ class SurveyUserInputLine(models.Model):
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:
Example of updated 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))
for line in self:
answer_is_correct, answer_score = False, 0
if line.answer_type:
# record selected suggested choice answer_score (can be: pos, neg, or 0)
if line.question_id.question_type in ['simple_choice', 'multiple_choice']:
if line.answer_type == 'suggestion' and line.suggested_answer_id:
answer_score = line.suggested_answer_id.answer_score
answer_is_correct = line.suggested_answer_id.is_correct
# for all other scored question cases, record question answer_score (can be: pos or 0)
elif line.question_id.question_type in ['date', 'datetime', 'numerical_box']:
answer = line[f'value_{line.answer_type}']
if line.answer_type == 'numerical_box':
answer = float(answer)
elif line.answer_type == 'date':
answer = fields.Date.from_string(answer)
elif line.answer_type == 'datetime':
answer = fields.Datetime.from_string(answer)
if answer and answer == line.question_id[f'answer_{line.answer_type}']:
answer_is_correct = True
answer_score = line.question_id.answer_score
# 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:
# Session speed rating
if (
answer_score > 0
and line.user_input_id.survey_id.session_speed_rating
and line.user_input_id.is_session_answer
and line.question_id.is_time_limited
):
max_score_delay = 2
time_limit = question.time_limit
time_limit = line.question_id.time_limit
now = fields.Datetime.now()
seconds_to_answer = (now - user_input.survey_id.session_question_start_time).total_seconds()
seconds_to_answer = (now - line.user_input_id.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
if question_remaining_time < 0 or line.question_id != line.user_input_id.survey_id.session_question_id:
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
elif seconds_to_answer > max_score_delay: # linear decrease in score after 2 sec
score_proportion = (time_limit - seconds_to_answer) / (time_limit - max_score_delay)
answer_score = (answer_score / 2) * (1 + score_proportion)
return {
'answer_is_correct': answer_is_correct,
'answer_score': answer_score
}
line.answer_is_correct = answer_is_correct
line.answer_score = answer_score
@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 and scale
if line.answer_type == 'numerical_box' and float_is_zero(line['value_numerical_box'], precision_digits=6):
continue
if line.answer_type == 'scale' and line['value_scale'] == 0:
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'))
def _get_answer_matching_domain(self):
self.ensure_one()
if self.answer_type in ('char_box', 'text_box', 'numerical_box', 'scale', 'date', 'datetime'):
value_field = {
'char_box': 'value_char_box',
'text_box': 'value_text_box',
'numerical_box': 'value_numerical_box',
'scale': 'value_scale',
'date': 'value_date',
'datetime': 'value_datetime',
}
operators = {
'char_box': 'ilike',
'text_box': 'ilike',
'numerical_box': '=',
'scale': '=',
'date': '=',
'datetime': '=',
}
return ['&', ('question_id', '=', self.question_id.id), (value_field[self.answer_type], operators[self.answer_type], self._get_answer_value())]
elif self.answer_type == 'suggestion':
return self.suggested_answer_id._get_answer_matching_domain(self.matrix_row_id.id if self.matrix_row_id else False)
def _get_answer_value(self):
self.ensure_one()
if self.answer_type == 'char_box':
return self.value_char_box
elif self.answer_type == 'text_box':
return self.value_text_box
elif self.answer_type == 'numerical_box':
return self.value_numerical_box
elif self.answer_type == 'scale':
return self.value_scale
elif self.answer_type == 'date':
return self.value_date
elif self.answer_type == 'datetime':
return self.value_datetime
elif self.answer_type == 'suggestion':
return self.suggested_answer_id.value

View file

@ -0,0 +1 @@
from . import survey_survey

Some files were not shown because too many files have changed in this diff Show more