2 Commits

Author SHA1 Message Date
67fe375975 [WIP]survey_dropdown_choice 2026-05-05 17:02:02 +02:00
ab1ba7dbb6 [FIX]survey_record_generation
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m34s
2026-04-23 16:52:17 +02:00
54 changed files with 436 additions and 2691 deletions

View File

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

View File

@@ -1,26 +0,0 @@
# 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,
}

View File

@@ -1,54 +0,0 @@
# 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

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

View File

@@ -1,19 +0,0 @@
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

@@ -1,46 +0,0 @@
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 @@
from . import models

View File

@@ -0,0 +1,29 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Survey dropdown choice",
"summary": "Display a 'simple choice' question as a searchable dropdown instead of radio buttons",
"description": """
Adds an option on 'Multiple choice: only one answer' questions to render the
suggested answers as a searchable dropdown. While taking the survey, the
respondent can type in the input to filter the visible options, which is much
more convenient than scrolling through a long radio-button list.
""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "Elabore",
"website": "https://www.elabore.coop",
"category": "Marketing/Surveys",
"depends": ["survey"],
"data": [
"views/survey_question_views.xml",
"views/survey_templates.xml",
],
"assets": {
"survey.survey_assets": [
"survey_dropdown_choice/static/src/scss/survey_dropdown_choice.scss",
"survey_dropdown_choice/static/src/js/survey_dropdown_choice.js",
],
},
"installable": True,
}

View File

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

View File

@@ -0,0 +1,13 @@
from odoo import fields, models
class SurveyQuestion(models.Model):
_inherit = "survey.question"
display_dropdown = fields.Boolean(
string="Display as searchable dropdown",
help="Render the suggested answers as a searchable dropdown instead of "
"a list of radio buttons. Only relevant for 'Multiple choice: only "
"one answer' questions. The respondent can type in the dropdown to "
"filter the available options.",
)

View File

@@ -0,0 +1,185 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
// Strip combining diacritical marks (U+0300..U+036F) so "Élève" matches "eleve".
const STRIP_DIACRITICS = (str) =>
(str || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
publicWidget.registry.SurveyDropdownChoice = publicWidget.Widget.extend({
selector: ".o_survey_dropdown_choice",
events: {
"input .o_survey_dropdown_search": "_onSearchInput",
"focus .o_survey_dropdown_search": "_onSearchFocus",
"click .o_survey_dropdown_search_wrapper": "_onWrapperClick",
"keydown .o_survey_dropdown_search": "_onSearchKeydown",
"mousedown .o_survey_dropdown_option": "_onOptionMousedown",
"click .o_survey_dropdown_option": "_onOptionClick",
"click input[type='radio']": "_onRadioInputClick",
},
/**
* @override
*/
start() {
this._panelEl = this.el.querySelector(".o_survey_dropdown_panel");
this._searchEl = this.el.querySelector(".o_survey_dropdown_search");
this._noMatchEl = this.el.querySelector(".o_survey_dropdown_no_match");
this._options = Array.from(this.el.querySelectorAll(".o_survey_dropdown_option"));
this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
document.addEventListener("mousedown", this._onDocumentMousedown);
return this._super(...arguments);
},
/**
* @override
*/
destroy() {
document.removeEventListener("mousedown", this._onDocumentMousedown);
return this._super(...arguments);
},
// --------------------------------------------------------------------------
// Handlers
// --------------------------------------------------------------------------
_onWrapperClick() {
// Make sure clicking anywhere in the search row (chevron, padding, ...)
// focuses the input, which in turn opens the panel.
if (document.activeElement !== this._searchEl) {
this._searchEl.focus();
} else {
this._openPanel();
this._filter("");
}
},
_onSearchFocus() {
this._openPanel();
this._filter("");
},
_onSearchInput(ev) {
this._openPanel();
this._filter(ev.currentTarget.value);
},
_onSearchKeydown(ev) {
if (ev.key === "Escape") {
this._closePanel();
ev.currentTarget.blur();
}
},
/**
* Prevent the search input's `blur` from firing before `click` resolves
* (otherwise the panel would close and swallow the click).
*/
_onOptionMousedown(ev) {
ev.preventDefault();
},
/**
* The wrapping <label> forwards clicks to the radio input as its
* default action. Stop that bubbled click from reaching
* SurveyFormWidget._onRadioChoiceClick at the .o_survey_form level —
* it would see the o_survey_form_choice_item_selected class we just
* added and toggle the radio back off, leaving the question with no
* selected answer at submission time.
*/
_onRadioInputClick(ev) {
ev.stopPropagation();
},
_onOptionClick(ev) {
const option = ev.currentTarget;
const radio = option.querySelector('input[type="radio"]');
if (!radio) {
return;
}
// Manage the radio state ourselves and skip the survey form's
// "click-radio-to-untick" handler — picking the same item from a
// dropdown should re-affirm the selection, not clear it.
const radioName = radio.getAttribute("name");
this.el
.querySelectorAll(`input[type="radio"][name="${radioName}"]`)
.forEach((other) => {
other.checked = false;
other.classList.remove("o_survey_form_choice_item_selected");
});
radio.checked = true;
radio.classList.add("o_survey_form_choice_item_selected");
// Trigger `change` so survey_form.js handles "Other" textarea
// visibility, conditional questions and auto-submit-on-pick.
radio.dispatchEvent(new Event("change", { bubbles: true }));
this._searchEl.value = option.dataset.labelValue || "";
this._options.forEach((opt) => {
const isSelected = opt === option;
opt.classList.toggle("o_survey_selected", isSelected);
opt.classList.toggle("bg-light", isSelected);
const check = opt.querySelector(".o_survey_dropdown_check");
if (check) {
check.classList.toggle("invisible", !isSelected);
}
});
this._closePanel();
this._searchEl.focus();
},
_onDocumentMousedown(ev) {
if (!this.el.contains(ev.target)) {
this._closePanel();
}
},
// --------------------------------------------------------------------------
// Internal
// --------------------------------------------------------------------------
_openPanel() {
this._panelEl.classList.remove("d-none");
},
_closePanel() {
this._panelEl.classList.add("d-none");
},
_filter(query) {
const needle = STRIP_DIACRITICS(query.trim());
let visibleCount = 0;
this._options.forEach((option) => {
const haystack = STRIP_DIACRITICS(option.dataset.labelValue || option.textContent);
const matches = !needle || haystack.includes(needle);
option.classList.toggle("d-none", !matches);
if (matches) {
visibleCount += 1;
}
});
if (this._noMatchEl) {
this._noMatchEl.classList.toggle("d-none", visibleCount > 0);
}
},
});
// In `page_per_question` layout, survey_form.js loads the next question's HTML
// via AJAX and only re-bootstraps date pickers (see _onNextScreenDone). Extend
// it so our dropdown widget is also instantiated on each navigation.
if (publicWidget.registry.SurveyFormWidget) {
publicWidget.registry.SurveyFormWidget.include({
async _onNextScreenDone() {
const result = await this._super(...arguments);
const $dropdowns = this.$el.find(".o_survey_dropdown_choice");
if ($dropdowns.length) {
this.trigger_up("widgets_start_request", { $target: $dropdowns });
}
return result;
},
});
}
export default publicWidget.registry.SurveyDropdownChoice;

View File

@@ -0,0 +1,42 @@
.o_survey_dropdown_choice {
.o_survey_dropdown_search_wrapper {
cursor: text;
}
.o_survey_dropdown_search {
cursor: text;
}
// The chevron sits above the input — let clicks pass through so focusing
// the input (which opens the panel) works when clicking near the arrow.
.o_survey_dropdown_caret {
pointer-events: none;
}
.o_survey_dropdown_panel {
position: absolute;
left: 0;
right: 0;
z-index: 1050;
max-height: 18rem;
overflow-y: auto;
}
.o_survey_dropdown_option {
cursor: pointer;
transition: background-color 0.1s ease-in-out;
&:hover {
background-color: var(--bs-light, #f8f9fa);
}
&.o_survey_selected {
font-weight: 500;
}
}
.o_survey_dropdown_check {
width: 1rem;
flex-shrink: 0;
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_question_form_dropdown" model="ir.ui.view">
<field name="name">survey.question.form.dropdown</field>
<field name="model">survey.question</field>
<field name="inherit_id" ref="survey.survey_question_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='comments_allowed']" position="before">
<field name="display_dropdown" invisible="question_type != 'simple_choice'"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Inherit the standard "simple_choice" template:
- when display_dropdown is set on the question AND we are not in
read-only/print mode, render our dropdown variant instead
- otherwise keep the standard radio-button rendering untouched
-->
<template id="question_simple_choice_inherit"
inherit_id="survey.question_simple_choice"
name="Question: simple choice (dropdown variant)">
<xpath expr="//div[@data-question-type='simple_choice_radio']" position="before">
<t t-if="question.display_dropdown and not survey_form_readonly"
t-call="survey_dropdown_choice.question_simple_choice_dropdown"/>
</xpath>
<xpath expr="//div[@data-question-type='simple_choice_radio']" position="attributes">
<attribute name="t-if">not question.display_dropdown or survey_form_readonly</attribute>
</xpath>
</template>
<!--
Searchable dropdown variant.
Keeps `data-question-type="simple_choice_radio"` and a real <input
type="radio"> per option so that the existing survey_form.js
validation/serialization logic (_validateForm, _prepareSubmitChoices)
works without any change.
-->
<template id="question_simple_choice_dropdown" name="Question: simple choice (dropdown)">
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.suggested_answer_id)"/>
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_char_box)"/>
<t t-set="selected_label" t-value="answer_line.suggested_answer_id"/>
<div class="o_survey_answer_wrapper o_survey_form_choice o_survey_dropdown_choice position-relative"
t-att-data-name="question.id"
t-att-data-is-skipped-question="is_skipped_question or None"
data-question-type="simple_choice_radio">
<div class="o_survey_dropdown_search_wrapper position-relative">
<input type="text"
class="o_survey_dropdown_search form-control pe-5"
autocomplete="off"
t-att-placeholder="question.question_placeholder or 'Type to filter...'"
t-att-value="selected_label.value if selected_label else ''"/>
<i class="o_survey_dropdown_caret fa fa-chevron-down position-absolute end-0 top-50 translate-middle-y me-3 text-muted"
aria-hidden="true"/>
</div>
<div class="o_survey_dropdown_panel d-none mt-1 border rounded shadow-sm bg-white">
<ul class="o_survey_dropdown_options list-unstyled m-0 p-0">
<t t-foreach="question.suggested_answer_ids" t-as="label">
<t t-set="answer_selected" t-value="answer_line and answer_line.suggested_answer_id.id == label.id"/>
<li t-attf-class="o_survey_dropdown_option px-3 py-2 #{'o_survey_selected bg-light' if answer_selected else ''}"
t-att-data-label-value="label.value"
t-att-data-answer-id="label.id">
<label t-att-for="str(question.id) + '_' + str(label.id)"
class="o_survey_choice_btn w-100 m-0 d-flex align-items-center"
style="cursor: pointer;">
<input t-att-id="str(question.id) + '_' + str(label.id)"
type="radio"
t-att-value="label.id"
t-attf-class="o_survey_form_choice_item invisible position-absolute #{'o_survey_form_choice_item_selected' if answer_selected else ''}"
t-att-name="question.id"
t-att-checked="'checked' if answer_selected else None"/>
<i t-attf-class="o_survey_dropdown_check fa fa-check me-2 #{'' if answer_selected else 'invisible'}"
aria-hidden="true"/>
<span class="text-break" t-field="label.value"/>
</label>
</li>
</t>
<li class="o_survey_dropdown_no_match d-none px-3 py-2 text-muted fst-italic">
No match found.
</li>
<t t-if="question.comments_allowed and question.comment_count_as_answer">
<li t-attf-class="o_survey_dropdown_option o_survey_dropdown_option_other px-3 py-2 #{'o_survey_selected bg-light' if comment_line else ''}"
data-label-value="">
<label class="o_survey_choice_btn w-100 m-0 d-flex align-items-center"
style="cursor: pointer;">
<input type="radio"
class="o_survey_form_choice_item o_survey_js_form_other_comment invisible position-absolute"
value="-1"
t-att-name="question.id"
t-att-checked="comment_line and 'checked' or None"/>
<i t-attf-class="o_survey_dropdown_check fa fa-check me-2 #{'' if comment_line else 'invisible'}"
aria-hidden="true"/>
<span t-out="question.comments_message or default_comments_message"/>
</label>
</li>
</t>
</ul>
</div>
<t t-if="question.comments_allowed and question.comment_count_as_answer">
<div t-attf-class="o_survey_comment_container mt-3 py-0 px-1 h-auto #{'d-none' if not comment_line else ''}">
<textarea type="text" class="form-control o_survey_question_text_box bg-transparent rounded-0 p-0"
t-att-disabled="None if comment_line else 'disabled'"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
</div>
</t>
<div t-if="question.comments_allowed and not question.comment_count_as_answer"
class="mb-2 o_survey_comment_container mt-3">
<textarea type="text"
class="col form-control o_survey_comment o_survey_question_text_box bg-transparent rounded-0 p-0"
t-att-placeholder="question.comments_message or default_comments_message"><t t-esc="comment_line.value_char_box if comment_line else ''"/></textarea>
</div>
</div>
</template>
</odoo>

View File

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

View File

@@ -1,33 +0,0 @@
# 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

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

View File

@@ -1,61 +0,0 @@
# 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

@@ -1,129 +0,0 @@
# 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

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

View File

@@ -1,20 +0,0 @@
# 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

@@ -1,94 +0,0 @@
# 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

@@ -1,17 +0,0 @@
# 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

@@ -1,232 +0,0 @@
/** @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

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

View File

@@ -1,297 +0,0 @@
# 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

@@ -1,20 +0,0 @@
<?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

@@ -1,81 +0,0 @@
<?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

@@ -1,17 +0,0 @@
<?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

@@ -11,7 +11,7 @@ Allow to create record of any model when sending the form :
* Associate question with fields
* For x2m fields : Associate values to questions
""",
"version": "18.0.1.0.0",
"version": "18.0.1.0.1",
"license": "AGPL-3",
"author": "Elabore",
"website": "https://www.elabore.coop",

View File

@@ -32,8 +32,9 @@ class SurveyQuestion(models.Model):
def fill(self):
for question in self:
if question.model_id:
new_suggested_answer_ids = [Command.clear()]
if question.suggested_answer_ids:
question.suggested_answer_ids = [Command.clear()]
elif question.model_id:
record_model = question.model_id.model
if question.fill_domain:
@@ -43,7 +44,11 @@ class SurveyQuestion(models.Model):
records = self.env[record_model].search(domain)
new_suggested_answer_ids += [Command.create({'value':record.display_name, 'record_id':f"{record_model},{record.id}"
}) for record in records]
question.suggested_answer_ids = new_suggested_answer_ids
question.suggested_answer_ids = [
Command.create({
'value': record.display_name,
'record_id': f"{record_model},{record.id}",
})
for record in records
]

View File

@@ -5,9 +5,6 @@ from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools.misc import format_date
_logger = logging.getLogger(__name__)
type_mapping = { #field types on the left, question types on the right. TODO : what about booleans ?
"char": ["char_box", "numerical_box", "date", "datetime", "simple_choice", "multiple_choice"],
"text": ["char_box", "date", "simple_choice"],
@@ -78,20 +75,28 @@ class SurveyRecordCreationFieldValues(models.Model):
record.field_help = field_help
@api.depends('field_id')
@api.depends('field_id', 'survey_record_creation_id.survey_id')
def _compute_allowed_question_ids(self):
for record_creation_field_values in self:
if not record_creation_field_values.survey_id or not record_creation_field_values.field_id:
record_creation_field_values.allowed_question_ids = None
for record in self:
survey = record.survey_id or record.survey_record_creation_id.survey_id
if not survey or not record.field_id:
record.allowed_question_ids = False
continue
question_domain = [('survey_id','=',record_creation_field_values.survey_id.id)]
if record_creation_field_values.field_id.ttype in ['many2one','many2many']:
question_domain.extend(['|','&',('answer_values_type','=','record'),('model_id','=',record_creation_field_values.field_id.relation),('answer_values_type','=','value')])
if record_creation_field_values.field_id.ttype in type_mapping:
question_domain.append(('question_type','in',type_mapping[record_creation_field_values.field_id.ttype]))
question_domain = [('survey_id', '=', survey.id)]
record_creation_field_values.allowed_question_ids = self.env['survey.question'].search(question_domain)
if record.field_id.ttype in ['many2one', 'many2many']:
question_domain.extend([
'|', '&',
('answer_values_type', '=', 'record'),
('model_id', '=', record.field_id.relation),
('answer_values_type', '=', 'value'),
])
if record.field_id.ttype in type_mapping:
question_domain.append(('question_type', 'in', type_mapping[record.field_id.ttype]))
questions = self.env['survey.question'].search(question_domain)
record.allowed_question_ids = questions
@api.model

View File

@@ -6,15 +6,17 @@
<field name="inherit_id" ref="survey.survey_question_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='suggested_answer_ids']" position="before">
<field name="answer_values_type" />
<field name="model_id" invisible="answer_values_type != 'record'" />
<field name="model_name" invisible="1" />
<field name="fill_domain" widget="domain" options="{'model': 'model_name'}" invisible="answer_values_type != 'record' or not model_id" />
<button name="fill" string="Empty and fill"
type="object"
colspan="2"
help="Empty the list and fill it with all items of selected model matching domain"
invisible="answer_values_type != 'record'" />
<group invisible="question_type not in ['simple_choice', 'multiple_choice', 'matrix']">
<field name="answer_values_type" />
<field name="model_id" invisible="answer_values_type != 'record'" />
<field name="model_name" invisible="1" />
<field name="fill_domain" widget="domain" options="{'model': 'model_name'}" invisible="answer_values_type != 'record' or not model_id" />
<button name="fill" string="Empty and fill"
type="object"
colspan="2"
help="Empty the list and fill it with all items of selected model matching domain"
invisible="answer_values_type != 'record'" />
</group>
</xpath>
<xpath expr="//field[@name='suggested_answer_ids']" position="attributes">
<attribute

View File

@@ -1,109 +0,0 @@
.. 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

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

View File

@@ -1,16 +0,0 @@
# 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

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

View File

@@ -1,23 +0,0 @@
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

@@ -1,17 +0,0 @@
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

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

View File

@@ -1,70 +0,0 @@
# 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

@@ -1,466 +0,0 @@
<!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

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

View File

@@ -1,148 +0,0 @@
# 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

@@ -1,87 +0,0 @@
.. 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

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

View File

@@ -1,19 +0,0 @@
# 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

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

View File

@@ -1,22 +0,0 @@
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

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

View File

@@ -1,15 +0,0 @@
# 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

@@ -1,442 +0,0 @@
<!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

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

View File

@@ -1,55 +0,0 @@
# 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"))