4 Commits

Author SHA1 Message Date
07d0af5e7f [ADD] survey_xlsx_expand_multiple_choice, survey_xlsx_extra_fields
survey_xlsx_expand_multiple_choice: expand multiple_choice questions into
one Oui/Non column per option, and matrix questions into one column per
row (value = selected option). Relies on the extension hooks added to
survey_xlsx.

survey_xlsx_extra_fields: bridge (auto_install) with survey_extra_fields
that excludes 'file' question types from the XLSX export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:49:48 +02:00
d0afa2310d [IMP] survey_extra_fields : handle file question on page navigation
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m40s
* Show a stored file (after navigating back) the same way as a freshly
  selected one: a "filename + Remove file" chip, input hidden until removal.
* Keep the stored file when a page is re-submitted without a new selection
  (instead of overwriting it with a skipped answer).
* "Remove file" now sends a sentinel to actually delete the stored file,
  so the removal persists across navigation.
2026-06-11 10:52:59 +02:00
8a2075a3db [MIG] survey_extra_fields, survey_base : modifications to make them work in v18 2026-06-11 10:52:53 +02:00
8f235646ef [ADD] survey_extra_fields, survey_base : copy modules from v16 2026-06-10 15:38:57 +02:00
42 changed files with 2663 additions and 0 deletions

1
survey_base/__init__.py Normal file
View File

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

View File

@@ -0,0 +1,26 @@
# Copyright 2016-2020 Akretion France (<https://www.akretion.com>)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Survey base",
'summary': 'Add fields used by several survey addons',
'description': """
Add fields used by several survey addons
----------------------------------------------------
* Add record reference in survey_question and survey.user_input.line
* Add value_file in survey.user_input.line
* Implementation of theses fields should be in another module
""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "Elabore",
"website": "https://www.elabore.coop",
"category": "",
"depends": ["survey"],
"data": [
],
"installable": True,
}

54
survey_base/i18n/fr.po Normal file
View File

@@ -0,0 +1,54 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * survey_base
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-17 14:44+0000\n"
"PO-Revision-Date: 2026-02-17 14:44+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__value_file
msgid "File"
msgstr "Fichier"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__value_file_fname
msgid "File Name"
msgstr "Nom du fichier"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__record_reference
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__record_reference
msgid "Record"
msgstr "Enregistrement"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__record_reference_model
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__record_reference_model
msgid "Record Model"
msgstr "Modèle de l'enregistrement"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__smart_search
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__smart_search
msgid "Smart Search"
msgstr "Recherche intelligente"
#. module: survey_base
#: model:ir.model,name:survey_base.model_survey_question_answer
msgid "Survey Label"
msgstr "Étiquette du sondage"
#. module: survey_base
#: model:ir.model,name:survey_base.model_survey_user_input_line
msgid "Survey User Input Line"
msgstr "Ligne d'entrée pour l'utilisateur du sondage"

View File

@@ -0,0 +1,2 @@
from . import survey_question_answer
from . import survey_user_input_line

View File

@@ -0,0 +1,19 @@
import logging
import textwrap
import uuid
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools import float_is_zero
_logger = logging.getLogger(__name__)
class SurveyQuestionAnswer(models.Model):
_inherit = 'survey.question.answer'
record_reference = fields.Many2oneReference(model_field="record_reference_model", string="Record")
record_reference_model = fields.Char("Record Model")

View File

@@ -0,0 +1,46 @@
import logging
import textwrap
import uuid
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools import float_is_zero
_logger = logging.getLogger(__name__)
class SurveyUserInputLine(models.Model):
_inherit = 'survey.user_input.line'
#attachment fields
value_file = fields.Binary(string="File")
value_file_fname = fields.Char(string="File Name")
#record reference fields
record_reference = fields.Many2oneReference(model_field="record_reference_model", string="Record")
record_reference_model = fields.Char('Record Model')
"""set record_reference when saving survey_user_input line
"""
def set_record_reference_data(self, vals):
if vals.get('answer_type') == "suggestion" and 'suggested_answer_id' in vals:
#find model
answer = self.env['survey.question.answer'].browse(vals['suggested_answer_id'])
vals['record_reference_model'] = answer.record_reference_model
vals['record_reference'] = answer.record_reference
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self.set_record_reference_data(vals)
return super(SurveyUserInputLine, self).create(vals_list)
def write(self, vals):
self.set_record_reference_data(vals)
return super(SurveyUserInputLine, self).write(vals)

View File

@@ -0,0 +1,2 @@
from . import controllers
from . import models

View File

@@ -0,0 +1,33 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Survey extra fields",
"summary": "Add extra question types to surveys",
"description": """
Add extra question types to surveys:
----------------------------------------------------
* File upload question type
- Allows survey participants to upload a file as an answer
- Configurable maximum file size (in MB) per question (default: 10 MB, 0 = no limit)
- Configurable allowed file extensions per question (e.g. .pdf,.docx — empty = all types allowed)
- Client-side validation (size and extension) before form submission
- Server-side validation on save to enforce constraints
""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "Elabore",
"website": "https://www.elabore.coop",
"category": "",
"depends": ["base", "survey_base"],
"data": [
"views/survey_templates.xml",
"views/survey_user_views.xml",
"views/survey_question_views.xml",
],
"assets": {
"survey.survey_assets": [
"/survey_extra_fields/static/src/js/survey_form.js",
],
},
"installable": True,
}

View File

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

View File

@@ -0,0 +1,61 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any
from odoo import http
from odoo.http import request, content_disposition
from odoo.addons.survey.controllers.main import Survey
if TYPE_CHECKING:
from werkzeug.wrappers import Response
class SurveyExtraFieldsController(Survey):
@http.route(
"/survey/file/<string:survey_token>/<string:answer_token>/<int:line_id>",
type="http",
auth="public",
)
def survey_file_download(
self,
survey_token: str,
answer_token: str,
line_id: int,
**kwargs: Any
) -> Response:
survey = request.env["survey.survey"].sudo().search(
[("access_token", "=", survey_token)], limit=1
)
if not survey:
raise request.not_found()
answer = request.env["survey.user_input"].sudo().search(
[
("survey_id", "=", survey.id),
("access_token", "=", answer_token),
],
limit=1,
)
if not answer:
raise request.not_found()
line = request.env["survey.user_input.line"].sudo().browse(line_id)
if not line.exists() or line.user_input_id != answer:
raise request.not_found()
if not line.value_file:
raise request.not_found()
file_content = base64.b64decode(line.value_file)
filename = line.value_file_fname or "file"
return request.make_response(
file_content,
headers=[
("Content-Type", "application/octet-stream"),
("Content-Disposition", content_disposition(filename)),
],
)

View File

@@ -0,0 +1,129 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * survey_extra_fields
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-10 15:03+0000\n"
"PO-Revision-Date: 2026-06-10 15:03+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: survey_extra_fields
#: model_terms:ir.ui.view,arch_db:survey_extra_fields.survey_question_form_inh
msgid ".pdf,.docx,.xlsx"
msgstr ""
#. module: survey_extra_fields
#: model_terms:ir.ui.view,arch_db:survey_extra_fields.question_file
msgid "<i class=\"fa fa-times me-1\"/>Remove file"
msgstr "<i class=\"fa fa-times me-1\"/>Supprimer le fichier"
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__allowed_extensions
msgid "Allowed Extensions"
msgstr "Extensions autorisées"
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_user_input_line__answer_type
msgid "Answer Type"
msgstr "Type de réponse"
#. module: survey_extra_fields
#: model:ir.model.fields,help:survey_extra_fields.field_survey_question__allowed_extensions
msgid ""
"Comma-separated list of allowed extensions (e.g. .pdf,.docx). Leave empty to"
" allow all types."
msgstr ""
"Liste d'extensions autorisées séparées par une virgule (e.g. .pdf,.docx). "
"Laisser vide pour autoriser tous les types de fichier."
#. module: survey_extra_fields
#: model:ir.model.fields.selection,name:survey_extra_fields.selection__survey_question__question_type__file
#: model:ir.model.fields.selection,name:survey_extra_fields.selection__survey_user_input_line__answer_type__file
msgid "File"
msgstr "Fichier"
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__max_file_size
#: model_terms:ir.ui.view,arch_db:survey_extra_fields.survey_question_form_inh
msgid "Max File Size (MB)"
msgstr "Taille maximale du fichier"
#. module: survey_extra_fields
#: model:ir.model.fields,help:survey_extra_fields.field_survey_question__max_file_size
msgid "Maximum file size in MB. Leave 0 for no limit."
msgstr ""
"Taille maximale du fichier en MB. Laisser à 0 pour ne pas restreindre la "
"taille. La valeur par défaut est 10 MB."
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__question_type
msgid "Question Type"
msgstr "Type de question"
#. module: survey_extra_fields
#: model_terms:ir.ui.view,arch_db:survey_extra_fields.survey_page_print_inh_type_file
msgid "Skipped"
msgstr "Ignoré"
#. module: survey_extra_fields
#: model:ir.model,name:survey_extra_fields.model_survey_question
msgid "Survey Question"
msgstr "Question du sondage"
#. module: survey_extra_fields
#: model:ir.model,name:survey_extra_fields.model_survey_user_input
msgid "Survey User Input"
msgstr "Entrée utilisateur du sondage"
#. module: survey_extra_fields
#: model:ir.model,name:survey_extra_fields.model_survey_user_input_line
msgid "Survey User Input Line"
msgstr "Ligne d'entrée pour l'utilisateur du sondage"
#. module: survey_extra_fields
#. odoo-python
#: code:addons/survey_extra_fields/models/survey_user_input.py:0
msgid "The file '%(name)s' exceeds the maximum allowed size of %(size)s MB."
msgstr ""
"Le fichier '%(name)s' dépasse la taille maximale autorisée de %(size)s MB."
#. module: survey_extra_fields
#. odoo-python
#: code:addons/survey_extra_fields/models/survey_user_input.py:0
msgid "The file '%(name)s' is not allowed. Accepted formats: %(exts)s."
msgstr ""
"Le fichier '%(name)s' n'est pas autorisé. Les formats de fichier autorisés "
"sont : %(exts)s."
#. module: survey_extra_fields
#. odoo-javascript
#: code:addons/survey_extra_fields/static/src/js/survey_form.js:0
msgid "The file exceeds the maximum allowed size of %s MB."
msgstr "Le fichier dépasse la taille maximale autorisée de %s MB."
#. module: survey_extra_fields
#. odoo-python
#: code:addons/survey_extra_fields/models/survey_user_input.py:0
msgid "This answer cannot be overwritten."
msgstr "Cette réponse ne peut pas être remplacée."
#. module: survey_extra_fields
#. odoo-javascript
#: code:addons/survey_extra_fields/static/src/js/survey_form.js:0
msgid "This file type is not allowed. Accepted formats: %s."
msgstr ""
"Le fichier n'est pas autorisé. Les formats de fichier autorisés sont : %s."
#. module: survey_extra_fields
#. odoo-javascript
#: code:addons/survey_extra_fields/static/src/js/survey_form.js:0
msgid "This question requires an answer."
msgstr "Cette question requiert une réponse."

View File

@@ -0,0 +1,3 @@
from . import survey_question
from . import survey_user_input
from . import survey_user_input_line

View File

@@ -0,0 +1,20 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
class SurveyQuestion(models.Model):
_inherit = "survey.question"
question_type = fields.Selection(
selection_add=[("file", "File")]
)
max_file_size = fields.Integer(
string="Max File Size (MB)",
default=10,
help="Maximum file size in MB. Leave 0 for no limit.",
)
allowed_extensions = fields.Char(
string="Allowed Extensions",
help="Comma-separated list of allowed extensions (e.g. .pdf,.docx). Leave empty to allow all types.",
)

View File

@@ -0,0 +1,94 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from __future__ import annotations
import base64
import json
import os
from typing import TYPE_CHECKING
from odoo import _, models
from odoo.exceptions import UserError, ValidationError
if TYPE_CHECKING:
from odoo.addons.survey_extra_fields.models.survey_question import SurveyQuestion
class SurveyUserInput(models.Model):
_inherit = "survey.user_input"
def _save_lines(
self,
question: SurveyQuestion,
answer: str | None,
comment: str | None = None,
overwrite_existing: bool = True,
) -> None:
if question.question_type == "file":
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 not answer and any(line.value_file for line in old_answers):
# No new file was submitted: a file input cannot be pre-filled
# by the browser when navigating back to a previous page, so an
# empty answer here does not mean the user removed their file.
# Keep the previously uploaded file instead of overwriting it
# with a skipped answer.
return
vals = {
"user_input_id": self.id,
"question_id": question.id,
"skipped": False,
"answer_type": "file",
}
file_data = json.loads(answer) if answer else {}
if file_data.get("cleared"):
# The user explicitly removed the file: drop the stored data and
# mark the line as skipped.
vals.update(answer_type=None, skipped=True, value_file=False, value_file_fname=False)
elif file_data:
file_b64 = file_data.get("data", "")
file_name = file_data.get("name", "")
self._check_file_constraints(question, file_b64, file_name)
vals["value_file"] = file_b64
vals["value_file_fname"] = file_name
else:
vals.update(answer_type=None, skipped=True)
if old_answers:
old_answers.write(vals)
else:
self.env["survey.user_input.line"].create(vals)
else:
return super()._save_lines(
question, answer, comment=comment, overwrite_existing=overwrite_existing
)
def _check_file_constraints(
self,
question: SurveyQuestion,
file_b64: str,
file_name: str
) -> None:
if question.max_file_size:
file_size = len(base64.b64decode(file_b64))
max_bytes = question.max_file_size * 1024 * 1024
if file_size > max_bytes:
raise ValidationError(
_("The file '%(name)s' exceeds the maximum allowed size of %(size)s MB.",
name=file_name, size=question.max_file_size)
)
if question.allowed_extensions:
allowed = [
allowed_extension.strip().lower()
for allowed_extension in question.allowed_extensions.split(",")
if allowed_extension.strip()
]
file_extension = os.path.splitext(file_name)[1].lower()
if file_extension not in allowed:
raise ValidationError(
_("The file '%(name)s' is not allowed. Accepted formats: %(exts)s.",
name=file_name, exts=question.allowed_extensions)
)

View File

@@ -0,0 +1,17 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
class SurveyUserInputLine(models.Model):
_inherit = "survey.user_input.line"
answer_type = fields.Selection(
selection_add=[("file", "File")]
)
def _compute_display_name(self):
super()._compute_display_name()
for line in self:
if line.answer_type == "file" and line.value_file_fname:
line.display_name = line.value_file_fname

View File

@@ -0,0 +1,232 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
import { _t } from "@web/core/l10n/translation";
import { rpc } from "@web/core/network/rpc";
publicWidget.registry.SurveyFormWidget.include({
/**
* @override
* Bind delegated listeners on the form root so they keep working after each
* page is re-rendered (the inner content is replaced on navigation, but the
* root element persists). They let the user clear a selected file before
* submitting the form.
*/
start: function () {
return this._super.apply(this, arguments).then(() => {
this.$el.on(
"change.surveyExtraFile",
'input[data-question-type="file"]',
this._onFileInputChange.bind(this)
);
this.$el.on(
"click.surveyExtraFile",
".o_survey_file_clear",
this._onFileClearClick.bind(this)
);
});
},
/**
* On selection, show the file "chip" (filename + remove button) and hide the
* raw input, so a freshly selected file looks exactly like an already stored
* one (rendered server-side when navigating back).
*/
_onFileInputChange: function (ev) {
const input = ev.currentTarget;
const container = input.closest(".o_survey_comment_container");
if (!container || !(input.files && input.files.length > 0)) {
return;
}
const chip = container.querySelector(".o_survey_file_selected");
const nameEl = container.querySelector(".o_survey_file_name");
if (nameEl) {
nameEl.textContent = input.files[0].name;
}
if (chip) {
chip.classList.remove("d-none");
}
delete input.dataset.fileCleared;
input.classList.add("d-none");
},
/**
* Discard the current file: hide the chip and bring back the input so the
* user can pick a new one. A file already stored server-side is only really
* replaced once a new file is submitted (see _save_lines).
*/
_onFileClearClick: function (ev) {
ev.preventDefault();
const container = ev.currentTarget.closest(".o_survey_comment_container");
if (!container) {
return;
}
const input = container.querySelector('input[data-question-type="file"]');
const chip = container.querySelector(".o_survey_file_selected");
if (input) {
input.value = "";
// Flag the explicit removal so the submit tells the server to drop
// any previously stored file (instead of preserving it).
input.dataset.fileCleared = "1";
input.classList.remove("d-none");
}
if (chip) {
chip.classList.add("d-none");
}
},
_readFileAsDataURL: function (file) {
return new Promise(function (resolve, reject) {
const reader = new FileReader();
reader.onload = function (e) {
resolve(e.target.result);
};
reader.onerror = function () {
reject(reader.error);
};
reader.readAsDataURL(file);
});
},
/**
* @override
* The base implementation builds the submit params synchronously and fires
* the RPC immediately. File inputs need to be read asynchronously (FileReader),
* so when the current page contains file answers we replicate the submit flow
* here, injecting the base64 file payload before submitting.
*/
_submitForm: async function (options) {
const fileInputs = this.el.querySelectorAll('input[data-question-type="file"]');
// A file action is either a new selection or an explicit removal of a
// previously stored file (which must be communicated to the server).
const hasFileAction = Array.from(fileInputs).some(
(input) => (input.files && input.files.length > 0) || input.dataset.fileCleared
);
if (!hasFileAction || this.options.isStartScreen) {
return this._super(options);
}
const params = {};
if (options.previousPageId) {
params.previous_page_id = options.previousPageId;
}
if (options.nextSkipped) {
params.next_skipped_page_or_question = true;
}
const $form = this.$("form");
const formData = new FormData($form[0]);
if (!options.skipValidation && !this._validateForm($form, formData)) {
return;
}
this._prepareSubmitValues(formData, params);
// Read all selected files as base64 and add them to the submit params.
// Explicitly cleared inputs (no new file) send a "cleared" sentinel so
// the server removes the previously stored file.
const filePromises = [];
for (const input of fileInputs) {
if (input.files && input.files.length > 0) {
const file = input.files[0];
const name = input.name;
filePromises.push(
this._readFileAsDataURL(file).then((dataURL) => {
params[name] = JSON.stringify({
data: dataURL.split(",")[1],
name: file.name,
});
})
);
} else if (input.dataset.fileCleared) {
params[input.name] = JSON.stringify({ cleared: true });
}
}
// Prevent user from submitting more times using enter key.
this.preventEnterSubmit = true;
if (this.options.sessionInProgress) {
this.fadeInOutDelay = 400;
this.readonly = true;
}
await Promise.all(filePromises);
const submitPromise = rpc(
`/survey/submit/${this.options.surveyToken}/${this.options.answerToken}`,
params
);
this._nextScreen(submitPromise, options);
},
/**
* @override
* Add client-side validation (required, max size, allowed extensions) for
* file questions, which the base implementation does not know about.
*/
_validateForm: function ($form, formData) {
const result = this._super.apply(this, arguments);
const errors = {};
const inactiveQuestionIds = this.options.sessionInProgress
? []
: this._getInactiveConditionalQuestionIds();
$form.find('input[data-question-type="file"]').each(function () {
const $questionWrapper = $(this).closest(".js_question-wrapper");
const questionId = $questionWrapper.attr("id");
if (inactiveQuestionIds.includes(parseInt(questionId))) {
return;
}
const questionRequired = $questionWrapper.data("required");
const constrErrorMsg =
$questionWrapper.data("constrErrorMsg") ||
_t("This question requires an answer.");
if (questionRequired && !(this.files && this.files.length > 0)) {
// A file may already be stored server-side (e.g. uploaded then
// navigating back): the chip is visible even though the input is
// empty. Treat that as a valid answer.
const container = this.closest(".o_survey_comment_container");
const chip = container && container.querySelector(".o_survey_file_selected");
const hasExistingFile = chip && !chip.classList.contains("d-none");
if (!hasExistingFile) {
errors[questionId] = constrErrorMsg;
}
return;
}
if (this.files && this.files.length > 0) {
const file = this.files[0];
const maxSizeMB = parseInt($(this).data("maxFileSize"));
if (maxSizeMB && file.size > maxSizeMB * 1024 * 1024) {
errors[questionId] = _t(
"The file exceeds the maximum allowed size of %s MB.",
maxSizeMB
);
return;
}
const allowedExtensions = $(this).data("allowedExtensions");
if (allowedExtensions) {
const allowed = allowedExtensions
.split(",")
.map((e) => e.trim().toLowerCase());
const ext = "." + file.name.split(".").pop().toLowerCase();
if (!allowed.includes(ext)) {
errors[questionId] = _t(
"This file type is not allowed. Accepted formats: %s.",
allowedExtensions
);
}
}
}
});
if (Object.keys(errors).length > 0) {
this._showErrors(errors);
return false;
}
return result;
},
});
export default publicWidget.registry.SurveyFormWidget;

View File

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

View File

@@ -0,0 +1,297 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import base64
import json
from unittest.mock import patch, MagicMock
from werkzeug.exceptions import NotFound
from werkzeug.wrappers import Response
from odoo.addons.survey.tests import common
class TestSurveyFileCommon(common.TestSurveyCommon):
def setUp(self):
super().setUp()
self.file_content = b"Hello, this is a test file."
self.file_b64 = base64.b64encode(self.file_content).decode()
self.file_name = "test_document.pdf"
self.question_file = self._add_question(
self.page_0,
"Upload your document",
"file",
constr_mandatory=False,
survey_id=self.survey.id,
)
self.question_file_required = self._add_question(
self.page_0,
"Upload your required document",
"file",
constr_mandatory=True,
survey_id=self.survey.id,
)
def _create_answer_with_file(self):
answer = self._add_answer(self.survey, False, email="test@example.com")
line = self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_file.id,
"answer_type": "file",
"skipped": False,
"value_file": self.file_b64,
"value_file_fname": self.file_name,
})
return answer, line
class TestSurveyFileSaveLines(TestSurveyFileCommon):
"""Test the _save_lines method for file question type."""
def test_save_file_answer(self):
"""Submitting a file stores base64 data and filename."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer._save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertEqual(line.answer_type, "file")
self.assertFalse(line.skipped)
self.assertEqual(line.value_file, self.file_b64.encode())
self.assertEqual(line.value_file_fname, self.file_name)
def test_save_file_skipped(self):
"""Submitting empty answer marks the line as skipped."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
answer._save_lines(self.question_file, "")
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertTrue(line.skipped)
self.assertFalse(line.answer_type)
def test_save_file_update_existing(self):
"""Submitting a new file updates the existing answer line."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json_1 = json.dumps({"data": self.file_b64, "name": "first.pdf"})
answer._save_lines(self.question_file, file_json_1)
new_b64 = base64.b64encode(b"Updated content").decode()
file_json_2 = json.dumps({"data": new_b64, "name": "second.pdf"})
answer._save_lines(self.question_file, file_json_2)
lines = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(lines), 1, "Should update, not create a second line")
self.assertEqual(lines.value_file, new_b64.encode())
self.assertEqual(lines.value_file_fname, "second.pdf")
def test_save_file_then_empty_keeps_file(self):
"""Submitting empty after a file keeps it (file inputs cannot be
pre-filled when navigating back, so an empty answer must not erase it)."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer._save_lines(self.question_file, file_json)
answer._save_lines(self.question_file, "")
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertFalse(line.skipped)
self.assertEqual(line.value_file, self.file_b64.encode())
self.assertEqual(line.value_file_fname, self.file_name)
def test_save_file_explicitly_cleared(self):
"""Submitting the 'cleared' sentinel after a file removes it."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer._save_lines(self.question_file, file_json)
answer._save_lines(self.question_file, json.dumps({"cleared": True}))
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertTrue(line.skipped)
self.assertFalse(line.value_file)
self.assertFalse(line.value_file_fname)
class TestSurveyFileConstraints(TestSurveyFileCommon):
"""Test _check_file_constraints validation logic."""
def test_no_constraints(self):
"""No max_file_size and no allowed_extensions: any file passes."""
self.question_file.write({"max_file_size": 0, "allowed_extensions": False})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": "anything.exe"})
answer._save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
def test_file_within_size_limit(self):
"""File smaller than max_file_size passes."""
self.question_file.write({"max_file_size": 10})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer._save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
def test_file_exceeds_size_limit(self):
"""File larger than max_file_size raises ValidationError."""
self.question_file.write({"max_file_size": 1})
large_content = b"x" * (1 * 1024 * 1024 + 1)
large_b64 = base64.b64encode(large_content).decode()
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": large_b64, "name": self.file_name})
with self.assertRaises(Exception):
answer._save_lines(self.question_file, file_json)
def test_allowed_extension_passes(self):
"""File with an allowed extension passes."""
self.question_file.write({"allowed_extensions": ".pdf,.docx"})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": "report.docx"})
answer._save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
def test_disallowed_extension_raises(self):
"""File with a disallowed extension raises ValidationError."""
self.question_file.write({"allowed_extensions": ".pdf,.docx"})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": "script.exe"})
with self.assertRaises(Exception):
answer._save_lines(self.question_file, file_json)
def test_both_constraints_valid(self):
"""File respecting both size and extension constraints passes."""
self.question_file.write({"max_file_size": 10, "allowed_extensions": ".pdf"})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer._save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
class TestSurveyFileDisplayName(TestSurveyFileCommon):
"""Test the display_name computation for file answer lines."""
def test_display_name_filename(self):
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
line = self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_file.id,
"answer_type": "file",
"skipped": False,
"value_file": self.file_b64,
"value_file_fname": "my_filename.pdf",
})
self.assertEqual(line.display_name, "my_filename.pdf")
class TestSurveyFileDownloadController(TestSurveyFileCommon):
"""Test the file download controller logic with mocked request."""
def _call_download(self, survey_token, answer_token, line_id):
"""Call the controller method with a mocked request context."""
from odoo.addons.survey_extra_fields.controllers.main import (
SurveyExtraFieldsController,
)
mock_request = MagicMock()
mock_request.env = self.env
mock_request.not_found.return_value = NotFound()
mock_request.make_response.side_effect = (
lambda content, headers=None: Response(content, headers=headers)
)
controller = SurveyExtraFieldsController()
with patch(
"odoo.addons.survey_extra_fields.controllers.main.request",
mock_request,
):
try:
return controller.survey_file_download(
survey_token, answer_token, line_id
)
except NotFound:
return NotFound()
def test_download_valid(self):
"""Valid tokens return the file content."""
answer, line = self._create_answer_with_file()
response = self._call_download(
self.survey.access_token, answer.access_token, line.id
)
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, self.file_content)
def test_download_invalid_survey_token(self):
"""Invalid survey token returns not_found."""
answer, line = self._create_answer_with_file()
response = self._call_download(
"invalid-token", answer.access_token, line.id
)
self.assertIsInstance(response, NotFound)
def test_download_invalid_answer_token(self):
"""Invalid answer token returns not_found."""
answer, line = self._create_answer_with_file()
response = self._call_download(
self.survey.access_token, "invalid-token", line.id
)
self.assertIsInstance(response, NotFound)
def test_download_line_not_belonging_to_answer(self):
"""Accessing a line from another answer returns not_found."""
answer, line = self._create_answer_with_file()
other_answer = self._add_answer(self.survey, False, email="other@example.com")
response = self._call_download(
self.survey.access_token, other_answer.access_token, line.id
)
self.assertIsInstance(response, NotFound)
def test_download_no_file(self):
"""Accessing a line without file data returns not_found."""
answer = self._add_answer(self.survey, False, email="test@example.com")
line = self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_file.id,
"skipped": True,
})
response = self._call_download(
self.survey.access_token, answer.access_token, line.id
)
self.assertIsInstance(response, NotFound)

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_question_form_inh" model="ir.ui.view">
<field name="name">survey.question.form.inherit.extra_fields</field>
<field name="model">survey.question</field>
<field name="inherit_id" ref="survey.survey_question_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='options']//field[@name='matrix_subtype']" position="before">
<field name="max_file_size"
invisible="question_type != 'file'"
string="Max File Size (MB)"/>
<field name="allowed_extensions"
invisible="question_type != 'file'"
placeholder=".pdf,.docx,.xlsx"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend question_container (form view during survey filling) -->
<template
id="question_container_inh_type_file"
inherit_id="survey.question_container"
>
<xpath expr="//t[@t-call='survey.question_matrix']" position="after">
<t t-if="question.question_type == 'file'">
<t t-call="survey_extra_fields.question_file"/>
</t>
</xpath>
</template>
<template id="question_file" name="Question: File">
<div class="o_survey_comment_container p-0">
<t t-set="existing_fname" t-value="answer_lines and answer_lines[0].value_file_fname"/>
<t t-if="survey_form_readonly">
<p t-if="existing_fname" class="mb-1">
<i class="fa fa-paperclip me-1"/><t t-out="answer_lines[0].value_file_fname"/>
</p>
</t>
<t t-else="">
<!-- Uploaded file display, shown both for a fresh selection and when a
file was already stored server-side (e.g. navigating back). The raw
file input is hidden until the user clicks "Remove file". -->
<span t-attf-class="o_survey_file_selected d-inline-flex align-items-center #{'' if existing_fname else 'd-none'}">
<i class="fa fa-paperclip me-1"/>
<span class="o_survey_file_name"><t t-out="existing_fname or ''"/></span>
<button type="button" class="btn btn-link btn-sm text-danger o_survey_file_clear ms-2 py-0">
<i class="fa fa-times me-1"/>Remove file
</button>
</span>
<input
type="file"
t-attf-class="o_survey_question_file #{'d-none' if existing_fname else ''}"
t-att-name="question.id"
t-att-data-question-type="question.question_type"
t-att-accept="question.allowed_extensions or None"
t-att-data-max-file-size="question.max_file_size or None"
t-att-data-allowed-extensions="question.allowed_extensions or None"
/>
</t>
</div>
</template>
<!-- Extend print/review page to show file answers -->
<template
id="survey_page_print_inh_type_file"
inherit_id="survey.survey_page_print"
>
<xpath expr="//div[hasclass('o_survey_question_error')]" position="before">
<t t-if="question.question_type == 'file'">
<t t-if="answer_lines">
<t t-set="answer_line" t-value="answer_lines[0]"/>
<t t-if="answer_line.skipped">
<div class="row g-0">
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
<input type="text"
class="form-control fst-italic o_survey_question_file bg-transparent rounded-0 p-0"
value="Skipped"/>
</div>
</div>
</t>
<t t-elif="answer_line.value_file_fname">
<div class="row g-0">
<div class="col-12 col-md-6 col-lg-4">
<a t-attf-href="/survey/file/#{survey.access_token}/#{answer.access_token}/#{answer_line.id}"
target="_blank">
<i class="fa fa-download me-1"/><t t-out="answer_line.value_file_fname"/>
</a>
</div>
</div>
</t>
</t>
</t>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_user_input_line_view_form_inh" model="ir.ui.view">
<field name="name">survey.user_input.line.view.form.inherit.extra_fields</field>
<field name="model">survey.user_input.line</field>
<field name="inherit_id" ref="survey.survey_user_input_line_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='suggested_answer_id']" position="after">
<field name="value_file" filename="value_file_fname" colspan="2"
invisible="answer_type != 'file'"/>
<field name="value_file_fname" invisible="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,109 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
====================================
Survey XLSX - Expand Multiple Choice
====================================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2ec53fabb2863ebd536f204a7cb4fa4833a634a5711a9e325ed64f50a4c3c4b6
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/gitea-Elabore%2Fsurvey--tools-lightgray.png
:target: https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice
:alt: Elabore/survey-tools
|badge1| |badge2| |badge3|
This module improves the **Survey Results XLSX export** provided by
``survey_xlsx`` for questions that can hold several answers.
By default such questions are exported as a single column containing every
selected value joined together, which is hard to analyse in a spreadsheet.
This module splits them into dedicated columns:
* **Multiple choice** questions (*multiple answers allowed*): one column per
possible answer, with ``Oui`` / ``Non`` as value.
* **Matrix** questions: one column per matrix row, with the selected option
as value.
.. warning::
This module relies on report extension hooks that are **not part of the
standard** ``survey_xlsx`` yet. They are introduced by this pull request:
https://github.com/elabore-coop/survey/pull/1
You must run a ``survey_xlsx`` that includes these hooks (the PR branch,
until it is merged upstream). Installed against a plain ``survey_xlsx``,
this module installs without error but the export **silently falls back**
to the default one-column-per-question behaviour.
**Table of contents**
.. contents::
:local:
Usage
=====
Export the results of a survey as usual:
#. Go to *Surveys* and open a survey.
#. Print the *Survey Results XLSX* report.
In the generated spreadsheet:
* A multiple choice question ``Favorite colors`` with options *Red*, *Green*
and *Blue* produces three columns ``Favorite colors / Red``,
``Favorite colors / Green`` and ``Favorite colors / Blue``, each containing
``Oui`` or ``Non``.
* A matrix question ``Satisfaction`` with rows *Dashboards* and *Customer
relationship* produces two columns ``Satisfaction / Dashboards`` and
``Satisfaction / Customer relationship``, each containing the selected
option (e.g. *Not satisfied at all*).
Other question types keep their standard single-column export.
Bug Tracker
===========
Bugs are tracked on `Gitea Issues <https://git.elabore.coop/Elabore/survey-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://git.elabore.coop/Elabore/survey-tools/issues/new?body=module:%20survey_xlsx_expand_multiple_choice%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Elabore
Contributors
~~~~~~~~~~~~
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot
Maintainers
~~~~~~~~~~~
This module is part of the `Elabore/survey-tools <https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice>`_ project on git.elabore.coop.
You are welcome to contribute.

View File

@@ -0,0 +1,3 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import report

View File

@@ -0,0 +1,16 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Survey XLSX - Expand Multiple Choice",
"summary": """
Expands multiple_choice questions into one Oui/Non column per option, and
matrix questions into one column per row (value = selected option)""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"installable": True,
"application": False,
"author": "Elabore",
"website": "https://elabore.coop",
"depends": ["survey_xlsx"], # WARNING : besoin des hooks créés dans cette PR pour fonctionner : https://github.com/elabore-coop/survey/pull/1
}

View File

@@ -0,0 +1,3 @@
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot

View File

@@ -0,0 +1,23 @@
This module improves the **Survey Results XLSX export** provided by
``survey_xlsx`` for questions that can hold several answers.
By default such questions are exported as a single column containing every
selected value joined together, which is hard to analyse in a spreadsheet.
This module splits them into dedicated columns:
* **Multiple choice** questions (*multiple answers allowed*): one column per
possible answer, with ``Oui`` / ``Non`` as value.
* **Matrix** questions: one column per matrix row, with the selected option
as value.
.. warning::
This module relies on report extension hooks that are **not part of the
standard** ``survey_xlsx`` yet. They are introduced by this pull request:
https://github.com/elabore-coop/survey/pull/1
You must run a ``survey_xlsx`` that includes these hooks (the PR branch,
until it is merged upstream). Installed against a plain ``survey_xlsx``,
this module installs without error but the export **silently falls back**
to the default one-column-per-question behaviour.

View File

@@ -0,0 +1,17 @@
Export the results of a survey as usual:
#. Go to *Surveys* and open a survey.
#. Print the *Survey Results XLSX* report.
In the generated spreadsheet:
* A multiple choice question ``Favorite colors`` with options *Red*, *Green*
and *Blue* produces three columns ``Favorite colors / Red``,
``Favorite colors / Green`` and ``Favorite colors / Blue``, each containing
``Oui`` or ``Non``.
* A matrix question ``Satisfaction`` with rows *Dashboards* and *Customer
relationship* produces two columns ``Satisfaction / Dashboards`` and
``Satisfaction / Customer relationship``, each containing the selected
option (e.g. *Not satisfied at all*).
Other question types keep their standard single-column export.

View File

@@ -0,0 +1,3 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import report_survey_xlsx

View File

@@ -0,0 +1,70 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class ReportSurveyXlsx(models.AbstractModel):
_inherit = "report.survey.xlsx"
def _write_question_header(self, sheet, question, cols, bold):
if question.question_type == "multiple_choice":
for answer in question.suggested_answer_ids:
col_key = f"question_{question.id}_answer_{answer.id}"
sheet.write(
0,
cols[col_key],
f"{question.title} / {answer.value}",
bold,
)
return
if question.question_type == "matrix":
for row in question.matrix_row_ids:
col_key = f"question_{question.id}_row_{row.id}"
sheet.write(
0,
cols[col_key],
f"{question.title} / {row.value}",
bold,
)
return
return super()._write_question_header(sheet, question, cols, bold)
def _process_user_answer(self, data, user_input_id, user_answer, cols):
question = user_answer.question_id
if question.question_type == "multiple_choice":
if user_answer.skipped:
return
col_key = f"question_{question.id}_answer_{user_answer.suggested_answer_id.id}"
if col_key not in cols:
return
data[user_input_id][cols[col_key]] = ["Oui"]
return
if question.question_type == "matrix":
if user_answer.skipped:
return
col_key = f"question_{question.id}_row_{user_answer.matrix_row_id.id}"
if col_key not in cols:
return
data[user_input_id][cols[col_key]].append(
user_answer.suggested_answer_id.value
)
return
return super()._process_user_answer(data, user_input_id, user_answer, cols)
def _post_process_user_input(self, data, user_input, cols):
super()._post_process_user_input(data, user_input, cols)
answered_mc = set()
for line in user_input.user_input_line_ids:
if line.question_id.question_type == "multiple_choice" and not line.skipped:
answered_mc.add(line.question_id.id)
for question in user_input.survey_id.question_ids:
if question.question_type != "multiple_choice":
continue
if question.id not in answered_mc:
continue
for answer in question.suggested_answer_ids:
col_key = f"question_{question.id}_answer_{answer.id}"
if col_key in cols:
col_idx = cols[col_key]
if col_idx not in data[user_input.id]:
data[user_input.id][col_idx] = ["Non"]

View File

@@ -0,0 +1,466 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document">
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
</a>
<div class="section" id="survey-xlsx-expand-multiple-choice">
<h1>Survey XLSX - Expand Multiple Choice</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2ec53fabb2863ebd536f204a7cb4fa4833a634a5711a9e325ed64f50a4c3c4b6
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice"><img alt="elabore-coop/survey-tools" src="https://img.shields.io/badge/github-elabore--coop%2Fsurvey--tools-lightgray.png?logo=github" /></a></p>
<p>This module improves the <strong>Survey Results XLSX export</strong> provided by
<tt class="docutils literal">survey_xlsx</tt> for questions that can hold several answers.</p>
<p>By default such questions are exported as a single column containing every
selected value joined together, which is hard to analyse in a spreadsheet.
This module splits them into dedicated columns:</p>
<ul class="simple">
<li><strong>Multiple choice</strong> questions (<em>multiple answers allowed</em>): one column per
possible answer, with <tt class="docutils literal">Oui</tt> / <tt class="docutils literal">Non</tt> as value.</li>
<li><strong>Matrix</strong> questions: one column per matrix row, with the selected option
as value.</li>
</ul>
<div class="admonition warning">
<p class="first admonition-title">Warning</p>
<p>This module relies on report extension hooks that are <strong>not part of the
standard</strong> <tt class="docutils literal">survey_xlsx</tt> yet. They are introduced by this pull request:</p>
<p><a class="reference external" href="https://github.com/elabore-coop/survey/pull/1">https://github.com/elabore-coop/survey/pull/1</a></p>
<p class="last">You must run a <tt class="docutils literal">survey_xlsx</tt> that includes these hooks (the PR branch,
until it is merged upstream). Installed against a plain <tt class="docutils literal">survey_xlsx</tt>,
this module installs without error but the export <strong>silently falls back</strong>
to the default one-column-per-question behaviour.</p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
<p>Export the results of a survey as usual:</p>
<ol class="arabic simple">
<li>Go to <em>Surveys</em> and open a survey.</li>
<li>Print the <em>Survey Results XLSX</em> report.</li>
</ol>
<p>In the generated spreadsheet:</p>
<ul class="simple">
<li>A multiple choice question <tt class="docutils literal">Favorite colors</tt> with options <em>Red</em>, <em>Green</em>
and <em>Blue</em> produces three columns <tt class="docutils literal">Favorite colors / Red</tt>,
<tt class="docutils literal">Favorite colors / Green</tt> and <tt class="docutils literal">Favorite colors / Blue</tt>, each containing
<tt class="docutils literal">Oui</tt> or <tt class="docutils literal">Non</tt>.</li>
<li>A matrix question <tt class="docutils literal">Satisfaction</tt> with rows <em>Dashboards</em> and <em>Customer
relationship</em> produces two columns <tt class="docutils literal">Satisfaction / Dashboards</tt> and
<tt class="docutils literal">Satisfaction / Customer relationship</tt>, each containing the selected
option (e.g. <em>Not satisfied at all</em>).</li>
</ul>
<p>Other question types keep their standard single-column export.</p>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues/new?body=module:%20survey_xlsx_expand_multiple_choice%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-3">Credits</a></h2>
<div class="section" id="authors">
<h3><a class="toc-backref" href="#toc-entry-4">Authors</a></h3>
<ul class="simple">
<li>Elabore</li>
</ul>
</div>
<div class="section" id="contributors">
<h3><a class="toc-backref" href="#toc-entry-5">Contributors</a></h3>
<ul class="simple">
<li><a class="reference external" href="https://www.elabore.coop">Elabore</a><ul>
<li>Quentin Mondot</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h3><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h3>
<p>This module is part of the <a class="reference external" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice">elabore-coop/survey-tools</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_report

View File

@@ -0,0 +1,148 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import io
import openpyxl
from odoo.addons.survey.tests import common
class TestExpandMultipleChoice(common.TestSurveyCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.mc_question = cls._add_question(
cls,
page=cls.page_0,
name="Favorite colors",
qtype="multiple_choice",
labels=[
{"value": "Red"},
{"value": "Green"},
{"value": "Blue"},
],
survey_id=cls.survey.id,
sequence=10,
)
cls.answer_red = cls.mc_question.suggested_answer_ids[0]
cls.answer_green = cls.mc_question.suggested_answer_ids[1]
cls.answer_blue = cls.mc_question.suggested_answer_ids[2]
# Input 1: selects Red and Green
input1 = cls._add_answer(cls, cls.survey, cls.survey_manager.partner_id)
cls._add_answer_line(cls, cls.mc_question, input1, cls.answer_red.id)
cls._add_answer_line(cls, cls.mc_question, input1, cls.answer_green.id)
input1._mark_done()
# Input 2: selects Blue only
input2 = cls._add_answer(cls, cls.survey, False, email="test2@example.com")
cls._add_answer_line(cls, cls.mc_question, input2, cls.answer_blue.id)
input2._mark_done()
# Matrix question (one choice per row): satisfaction grid
cls.matrix_question = cls._add_question(
cls,
page=cls.page_0,
name="Satisfaction",
qtype="matrix",
matrix_subtype="simple",
labels=[
{"value": "Pas du tout satisfait"},
{"value": "Satisfait"},
],
labels_2=[
{"value": "Tableaux de bord"},
{"value": "Relation client"},
],
survey_id=cls.survey.id,
sequence=20,
)
cls.col_unhappy = cls.matrix_question.suggested_answer_ids[0]
cls.col_happy = cls.matrix_question.suggested_answer_ids[1]
cls.row_dashboard = cls.matrix_question.matrix_row_ids[0]
cls.row_client = cls.matrix_question.matrix_row_ids[1]
# input1 answers the matrix: dashboard -> unhappy, client -> happy
cls._add_answer_line(
cls,
cls.matrix_question,
input1,
cls.col_unhappy.id,
answer_value_row=cls.row_dashboard.id,
)
cls._add_answer_line(
cls,
cls.matrix_question,
input1,
cls.col_happy.id,
answer_value_row=cls.row_client.id,
)
def _get_sheet(self):
report = self.env.ref("survey_xlsx.report_survey_xlsx")
rep = self.env["ir.actions.report"]._render(report, self.survey.ids, {})
wb = openpyxl.load_workbook(io.BytesIO(rep[0]))
return wb.worksheets[0]
def _find_col(self, sheet, header):
for col in range(1, sheet.max_column + 1):
if sheet.cell(1, col).value == header:
return col
return None
def test_headers(self):
sheet = self._get_sheet()
self.assertIsNotNone(
self._find_col(sheet, "Favorite colors / Red")
)
self.assertIsNotNone(
self._find_col(sheet, "Favorite colors / Green")
)
self.assertIsNotNone(
self._find_col(sheet, "Favorite colors / Blue")
)
def test_values(self):
sheet = self._get_sheet()
col_red = self._find_col(sheet, "Favorite colors / Red")
col_green = self._find_col(sheet, "Favorite colors / Green")
col_blue = self._find_col(sheet, "Favorite colors / Blue")
# Collect rows by (red, green, blue) values
rows = set()
for row in range(2, sheet.max_row + 1):
rows.add((
sheet.cell(row, col_red).value,
sheet.cell(row, col_green).value,
sheet.cell(row, col_blue).value,
))
self.assertIn(("Oui", "Oui", "Non"), rows)
self.assertIn(("Non", "Non", "Oui"), rows)
def test_matrix_headers(self):
sheet = self._get_sheet()
self.assertIsNotNone(
self._find_col(sheet, "Satisfaction / Tableaux de bord")
)
self.assertIsNotNone(
self._find_col(sheet, "Satisfaction / Relation client")
)
# No per-column expansion for matrices
self.assertIsNone(
self._find_col(sheet, "Satisfaction / Pas du tout satisfait")
)
def test_matrix_values(self):
sheet = self._get_sheet()
col_dashboard = self._find_col(sheet, "Satisfaction / Tableaux de bord")
col_client = self._find_col(sheet, "Satisfaction / Relation client")
values = set()
for row in range(2, sheet.max_row + 1):
values.add((
sheet.cell(row, col_dashboard).value,
sheet.cell(row, col_client).value,
))
self.assertIn(("Pas du tout satisfait", "Satisfait"), values)

View File

@@ -0,0 +1,87 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
=================================
Survey XLSX - Extra Fields Bridge
=================================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:8baa47af03e5b4b4e70ca6db224a5ac3e73aa66f287c42053a9a8f631efd10c2
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/gitea-Elabore%2Fsurvey--tools-lightgray.png
:target: https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_extra_fields
:alt: Elabore/survey-tools
|badge1| |badge2| |badge3|
This is a **bridge module** between ``survey_xlsx_expand_multiple_choice``
and ``survey_extra_fields``.
``survey_extra_fields`` adds a *File* question type, whose answers are
uploaded attachments that cannot be represented in a spreadsheet cell. This
module excludes those *File* questions from the **Survey Results XLSX
export**: they get no column at all, instead of an unusable one.
It installs automatically (``auto_install``) as soon as both
``survey_xlsx_expand_multiple_choice`` and ``survey_extra_fields`` are
installed, and is uninstalled when either of them is removed. There is
nothing to configure.
.. warning::
The exclusion is implemented through the report extension hooks provided
by ``survey_xlsx_expand_multiple_choice``, which itself relies on hooks
added to ``survey_xlsx`` by this pull request:
https://github.com/elabore-coop/survey/pull/1
Without those hooks, *File* questions are not filtered out of the export.
**Table of contents**
.. contents::
:local:
Bug Tracker
===========
Bugs are tracked on `Gitea Issues <https://git.elabore.coop/Elabore/survey-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://git.elabore.coop/Elabore/survey-tools/issues/new?body=module:%20survey_xlsx_extra_fields%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Elabore
Contributors
~~~~~~~~~~~~
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot
Maintainers
~~~~~~~~~~~
This module is part of the `Elabore/survey-tools <https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_extra_fields>`_ project on git.elabore.coop.
You are welcome to contribute.

View File

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

View File

@@ -0,0 +1,19 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Survey XLSX - Extra Fields Bridge",
"summary": """
Excludes 'file' question types from the survey XLSX export""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"installable": True,
"application": False,
"auto_install": True,
"author": "Elabore",
"website": "https://elabore.coop",
"depends": [
"survey_xlsx_expand_multiple_choice",
"survey_extra_fields",
],
}

View File

@@ -0,0 +1,3 @@
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot

View File

@@ -0,0 +1,22 @@
This is a **bridge module** between ``survey_xlsx_expand_multiple_choice``
and ``survey_extra_fields``.
``survey_extra_fields`` adds a *File* question type, whose answers are
uploaded attachments that cannot be represented in a spreadsheet cell. This
module excludes those *File* questions from the **Survey Results XLSX
export**: they get no column at all, instead of an unusable one.
It installs automatically (``auto_install``) as soon as both
``survey_xlsx_expand_multiple_choice`` and ``survey_extra_fields`` are
installed, and is uninstalled when either of them is removed. There is
nothing to configure.
.. warning::
The exclusion is implemented through the report extension hooks provided
by ``survey_xlsx_expand_multiple_choice``, which itself relies on hooks
added to ``survey_xlsx`` by this pull request:
https://github.com/elabore-coop/survey/pull/1
Without those hooks, *File* questions are not filtered out of the export.

View File

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

View File

@@ -0,0 +1,15 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import models
class ReportSurveyXlsx(models.AbstractModel):
_inherit = "report.survey.xlsx"
def _write_question_header(self, sheet, question, cols, bold):
# "file" questions store uploaded attachments that cannot be rendered
# in a spreadsheet cell: skip them so no column is created. Without a
# column, _process_user_answer ignores their answers automatically.
if question.question_type == "file":
return
return super()._write_question_header(sheet, question, cols, bold)

View File

@@ -0,0 +1,442 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document">
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
</a>
<div class="section" id="survey-xlsx-extra-fields-bridge">
<h1>Survey XLSX - Extra Fields Bridge</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:8baa47af03e5b4b4e70ca6db224a5ac3e73aa66f287c42053a9a8f631efd10c2
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_extra_fields"><img alt="elabore-coop/survey-tools" src="https://img.shields.io/badge/github-elabore--coop%2Fsurvey--tools-lightgray.png?logo=github" /></a></p>
<p>This is a <strong>bridge module</strong> between <tt class="docutils literal">survey_xlsx_expand_multiple_choice</tt>
and <tt class="docutils literal">survey_extra_fields</tt>.</p>
<p><tt class="docutils literal">survey_extra_fields</tt> adds a <em>File</em> question type, whose answers are
uploaded attachments that cannot be represented in a spreadsheet cell. This
module excludes those <em>File</em> questions from the <strong>Survey Results XLSX
export</strong>: they get no column at all, instead of an unusable one.</p>
<p>It installs automatically (<tt class="docutils literal">auto_install</tt>) as soon as both
<tt class="docutils literal">survey_xlsx_expand_multiple_choice</tt> and <tt class="docutils literal">survey_extra_fields</tt> are
installed, and is uninstalled when either of them is removed. There is
nothing to configure.</p>
<div class="admonition warning">
<p class="first admonition-title">Warning</p>
<p>The exclusion is implemented through the report extension hooks provided
by <tt class="docutils literal">survey_xlsx_expand_multiple_choice</tt>, which itself relies on hooks
added to <tt class="docutils literal">survey_xlsx</tt> by this pull request:</p>
<p><a class="reference external" href="https://github.com/elabore-coop/survey/pull/1">https://github.com/elabore-coop/survey/pull/1</a></p>
<p class="last">Without those hooks, <em>File</em> questions are not filtered out of the export.</p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-1">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-2">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-3">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-4">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-5">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-1">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues/new?body=module:%20survey_xlsx_extra_fields%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-2">Credits</a></h2>
<div class="section" id="authors">
<h3><a class="toc-backref" href="#toc-entry-3">Authors</a></h3>
<ul class="simple">
<li>Elabore</li>
</ul>
</div>
<div class="section" id="contributors">
<h3><a class="toc-backref" href="#toc-entry-4">Contributors</a></h3>
<ul class="simple">
<li><a class="reference external" href="https://www.elabore.coop">Elabore</a><ul>
<li>Quentin Mondot</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h3><a class="toc-backref" href="#toc-entry-5">Maintainers</a></h3>
<p>This module is part of the <a class="reference external" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_extra_fields">elabore-coop/survey-tools</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

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

View File

@@ -0,0 +1,55 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import io
import openpyxl
from odoo.addons.survey.tests import common
class TestExcludeFileQuestion(common.TestSurveyCommon):
def setUp(self):
super().setUp()
self.text_question = self._add_question(
self.page_0,
"Your name",
"char_box",
survey_id=self.survey.id,
)
self.file_question = self._add_question(
self.page_0,
"Upload your document",
"file",
constr_mandatory=False,
survey_id=self.survey.id,
)
answer = self._add_answer(self.survey, False, email="test@example.com")
self._add_answer_line(self.text_question, answer, "Alice")
self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.file_question.id,
"answer_type": "file",
"skipped": False,
"value_file": "ZmFrZQ==",
"value_file_fname": "doc.pdf",
})
answer._mark_done()
def _get_sheet(self):
report = self.env.ref("survey_xlsx.report_survey_xlsx")
rep = self.env["ir.actions.report"]._render(report, self.survey.ids, {})
wb = openpyxl.load_workbook(io.BytesIO(rep[0]))
return wb.worksheets[0]
def _find_col(self, sheet, header):
for col in range(1, sheet.max_column + 1):
if sheet.cell(1, col).value == header:
return col
return None
def test_file_question_excluded(self):
sheet = self._get_sheet()
# Regular questions are still exported
self.assertIsNotNone(self._find_col(sheet, "Your name"))
# File questions get no column at all
self.assertIsNone(self._find_col(sheet, "Upload your document"))