From 8f235646ef96201f70671c89fd11cd63f05bcc1c Mon Sep 17 00:00:00 2001 From: Quentin Mondot Date: Wed, 10 Jun 2026 15:38:57 +0200 Subject: [PATCH 1/3] [ADD] survey_extra_fields, survey_base : copy modules from v16 --- survey_base/__init__.py | 1 + survey_base/__manifest__.py | 26 ++ survey_base/i18n/fr.po | 54 ++++ survey_base/models/__init__.py | 2 + survey_base/models/survey_question_answer.py | 19 ++ survey_base/models/survey_user_input_line.py | 46 +++ survey_extra_fields/__init__.py | 2 + survey_extra_fields/__manifest__.py | 33 +++ survey_extra_fields/controllers/__init__.py | 1 + survey_extra_fields/controllers/main.py | 61 ++++ survey_extra_fields/i18n/fr.po | 124 ++++++++ survey_extra_fields/models/__init__.py | 3 + survey_extra_fields/models/survey_question.py | 20 ++ .../models/survey_user_input.py | 73 +++++ .../models/survey_user_input_line.py | 17 ++ .../static/src/js/survey_form.js | 146 +++++++++ survey_extra_fields/tests/__init__.py | 1 + survey_extra_fields/tests/test_survey_file.py | 278 ++++++++++++++++++ .../views/survey_question_views.xml | 20 ++ .../views/survey_templates.xml | 68 +++++ .../views/survey_user_views.xml | 17 ++ 21 files changed, 1012 insertions(+) create mode 100644 survey_base/__init__.py create mode 100644 survey_base/__manifest__.py create mode 100644 survey_base/i18n/fr.po create mode 100644 survey_base/models/__init__.py create mode 100644 survey_base/models/survey_question_answer.py create mode 100644 survey_base/models/survey_user_input_line.py create mode 100644 survey_extra_fields/__init__.py create mode 100644 survey_extra_fields/__manifest__.py create mode 100644 survey_extra_fields/controllers/__init__.py create mode 100644 survey_extra_fields/controllers/main.py create mode 100644 survey_extra_fields/i18n/fr.po create mode 100644 survey_extra_fields/models/__init__.py create mode 100644 survey_extra_fields/models/survey_question.py create mode 100644 survey_extra_fields/models/survey_user_input.py create mode 100644 survey_extra_fields/models/survey_user_input_line.py create mode 100644 survey_extra_fields/static/src/js/survey_form.js create mode 100644 survey_extra_fields/tests/__init__.py create mode 100644 survey_extra_fields/tests/test_survey_file.py create mode 100644 survey_extra_fields/views/survey_question_views.xml create mode 100644 survey_extra_fields/views/survey_templates.xml create mode 100644 survey_extra_fields/views/survey_user_views.xml diff --git a/survey_base/__init__.py b/survey_base/__init__.py new file mode 100644 index 0000000..9a7e03e --- /dev/null +++ b/survey_base/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/survey_base/__manifest__.py b/survey_base/__manifest__.py new file mode 100644 index 0000000..adee7e2 --- /dev/null +++ b/survey_base/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2016-2020 Akretion France () +# @author: Alexis de Lattre +# 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": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Elabore", + "website": "https://www.elabore.coop", + "category": "", + "depends": ["survey"], + "data": [ + + ], + "installable": True, +} diff --git a/survey_base/i18n/fr.po b/survey_base/i18n/fr.po new file mode 100644 index 0000000..45d1244 --- /dev/null +++ b/survey_base/i18n/fr.po @@ -0,0 +1,54 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * survey_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-17 14:44+0000\n" +"PO-Revision-Date: 2026-02-17 14:44+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: survey_base +#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__value_file +msgid "File" +msgstr "Fichier" + +#. module: survey_base +#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__value_file_fname +msgid "File Name" +msgstr "Nom du fichier" + +#. module: survey_base +#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__record_reference +#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__record_reference +msgid "Record" +msgstr "Enregistrement" + +#. module: survey_base +#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__record_reference_model +#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__record_reference_model +msgid "Record Model" +msgstr "Modèle de l'enregistrement" + +#. module: survey_base +#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__smart_search +#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__smart_search +msgid "Smart Search" +msgstr "Recherche intelligente" + +#. module: survey_base +#: model:ir.model,name:survey_base.model_survey_question_answer +msgid "Survey Label" +msgstr "Étiquette du sondage" + +#. module: survey_base +#: model:ir.model,name:survey_base.model_survey_user_input_line +msgid "Survey User Input Line" +msgstr "Ligne d'entrée pour l'utilisateur du sondage" diff --git a/survey_base/models/__init__.py b/survey_base/models/__init__.py new file mode 100644 index 0000000..4487824 --- /dev/null +++ b/survey_base/models/__init__.py @@ -0,0 +1,2 @@ +from . import survey_question_answer +from . import survey_user_input_line \ No newline at end of file diff --git a/survey_base/models/survey_question_answer.py b/survey_base/models/survey_question_answer.py new file mode 100644 index 0000000..8af6e75 --- /dev/null +++ b/survey_base/models/survey_question_answer.py @@ -0,0 +1,19 @@ + +import logging +import textwrap +import uuid + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools import float_is_zero + +_logger = logging.getLogger(__name__) + + +class SurveyQuestionAnswer(models.Model): + _inherit = 'survey.question.answer' + + record_reference = fields.Many2oneReference(model_field="record_reference_model", string="Record") + record_reference_model = fields.Char("Record Model") \ No newline at end of file diff --git a/survey_base/models/survey_user_input_line.py b/survey_base/models/survey_user_input_line.py new file mode 100644 index 0000000..f19c031 --- /dev/null +++ b/survey_base/models/survey_user_input_line.py @@ -0,0 +1,46 @@ + +import logging +import textwrap +import uuid + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError +from odoo.tools import float_is_zero + +_logger = logging.getLogger(__name__) + + +class SurveyUserInputLine(models.Model): + _inherit = 'survey.user_input.line' + + #attachment fields + value_file = fields.Binary(string="File") + value_file_fname = fields.Char(string="File Name") + + #record reference fields + record_reference = fields.Many2oneReference(model_field="record_reference_model", string="Record") + record_reference_model = fields.Char('Record Model') + + """set record_reference when saving survey_user_input line + """ + def set_record_reference_data(self, vals): + if vals.get('answer_type') == "suggestion" and 'suggested_answer_id' in vals: + #find model + answer = self.env['survey.question.answer'].browse(vals['suggested_answer_id']) + vals['record_reference_model'] = answer.record_reference_model + vals['record_reference'] = answer.record_reference + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self.set_record_reference_data(vals) + return super(SurveyUserInputLine, self).create(vals_list) + + + def write(self, vals): + self.set_record_reference_data(vals) + return super(SurveyUserInputLine, self).write(vals) + + diff --git a/survey_extra_fields/__init__.py b/survey_extra_fields/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/survey_extra_fields/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/survey_extra_fields/__manifest__.py b/survey_extra_fields/__manifest__.py new file mode 100644 index 0000000..77339c2 --- /dev/null +++ b/survey_extra_fields/__manifest__.py @@ -0,0 +1,33 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "Survey extra fields", + "summary": "Add extra question types to surveys", + "description": """ +Add extra question types to surveys: +---------------------------------------------------- +* File upload question type + - Allows survey participants to upload a file as an answer + - Configurable maximum file size (in MB) per question (default: 10 MB, 0 = no limit) + - Configurable allowed file extensions per question (e.g. .pdf,.docx — empty = all types allowed) + - Client-side validation (size and extension) before form submission + - Server-side validation on save to enforce constraints +""", + "version": "16.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, +} diff --git a/survey_extra_fields/controllers/__init__.py b/survey_extra_fields/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/survey_extra_fields/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/survey_extra_fields/controllers/main.py b/survey_extra_fields/controllers/main.py new file mode 100644 index 0000000..d417641 --- /dev/null +++ b/survey_extra_fields/controllers/main.py @@ -0,0 +1,61 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING, Any + +from odoo import http +from odoo.http import request, content_disposition +from odoo.addons.survey.controllers.main import Survey + +if TYPE_CHECKING: + from werkzeug.wrappers import Response + + +class SurveyExtraFieldsController(Survey): + + @http.route( + "/survey/file///", + 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: + return request.not_found() + + answer = request.env["survey.user_input"].sudo().search( + [ + ("survey_id", "=", survey.id), + ("access_token", "=", answer_token), + ], + limit=1, + ) + if not answer: + return request.not_found() + + line = request.env["survey.user_input.line"].sudo().browse(line_id) + if not line.exists() or line.user_input_id != answer: + return request.not_found() + + if not line.value_file: + return 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)), + ], + ) diff --git a/survey_extra_fields/i18n/fr.po b/survey_extra_fields/i18n/fr.po new file mode 100644 index 0000000..8a6d53f --- /dev/null +++ b/survey_extra_fields/i18n/fr.po @@ -0,0 +1,124 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * survey_extra_fields +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-02-18 16:38+0000\n" +"PO-Revision-Date: 2026-02-18 16:38+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: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.fields,field_description:survey_extra_fields.field_survey_question__smart_search +#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_user_input__smart_search +#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_user_input_line__smart_search +msgid "Smart Search" +msgstr "Recherche intelligente" + +#. 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 +#, python-format +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 +#, python-format +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 +#, python-format +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-javascript +#: code:addons/survey_extra_fields/static/src/js/survey_form.js:0 +#, python-format +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 +#, python-format +msgid "This question requires an answer." +msgstr "Cette question requiert une réponse." diff --git a/survey_extra_fields/models/__init__.py b/survey_extra_fields/models/__init__.py new file mode 100644 index 0000000..198087b --- /dev/null +++ b/survey_extra_fields/models/__init__.py @@ -0,0 +1,3 @@ +from . import survey_question +from . import survey_user_input +from . import survey_user_input_line diff --git a/survey_extra_fields/models/survey_question.py b/survey_extra_fields/models/survey_question.py new file mode 100644 index 0000000..8bb622c --- /dev/null +++ b/survey_extra_fields/models/survey_question.py @@ -0,0 +1,20 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SurveyQuestion(models.Model): + _inherit = "survey.question" + + question_type = fields.Selection( + selection_add=[("file", "File")] + ) + max_file_size = fields.Integer( + string="Max File Size (MB)", + default=10, + help="Maximum file size in MB. Leave 0 for no limit.", + ) + allowed_extensions = fields.Char( + string="Allowed Extensions", + help="Comma-separated list of allowed extensions (e.g. .pdf,.docx). Leave empty to allow all types.", + ) diff --git a/survey_extra_fields/models/survey_user_input.py b/survey_extra_fields/models/survey_user_input.py new file mode 100644 index 0000000..5ea52d2 --- /dev/null +++ b/survey_extra_fields/models/survey_user_input.py @@ -0,0 +1,73 @@ +# 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 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) -> None: + if question.question_type == "file": + old_answers = self.env["survey.user_input.line"].search([ + ("user_input_id", "=", self.id), + ("question_id", "=", question.id), + ]) + vals = { + "user_input_id": self.id, + "question_id": question.id, + "skipped": False, + "answer_type": "file", + } + if answer: + file_data = json.loads(answer) + 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) + + 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) + ) diff --git a/survey_extra_fields/models/survey_user_input_line.py b/survey_extra_fields/models/survey_user_input_line.py new file mode 100644 index 0000000..19f851f --- /dev/null +++ b/survey_extra_fields/models/survey_user_input_line.py @@ -0,0 +1,17 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SurveyUserInputLine(models.Model): + _inherit = "survey.user_input.line" + + answer_type = fields.Selection( + selection_add=[("file", "File")] + ) + + def _compute_display_name(self): + super()._compute_display_name() + for line in self: + if line.answer_type == "file" and line.value_file_fname: + line.display_name = line.value_file_fname diff --git a/survey_extra_fields/static/src/js/survey_form.js b/survey_extra_fields/static/src/js/survey_form.js new file mode 100644 index 0000000..20844b8 --- /dev/null +++ b/survey_extra_fields/static/src/js/survey_form.js @@ -0,0 +1,146 @@ +odoo.define("survey_extra_fields.survey_form", function (require) { + "use strict"; + + var core = require("web.core"); + var _t = core._t; + var survey_form = require("survey.form"); + + survey_form.include({ + _readFileAsDataURL: function (file) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onload = function (e) { + resolve(e.target.result); + }; + reader.onerror = function () { + reject(reader.error); + }; + reader.readAsDataURL(file); + }); + }, + + _submitForm: function (options) { + var self = this; + var $fileInputs = this.$('input[data-question-type="file"]'); + var hasFiles = false; + $fileInputs.each(function () { + if (this.files && this.files.length > 0) { + hasFiles = true; + return false; + } + }); + + if (!hasFiles || this.options.isStartScreen) { + return this._super(options); + } + + // Async flow: read files then submit + var params = {}; + if (options.previousPageId) { + params.previous_page_id = options.previousPageId; + } + + var $form = this.$("form"); + var formData = new FormData($form[0]); + + if (!options.skipValidation) { + if (!this._validateForm($form, formData)) { + return; + } + } + + this._prepareSubmitValues(formData, params); + + // Read all selected files as base64 + var filePromises = []; + $fileInputs.each(function () { + if (this.files && this.files.length > 0) { + var file = this.files[0]; + var name = this.name; + filePromises.push( + self._readFileAsDataURL(file).then(function (dataURL) { + params[name] = JSON.stringify({ + data: dataURL.split(",")[1], + name: file.name, + }); + }) + ); + } + }); + + this.preventEnterSubmit = true; + + if (this.options.sessionInProgress) { + this.fadeInOutDelay = 400; + this.readonly = true; + } + + Promise.all(filePromises).then(function () { + var submitPromise = self._rpc({ + route: _.str.sprintf( + "%s/%s/%s", + "/survey/submit", + self.options.surveyToken, + self.options.answerToken + ), + params: params, + }); + self._nextScreen(submitPromise, options); + }); + }, + + _validateForm: function ($form, formData) { + var result = this._super.apply(this, arguments); + var errors = {}; + var inactiveQuestionIds = this.options.sessionInProgress + ? [] + : this._getInactiveConditionalQuestionIds(); + + $form.find('input[data-question-type="file"]').each(function () { + var $questionWrapper = $(this).closest(".js_question-wrapper"); + var questionId = $questionWrapper.attr("id"); + if (inactiveQuestionIds.includes(parseInt(questionId))) { + return; + } + var questionRequired = $questionWrapper.data("required"); + var constrErrorMsg = + $questionWrapper.data("constrErrorMsg") || + _t("This question requires an answer."); + if (questionRequired && !(this.files && this.files.length > 0)) { + errors[questionId] = constrErrorMsg; + return; + } + if (this.files && this.files.length > 0) { + var file = this.files[0]; + var maxSizeMB = parseInt($(this).data("maxFileSize")); + if (maxSizeMB && file.size > maxSizeMB * 1024 * 1024) { + errors[questionId] = _.str.sprintf( + _t("The file exceeds the maximum allowed size of %s MB."), + maxSizeMB + ); + return; + } + var allowedExtensions = $(this).data("allowedExtensions"); + if (allowedExtensions) { + var allowed = allowedExtensions.split(",").map(function (e) { + return e.trim().toLowerCase(); + }); + var ext = "." + file.name.split(".").pop().toLowerCase(); + if (!allowed.includes(ext)) { + errors[questionId] = _.str.sprintf( + _t("This file type is not allowed. Accepted formats: %s."), + allowedExtensions + ); + } + } + } + }); + + if (_.keys(errors).length > 0) { + this._showErrors(errors); + return false; + } + return result; + }, + }); +}); diff --git a/survey_extra_fields/tests/__init__.py b/survey_extra_fields/tests/__init__.py new file mode 100644 index 0000000..d0e947c --- /dev/null +++ b/survey_extra_fields/tests/__init__.py @@ -0,0 +1 @@ +from . import test_survey_file diff --git a/survey_extra_fields/tests/test_survey_file.py b/survey_extra_fields/tests/test_survey_file.py new file mode 100644 index 0000000..3529ac9 --- /dev/null +++ b/survey_extra_fields/tests/test_survey_file.py @@ -0,0 +1,278 @@ +# 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_skip(self): + """Uploading a file then submitting empty marks line as skipped.""" + 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.assertTrue(line.skipped) + + +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) diff --git a/survey_extra_fields/views/survey_question_views.xml b/survey_extra_fields/views/survey_question_views.xml new file mode 100644 index 0000000..8eaa08f --- /dev/null +++ b/survey_extra_fields/views/survey_question_views.xml @@ -0,0 +1,20 @@ + + + + + survey.question.form.inherit.extra_fields + survey.question + + + + + + + + + + diff --git a/survey_extra_fields/views/survey_templates.xml b/survey_extra_fields/views/survey_templates.xml new file mode 100644 index 0000000..26ac874 --- /dev/null +++ b/survey_extra_fields/views/survey_templates.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + diff --git a/survey_extra_fields/views/survey_user_views.xml b/survey_extra_fields/views/survey_user_views.xml new file mode 100644 index 0000000..e97bee3 --- /dev/null +++ b/survey_extra_fields/views/survey_user_views.xml @@ -0,0 +1,17 @@ + + + + + survey.user_input.line.view.form.inherit.extra_fields + survey.user_input.line + + + + + + + + + + -- 2.49.1 From 8a2075a3db12cb43533af7deb376fa4b4445236b Mon Sep 17 00:00:00 2001 From: Quentin Mondot Date: Wed, 10 Jun 2026 16:56:56 +0200 Subject: [PATCH 2/3] [MIG] survey_extra_fields, survey_base : modifications to make them work in v18 --- survey_base/__manifest__.py | 2 +- survey_extra_fields/__manifest__.py | 2 +- survey_extra_fields/controllers/main.py | 8 +- survey_extra_fields/i18n/fr.po | 48 ++-- .../models/survey_user_input.py | 16 +- .../static/src/js/survey_form.js | 266 +++++++++--------- survey_extra_fields/tests/test_survey_file.py | 26 +- .../views/survey_question_views.xml | 4 +- .../views/survey_templates.xml | 2 +- .../views/survey_user_views.xml | 2 +- 10 files changed, 194 insertions(+), 182 deletions(-) diff --git a/survey_base/__manifest__.py b/survey_base/__manifest__.py index adee7e2..2087a44 100644 --- a/survey_base/__manifest__.py +++ b/survey_base/__manifest__.py @@ -13,7 +13,7 @@ Add fields used by several survey addons * Implementation of theses fields should be in another module """, - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "license": "AGPL-3", "author": "Elabore", "website": "https://www.elabore.coop", diff --git a/survey_extra_fields/__manifest__.py b/survey_extra_fields/__manifest__.py index 77339c2..352ca91 100644 --- a/survey_extra_fields/__manifest__.py +++ b/survey_extra_fields/__manifest__.py @@ -13,7 +13,7 @@ Add extra question types to surveys: - Client-side validation (size and extension) before form submission - Server-side validation on save to enforce constraints """, - "version": "16.0.1.0.0", + "version": "18.0.1.0.0", "license": "AGPL-3", "author": "Elabore", "website": "https://www.elabore.coop", diff --git a/survey_extra_fields/controllers/main.py b/survey_extra_fields/controllers/main.py index d417641..4181954 100644 --- a/survey_extra_fields/controllers/main.py +++ b/survey_extra_fields/controllers/main.py @@ -31,7 +31,7 @@ class SurveyExtraFieldsController(Survey): [("access_token", "=", survey_token)], limit=1 ) if not survey: - return request.not_found() + raise request.not_found() answer = request.env["survey.user_input"].sudo().search( [ @@ -41,14 +41,14 @@ class SurveyExtraFieldsController(Survey): limit=1, ) if not answer: - return request.not_found() + 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: - return request.not_found() + raise request.not_found() if not line.value_file: - return request.not_found() + raise request.not_found() file_content = base64.b64decode(line.value_file) filename = line.value_file_fname or "file" diff --git a/survey_extra_fields/i18n/fr.po b/survey_extra_fields/i18n/fr.po index 8a6d53f..ee74c48 100644 --- a/survey_extra_fields/i18n/fr.po +++ b/survey_extra_fields/i18n/fr.po @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" +"Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-18 16:38+0000\n" -"PO-Revision-Date: 2026-02-18 16:38+0000\n" +"POT-Creation-Date: 2026-06-10 14:31+0000\n" +"PO-Revision-Date: 2026-06-10 14:31+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -36,8 +36,8 @@ 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." +"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 @@ -54,7 +54,9 @@ 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." +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 @@ -66,13 +68,6 @@ msgstr "Type de question" msgid "Skipped" msgstr "Ignoré" -#. module: survey_extra_fields -#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__smart_search -#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_user_input__smart_search -#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_user_input_line__smart_search -msgid "Smart Search" -msgstr "Recherche intelligente" - #. module: survey_extra_fields #: model:ir.model,name:survey_extra_fields.model_survey_question msgid "Survey Question" @@ -91,34 +86,39 @@ 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 -#, python-format 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." +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 -#, python-format 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." +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 -#, python-format 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-javascript -#: code:addons/survey_extra_fields/static/src/js/survey_form.js:0 -#, python-format -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." +#. 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 -#, python-format msgid "This question requires an answer." msgstr "Cette question requiert une réponse." diff --git a/survey_extra_fields/models/survey_user_input.py b/survey_extra_fields/models/survey_user_input.py index 5ea52d2..35683b8 100644 --- a/survey_extra_fields/models/survey_user_input.py +++ b/survey_extra_fields/models/survey_user_input.py @@ -8,7 +8,7 @@ import os from typing import TYPE_CHECKING from odoo import _, models -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError if TYPE_CHECKING: from odoo.addons.survey_extra_fields.models.survey_question import SurveyQuestion @@ -17,12 +17,20 @@ if TYPE_CHECKING: class SurveyUserInput(models.Model): _inherit = "survey.user_input" - def save_lines(self, question: SurveyQuestion, answer: str | None, comment: str | None = None) -> None: + 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.")) vals = { "user_input_id": self.id, "question_id": question.id, @@ -43,7 +51,9 @@ class SurveyUserInput(models.Model): else: self.env["survey.user_input.line"].create(vals) else: - return super().save_lines(question, answer, comment=comment) + return super()._save_lines( + question, answer, comment=comment, overwrite_existing=overwrite_existing + ) def _check_file_constraints( self, diff --git a/survey_extra_fields/static/src/js/survey_form.js b/survey_extra_fields/static/src/js/survey_form.js index 20844b8..a84d148 100644 --- a/survey_extra_fields/static/src/js/survey_form.js +++ b/survey_extra_fields/static/src/js/survey_form.js @@ -1,146 +1,148 @@ -odoo.define("survey_extra_fields.survey_form", function (require) { - "use strict"; +/** @odoo-module **/ - var core = require("web.core"); - var _t = core._t; - var survey_form = require("survey.form"); +import publicWidget from "@web/legacy/js/public/public_widget"; +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; - survey_form.include({ - _readFileAsDataURL: function (file) { - return new Promise(function (resolve, reject) { - var reader = new FileReader(); - reader.onload = function (e) { - resolve(e.target.result); - }; - reader.onerror = function () { - reject(reader.error); - }; - reader.readAsDataURL(file); - }); - }, +publicWidget.registry.SurveyFormWidget.include({ + _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); + }); + }, - _submitForm: function (options) { - var self = this; - var $fileInputs = this.$('input[data-question-type="file"]'); - var hasFiles = false; - $fileInputs.each(function () { - if (this.files && this.files.length > 0) { - hasFiles = true; - return false; - } - }); + /** + * @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"]'); + const hasFiles = Array.from(fileInputs).some( + (input) => input.files && input.files.length > 0 + ); - if (!hasFiles || this.options.isStartScreen) { - return this._super(options); - } + if (!hasFiles || this.options.isStartScreen) { + return this._super(options); + } - // Async flow: read files then submit - var params = {}; - if (options.previousPageId) { - params.previous_page_id = options.previousPageId; - } + const params = {}; + if (options.previousPageId) { + params.previous_page_id = options.previousPageId; + } + if (options.nextSkipped) { + params.next_skipped_page_or_question = true; + } - var $form = this.$("form"); - var formData = new FormData($form[0]); + const $form = this.$("form"); + const formData = new FormData($form[0]); - if (!options.skipValidation) { - if (!this._validateForm($form, formData)) { - return; - } - } + if (!options.skipValidation && !this._validateForm($form, formData)) { + return; + } - this._prepareSubmitValues(formData, params); + this._prepareSubmitValues(formData, params); - // Read all selected files as base64 - var filePromises = []; - $fileInputs.each(function () { - if (this.files && this.files.length > 0) { - var file = this.files[0]; - var name = this.name; - filePromises.push( - self._readFileAsDataURL(file).then(function (dataURL) { - params[name] = JSON.stringify({ - data: dataURL.split(",")[1], - name: file.name, - }); - }) - ); - } - }); - - this.preventEnterSubmit = true; - - if (this.options.sessionInProgress) { - this.fadeInOutDelay = 400; - this.readonly = true; - } - - Promise.all(filePromises).then(function () { - var submitPromise = self._rpc({ - route: _.str.sprintf( - "%s/%s/%s", - "/survey/submit", - self.options.surveyToken, - self.options.answerToken - ), - params: params, - }); - self._nextScreen(submitPromise, options); - }); - }, - - _validateForm: function ($form, formData) { - var result = this._super.apply(this, arguments); - var errors = {}; - var inactiveQuestionIds = this.options.sessionInProgress - ? [] - : this._getInactiveConditionalQuestionIds(); - - $form.find('input[data-question-type="file"]').each(function () { - var $questionWrapper = $(this).closest(".js_question-wrapper"); - var questionId = $questionWrapper.attr("id"); - if (inactiveQuestionIds.includes(parseInt(questionId))) { - return; - } - var questionRequired = $questionWrapper.data("required"); - var constrErrorMsg = - $questionWrapper.data("constrErrorMsg") || - _t("This question requires an answer."); - if (questionRequired && !(this.files && this.files.length > 0)) { - errors[questionId] = constrErrorMsg; - return; - } - if (this.files && this.files.length > 0) { - var file = this.files[0]; - var maxSizeMB = parseInt($(this).data("maxFileSize")); - if (maxSizeMB && file.size > maxSizeMB * 1024 * 1024) { - errors[questionId] = _.str.sprintf( - _t("The file exceeds the maximum allowed size of %s MB."), - maxSizeMB - ); - return; - } - var allowedExtensions = $(this).data("allowedExtensions"); - if (allowedExtensions) { - var allowed = allowedExtensions.split(",").map(function (e) { - return e.trim().toLowerCase(); + // Read all selected files as base64 and add them to the submit params. + 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, }); - var ext = "." + file.name.split(".").pop().toLowerCase(); - if (!allowed.includes(ext)) { - errors[questionId] = _.str.sprintf( - _t("This file type is not allowed. Accepted formats: %s."), - allowedExtensions - ); - } + }) + ); + } + } + + // 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)) { + 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 (_.keys(errors).length > 0) { - this._showErrors(errors); - return false; } - return result; - }, - }); + }); + + if (Object.keys(errors).length > 0) { + this._showErrors(errors); + return false; + } + return result; + }, }); + +export default publicWidget.registry.SurveyFormWidget; diff --git a/survey_extra_fields/tests/test_survey_file.py b/survey_extra_fields/tests/test_survey_file.py index 3529ac9..47886c1 100644 --- a/survey_extra_fields/tests/test_survey_file.py +++ b/survey_extra_fields/tests/test_survey_file.py @@ -46,14 +46,14 @@ class TestSurveyFileCommon(common.TestSurveyCommon): class TestSurveyFileSaveLines(TestSurveyFileCommon): - """Test the save_lines method for file question type.""" + """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) + answer._save_lines(self.question_file, file_json) line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file @@ -68,7 +68,7 @@ class TestSurveyFileSaveLines(TestSurveyFileCommon): """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, "") + answer._save_lines(self.question_file, "") line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file @@ -81,11 +81,11 @@ class TestSurveyFileSaveLines(TestSurveyFileCommon): """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) + 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) + answer._save_lines(self.question_file, file_json_2) lines = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file @@ -98,9 +98,9 @@ class TestSurveyFileSaveLines(TestSurveyFileCommon): """Uploading a file then submitting empty marks line as skipped.""" 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, file_json) - answer.save_lines(self.question_file, "") + answer._save_lines(self.question_file, "") line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file @@ -117,7 +117,7 @@ class TestSurveyFileConstraints(TestSurveyFileCommon): 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) + answer._save_lines(self.question_file, file_json) line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file ) @@ -128,7 +128,7 @@ class TestSurveyFileConstraints(TestSurveyFileCommon): 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) + answer._save_lines(self.question_file, file_json) line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file ) @@ -142,14 +142,14 @@ class TestSurveyFileConstraints(TestSurveyFileCommon): 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) + 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) + answer._save_lines(self.question_file, file_json) line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file ) @@ -161,14 +161,14 @@ class TestSurveyFileConstraints(TestSurveyFileCommon): 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) + 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) + answer._save_lines(self.question_file, file_json) line = answer.user_input_line_ids.filtered( lambda l: l.question_id == self.question_file ) diff --git a/survey_extra_fields/views/survey_question_views.xml b/survey_extra_fields/views/survey_question_views.xml index 8eaa08f..78adb90 100644 --- a/survey_extra_fields/views/survey_question_views.xml +++ b/survey_extra_fields/views/survey_question_views.xml @@ -8,10 +8,10 @@ diff --git a/survey_extra_fields/views/survey_templates.xml b/survey_extra_fields/views/survey_templates.xml index 26ac874..f2aaa09 100644 --- a/survey_extra_fields/views/survey_templates.xml +++ b/survey_extra_fields/views/survey_templates.xml @@ -6,7 +6,7 @@ id="question_container_inh_type_file" inherit_id="survey.question_container" > - + diff --git a/survey_extra_fields/views/survey_user_views.xml b/survey_extra_fields/views/survey_user_views.xml index e97bee3..77eb0e6 100644 --- a/survey_extra_fields/views/survey_user_views.xml +++ b/survey_extra_fields/views/survey_user_views.xml @@ -8,7 +8,7 @@ + invisible="answer_type != 'file'"/> -- 2.49.1 From d0afa2310d78011ef90873618ca20fe58d888940 Mon Sep 17 00:00:00 2001 From: Quentin Mondot Date: Wed, 10 Jun 2026 17:23:31 +0200 Subject: [PATCH 3/3] [IMP] survey_extra_fields : handle file question on page navigation * Show a stored file (after navigating back) the same way as a freshly selected one: a "filename + Remove file" chip, input hidden until removal. * Keep the stored file when a page is re-submitted without a new selection (instead of overwriting it with a skipped answer). * "Remove file" now sends a sentinel to actually delete the stored file, so the removal persists across navigation. --- survey_extra_fields/i18n/fr.po | 9 +- .../models/survey_user_input.py | 15 ++- .../static/src/js/survey_form.js | 92 ++++++++++++++++++- survey_extra_fields/tests/test_survey_file.py | 23 ++++- .../views/survey_templates.xml | 21 ++++- 5 files changed, 146 insertions(+), 14 deletions(-) diff --git a/survey_extra_fields/i18n/fr.po b/survey_extra_fields/i18n/fr.po index ee74c48..955f592 100644 --- a/survey_extra_fields/i18n/fr.po +++ b/survey_extra_fields/i18n/fr.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-06-10 14:31+0000\n" -"PO-Revision-Date: 2026-06-10 14:31+0000\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" @@ -20,6 +20,11 @@ msgstr "" msgid ".pdf,.docx,.xlsx" msgstr "" +#. module: survey_extra_fields +#: model_terms:ir.ui.view,arch_db:survey_extra_fields.question_file +msgid "Remove file" +msgstr "Supprimer le fichier" + #. module: survey_extra_fields #: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__allowed_extensions msgid "Allowed Extensions" diff --git a/survey_extra_fields/models/survey_user_input.py b/survey_extra_fields/models/survey_user_input.py index 35683b8..27de879 100644 --- a/survey_extra_fields/models/survey_user_input.py +++ b/survey_extra_fields/models/survey_user_input.py @@ -31,14 +31,25 @@ class SurveyUserInput(models.Model): ]) 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", } - if answer: - file_data = json.loads(answer) + 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) diff --git a/survey_extra_fields/static/src/js/survey_form.js b/survey_extra_fields/static/src/js/survey_form.js index a84d148..f2f94fc 100644 --- a/survey_extra_fields/static/src/js/survey_form.js +++ b/survey_extra_fields/static/src/js/survey_form.js @@ -5,6 +5,76 @@ 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(); @@ -27,11 +97,13 @@ publicWidget.registry.SurveyFormWidget.include({ */ _submitForm: async function (options) { const fileInputs = this.el.querySelectorAll('input[data-question-type="file"]'); - const hasFiles = Array.from(fileInputs).some( - (input) => input.files && input.files.length > 0 + // 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 (!hasFiles || this.options.isStartScreen) { + if (!hasFileAction || this.options.isStartScreen) { return this._super(options); } @@ -53,6 +125,8 @@ publicWidget.registry.SurveyFormWidget.include({ 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) { @@ -66,6 +140,8 @@ publicWidget.registry.SurveyFormWidget.include({ }); }) ); + } else if (input.dataset.fileCleared) { + params[input.name] = JSON.stringify({ cleared: true }); } } @@ -108,7 +184,15 @@ publicWidget.registry.SurveyFormWidget.include({ $questionWrapper.data("constrErrorMsg") || _t("This question requires an answer."); if (questionRequired && !(this.files && this.files.length > 0)) { - errors[questionId] = constrErrorMsg; + // 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) { diff --git a/survey_extra_fields/tests/test_survey_file.py b/survey_extra_fields/tests/test_survey_file.py index 47886c1..2d5efc1 100644 --- a/survey_extra_fields/tests/test_survey_file.py +++ b/survey_extra_fields/tests/test_survey_file.py @@ -94,19 +94,38 @@ class TestSurveyFileSaveLines(TestSurveyFileCommon): self.assertEqual(lines.value_file, new_b64.encode()) self.assertEqual(lines.value_file_fname, "second.pdf") - def test_save_file_then_skip(self): - """Uploading a file then submitting empty marks line as skipped.""" + 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): diff --git a/survey_extra_fields/views/survey_templates.xml b/survey_extra_fields/views/survey_templates.xml index f2aaa09..20956f3 100644 --- a/survey_extra_fields/views/survey_templates.xml +++ b/survey_extra_fields/views/survey_templates.xml @@ -15,13 +15,26 @@