From 0d4a91f1b775cd5c04c2280aab3076bf3a3586fd Mon Sep 17 00:00:00 2001 From: Quentin Mondot Date: Tue, 17 Feb 2026 12:27:22 +0100 Subject: [PATCH] [ADD] survey_extra_fields : file question type --- survey_extra_fields/__init__.py | 2 + survey_extra_fields/__manifest__.py | 27 +++ survey_extra_fields/controllers/__init__.py | 1 + survey_extra_fields/controllers/main.py | 49 ++++ survey_extra_fields/i18n/fr.po | 66 ++++++ survey_extra_fields/models/__init__.py | 3 + survey_extra_fields/models/survey_question.py | 11 + .../models/survey_user_input.py | 34 +++ .../models/survey_user_input_line.py | 17 ++ .../static/src/js/survey_form.js | 121 ++++++++++ survey_extra_fields/tests/__init__.py | 1 + survey_extra_fields/tests/test_survey_file.py | 212 ++++++++++++++++++ .../views/survey_templates.xml | 65 ++++++ .../views/survey_user_views.xml | 17 ++ 14 files changed, 626 insertions(+) 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_templates.xml create mode 100644 survey_extra_fields/views/survey_user_views.xml 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..9efe2a1 --- /dev/null +++ b/survey_extra_fields/__manifest__.py @@ -0,0 +1,27 @@ +# 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 +""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Elabore", + "website": "https://www.elabore.coop", + "category": "", + "depends": ["survey_base"], + "data": [ + "views/survey_templates.xml", + "views/survey_user_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..490bbeb --- /dev/null +++ b/survey_extra_fields/controllers/main.py @@ -0,0 +1,49 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import base64 + +from odoo import http +from odoo.http import request, content_disposition +from odoo.addons.survey.controllers.main import Survey + + +class SurveyExtraFieldsController(Survey): + + @http.route( + "/survey/file///", + type="http", + auth="public", + ) + def survey_file_download(self, survey_token, answer_token, line_id, **kwargs): + 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..bdd35b1 --- /dev/null +++ b/survey_extra_fields/i18n/fr.po @@ -0,0 +1,66 @@ +# 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-17 14:34+0000\n" +"PO-Revision-Date: 2026-02-17 14:34+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: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.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__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-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..08e7e38 --- /dev/null +++ b/survey_extra_fields/models/survey_question.py @@ -0,0 +1,11 @@ +# 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")] + ) 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..c4631ee --- /dev/null +++ b/survey_extra_fields/models/survey_user_input.py @@ -0,0 +1,34 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json + +from odoo import models + + +class SurveyUserInput(models.Model): + _inherit = "survey.user_input" + + def save_lines(self, question, answer, comment=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) + vals["value_file"] = file_data.get("data") + vals["value_file_fname"] = file_data.get("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) 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..d06a751 --- /dev/null +++ b/survey_extra_fields/static/src/js/survey_form.js @@ -0,0 +1,121 @@ +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; + } + }); + + 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..0961fd4 --- /dev/null +++ b/survey_extra_fields/tests/test_survey_file.py @@ -0,0 +1,212 @@ +# 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 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_templates.xml b/survey_extra_fields/views/survey_templates.xml new file mode 100644 index 0000000..a68fcaf --- /dev/null +++ b/survey_extra_fields/views/survey_templates.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + 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 + + + + + + + + + +