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/survey_user_input_line.py b/survey_base/models/survey_user_input_line.py index 6c49097..f19c031 100644 --- a/survey_base/models/survey_user_input_line.py +++ b/survey_base/models/survey_user_input_line.py @@ -21,7 +21,7 @@ class SurveyUserInputLine(models.Model): #record reference fields record_reference = fields.Many2oneReference(model_field="record_reference_model", string="Record") - record_reference_model = fields.Char('Record model') + record_reference_model = fields.Char('Record Model') """set record_reference when saving survey_user_input line """ 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 + + + + + + + + + +