From 9d1cd2746abd858cedd180982e820bf25c65bba3 Mon Sep 17 00:00:00 2001 From: Quentin Mondot Date: Wed, 10 Jun 2026 16:56:56 +0200 Subject: [PATCH] [IMP] 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'"/>