1 Commits

Author SHA1 Message Date
860ab5da81 wip add tests on record creation
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m33s
2025-10-14 14:36:28 +02:00
37 changed files with 317 additions and 2771 deletions

View File

@@ -1,54 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * survey_base
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-17 14:44+0000\n"
"PO-Revision-Date: 2026-02-17 14:44+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__value_file
msgid "File"
msgstr "Fichier"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__value_file_fname
msgid "File Name"
msgstr "Nom du fichier"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__record_reference
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__record_reference
msgid "Record"
msgstr "Enregistrement"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__record_reference_model
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__record_reference_model
msgid "Record Model"
msgstr "Modèle de l'enregistrement"
#. module: survey_base
#: model:ir.model.fields,field_description:survey_base.field_survey_question_answer__smart_search
#: model:ir.model.fields,field_description:survey_base.field_survey_user_input_line__smart_search
msgid "Smart Search"
msgstr "Recherche intelligente"
#. module: survey_base
#: model:ir.model,name:survey_base.model_survey_question_answer
msgid "Survey Label"
msgstr "Étiquette du sondage"
#. module: survey_base
#: model:ir.model,name:survey_base.model_survey_user_input_line
msgid "Survey User Input Line"
msgstr "Ligne d'entrée pour l'utilisateur du sondage"

View File

@@ -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
"""

View File

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

View File

@@ -1,33 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Survey extra fields",
"summary": "Add extra question types to surveys",
"description": """
Add extra question types to surveys:
----------------------------------------------------
* File upload question type
- Allows survey participants to upload a file as an answer
- Configurable maximum file size (in MB) per question (default: 10 MB, 0 = no limit)
- Configurable allowed file extensions per question (e.g. .pdf,.docx — empty = all types allowed)
- Client-side validation (size and extension) before form submission
- Server-side validation on save to enforce constraints
""",
"version": "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,
}

View File

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

View File

@@ -1,61 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any
from odoo import http
from odoo.http import request, content_disposition
from odoo.addons.survey.controllers.main import Survey
if TYPE_CHECKING:
from werkzeug.wrappers import Response
class SurveyExtraFieldsController(Survey):
@http.route(
"/survey/file/<string:survey_token>/<string:answer_token>/<int:line_id>",
type="http",
auth="public",
)
def survey_file_download(
self,
survey_token: str,
answer_token: str,
line_id: int,
**kwargs: Any
) -> Response:
survey = request.env["survey.survey"].sudo().search(
[("access_token", "=", survey_token)], limit=1
)
if not survey:
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)),
],
)

View File

@@ -1,129 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * survey_extra_fields
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 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_terms:ir.ui.view,arch_db:survey_extra_fields.question_file
msgid "<i class=\"fa fa-times me-1\"/>Remove file"
msgstr "<i class=\"fa fa-times me-1\"/>Supprimer le fichier"
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__allowed_extensions
msgid "Allowed Extensions"
msgstr "Extensions autorisées"
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_user_input_line__answer_type
msgid "Answer Type"
msgstr "Type de réponse"
#. module: survey_extra_fields
#: model:ir.model.fields,help:survey_extra_fields.field_survey_question__allowed_extensions
msgid ""
"Comma-separated list of allowed extensions (e.g. .pdf,.docx). Leave empty to"
" allow all types."
msgstr ""
"Liste d'extensions autorisées séparées par une virgule (e.g. .pdf,.docx). Laisser vide pour"
" autoriser tous les types de fichier."
#. module: survey_extra_fields
#: model:ir.model.fields.selection,name:survey_extra_fields.selection__survey_question__question_type__file
#: model:ir.model.fields.selection,name:survey_extra_fields.selection__survey_user_input_line__answer_type__file
msgid "File"
msgstr "Fichier"
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__max_file_size
#: model_terms:ir.ui.view,arch_db:survey_extra_fields.survey_question_form_inh
msgid "Max File Size (MB)"
msgstr "Taille maximale du fichier"
#. module: survey_extra_fields
#: model:ir.model.fields,help:survey_extra_fields.field_survey_question__max_file_size
msgid "Maximum file size in MB. Leave 0 for no limit."
msgstr "Taille maximale du fichier en MB. Laisser à 0 pour ne pas restreindre la taille. La valeur par défaut est 10 MB."
#. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__question_type
msgid "Question Type"
msgstr "Type de question"
#. module: survey_extra_fields
#: model_terms:ir.ui.view,arch_db:survey_extra_fields.survey_page_print_inh_type_file
msgid "Skipped"
msgstr "Ignoré"
#. module: survey_extra_fields
#: model:ir.model.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."

View File

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

View File

@@ -1,20 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
class SurveyQuestion(models.Model):
_inherit = "survey.question"
question_type = fields.Selection(
selection_add=[("file", "File")]
)
max_file_size = fields.Integer(
string="Max File Size (MB)",
default=10,
help="Maximum file size in MB. Leave 0 for no limit.",
)
allowed_extensions = fields.Char(
string="Allowed Extensions",
help="Comma-separated list of allowed extensions (e.g. .pdf,.docx). Leave empty to allow all types.",
)

View File

@@ -1,84 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from __future__ import annotations
import base64
import json
import os
from typing import TYPE_CHECKING
from odoo import _, models
from odoo.exceptions import 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),
])
if not answer and any(line.value_file for line in old_answers):
# No new file was submitted: a file input cannot be pre-filled
# by the browser when navigating back to a previous page, so an
# empty answer here does not mean the user removed their file.
# Keep the previously uploaded file instead of overwriting it
# with a skipped answer.
return
vals = {
"user_input_id": self.id,
"question_id": question.id,
"skipped": False,
"answer_type": "file",
}
file_data = json.loads(answer) if answer else {}
if file_data.get("cleared"):
# The user explicitly removed the file: drop the stored data and
# mark the line as skipped.
vals.update(answer_type=None, skipped=True, value_file=False, value_file_fname=False)
elif file_data:
file_b64 = file_data.get("data", "")
file_name = file_data.get("name", "")
self._check_file_constraints(question, file_b64, file_name)
vals["value_file"] = file_b64
vals["value_file_fname"] = file_name
else:
vals.update(answer_type=None, skipped=True)
if old_answers:
old_answers.write(vals)
else:
self.env["survey.user_input.line"].create(vals)
else:
return super().save_lines(question, answer, comment=comment)
def _check_file_constraints(
self,
question: SurveyQuestion,
file_b64: str,
file_name: str
) -> None:
if question.max_file_size:
file_size = len(base64.b64decode(file_b64))
max_bytes = question.max_file_size * 1024 * 1024
if file_size > max_bytes:
raise ValidationError(
_("The file '%(name)s' exceeds the maximum allowed size of %(size)s MB.",
name=file_name, size=question.max_file_size)
)
if question.allowed_extensions:
allowed = [
allowed_extension.strip().lower()
for allowed_extension in question.allowed_extensions.split(",")
if allowed_extension.strip()
]
file_extension = os.path.splitext(file_name)[1].lower()
if file_extension not in allowed:
raise ValidationError(
_("The file '%(name)s' is not allowed. Accepted formats: %(exts)s.",
name=file_name, exts=question.allowed_extensions)
)

View File

@@ -1,17 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import fields, models
class SurveyUserInputLine(models.Model):
_inherit = "survey.user_input.line"
answer_type = fields.Selection(
selection_add=[("file", "File")]
)
def _compute_display_name(self):
super()._compute_display_name()
for line in self:
if line.answer_type == "file" and line.value_file_fname:
line.display_name = line.value_file_fname

View File

@@ -1,231 +0,0 @@
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({
/**
* @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 () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$el.on(
"change.surveyExtraFile",
'input[data-question-type="file"]',
self._onFileInputChange.bind(self)
);
self.$el.on(
"click.surveyExtraFile",
".o_survey_file_clear",
self._onFileClearClick.bind(self)
);
});
},
/**
* 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) {
var input = ev.currentTarget;
var $container = $(input).closest(".o_survey_comment_container");
if (!$container.length || !(input.files && input.files.length > 0)) {
return;
}
$container.find(".o_survey_file_name").text(input.files[0].name);
$container.find(".o_survey_file_selected").removeClass("d-none");
delete input.dataset.fileCleared;
$(input).addClass("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();
var $container = $(ev.currentTarget).closest(".o_survey_comment_container");
if (!$container.length) {
return;
}
var $input = $container.find('input[data-question-type="file"]');
if ($input.length) {
$input.val("");
// Flag the explicit removal so the submit tells the server to
// drop any previously stored file (instead of preserving it).
$input[0].dataset.fileCleared = "1";
$input.removeClass("d-none");
}
$container.find(".o_survey_file_selected").addClass("d-none");
},
_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);
});
},
/**
* @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: function (options) {
var self = this;
var $fileInputs = this.$('input[data-question-type="file"]');
// A file action is either a new selection or an explicit removal of
// a previously stored file (which must be communicated to the server).
var hasFileAction = false;
$fileInputs.each(function () {
if ((this.files && this.files.length > 0) || this.dataset.fileCleared) {
hasFileAction = true;
return false;
}
});
if (!hasFileAction || 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 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.
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,
});
})
);
} else if (this.dataset.fileCleared) {
params[this.name] = JSON.stringify({ cleared: true });
}
});
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)) {
// 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.
var $chip = $(this)
.closest(".o_survey_comment_container")
.find(".o_survey_file_selected");
var hasExistingFile = $chip.length && !$chip.hasClass("d-none");
if (!hasExistingFile) {
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;
},
});
});

View File

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

View File

@@ -1,297 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import base64
import json
from unittest.mock import patch, MagicMock
from werkzeug.exceptions import NotFound
from werkzeug.wrappers import Response
from odoo.addons.survey.tests import common
class TestSurveyFileCommon(common.TestSurveyCommon):
def setUp(self):
super().setUp()
self.file_content = b"Hello, this is a test file."
self.file_b64 = base64.b64encode(self.file_content).decode()
self.file_name = "test_document.pdf"
self.question_file = self._add_question(
self.page_0,
"Upload your document",
"file",
constr_mandatory=False,
survey_id=self.survey.id,
)
self.question_file_required = self._add_question(
self.page_0,
"Upload your required document",
"file",
constr_mandatory=True,
survey_id=self.survey.id,
)
def _create_answer_with_file(self):
answer = self._add_answer(self.survey, False, email="test@example.com")
line = self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_file.id,
"answer_type": "file",
"skipped": False,
"value_file": self.file_b64,
"value_file_fname": self.file_name,
})
return answer, line
class TestSurveyFileSaveLines(TestSurveyFileCommon):
"""Test the save_lines method for file question type."""
def test_save_file_answer(self):
"""Submitting a file stores base64 data and filename."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer.save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertEqual(line.answer_type, "file")
self.assertFalse(line.skipped)
self.assertEqual(line.value_file, self.file_b64.encode())
self.assertEqual(line.value_file_fname, self.file_name)
def test_save_file_skipped(self):
"""Submitting empty answer marks the line as skipped."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
answer.save_lines(self.question_file, "")
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertTrue(line.skipped)
self.assertFalse(line.answer_type)
def test_save_file_update_existing(self):
"""Submitting a new file updates the existing answer line."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json_1 = json.dumps({"data": self.file_b64, "name": "first.pdf"})
answer.save_lines(self.question_file, file_json_1)
new_b64 = base64.b64encode(b"Updated content").decode()
file_json_2 = json.dumps({"data": new_b64, "name": "second.pdf"})
answer.save_lines(self.question_file, file_json_2)
lines = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(lines), 1, "Should update, not create a second line")
self.assertEqual(lines.value_file, new_b64.encode())
self.assertEqual(lines.value_file_fname, "second.pdf")
def test_save_file_then_empty_keeps_file(self):
"""Submitting empty after a file keeps it (file inputs cannot be
pre-filled when navigating back, so an empty answer must not erase it)."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer.save_lines(self.question_file, file_json)
answer.save_lines(self.question_file, "")
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertFalse(line.skipped)
self.assertEqual(line.value_file, self.file_b64.encode())
self.assertEqual(line.value_file_fname, self.file_name)
def test_save_file_explicitly_cleared(self):
"""Submitting the 'cleared' sentinel after a file removes it."""
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer.save_lines(self.question_file, file_json)
answer.save_lines(self.question_file, json.dumps({"cleared": True}))
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertEqual(len(line), 1)
self.assertTrue(line.skipped)
self.assertFalse(line.value_file)
self.assertFalse(line.value_file_fname)
class TestSurveyFileConstraints(TestSurveyFileCommon):
"""Test _check_file_constraints validation logic."""
def test_no_constraints(self):
"""No max_file_size and no allowed_extensions: any file passes."""
self.question_file.write({"max_file_size": 0, "allowed_extensions": False})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": "anything.exe"})
answer.save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
def test_file_within_size_limit(self):
"""File smaller than max_file_size passes."""
self.question_file.write({"max_file_size": 10})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer.save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
def test_file_exceeds_size_limit(self):
"""File larger than max_file_size raises ValidationError."""
self.question_file.write({"max_file_size": 1})
large_content = b"x" * (1 * 1024 * 1024 + 1)
large_b64 = base64.b64encode(large_content).decode()
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": large_b64, "name": self.file_name})
with self.assertRaises(Exception):
answer.save_lines(self.question_file, file_json)
def test_allowed_extension_passes(self):
"""File with an allowed extension passes."""
self.question_file.write({"allowed_extensions": ".pdf,.docx"})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": "report.docx"})
answer.save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
def test_disallowed_extension_raises(self):
"""File with a disallowed extension raises ValidationError."""
self.question_file.write({"allowed_extensions": ".pdf,.docx"})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": "script.exe"})
with self.assertRaises(Exception):
answer.save_lines(self.question_file, file_json)
def test_both_constraints_valid(self):
"""File respecting both size and extension constraints passes."""
self.question_file.write({"max_file_size": 10, "allowed_extensions": ".pdf"})
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer.save_lines(self.question_file, file_json)
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
self.assertFalse(line.skipped)
class TestSurveyFileDisplayName(TestSurveyFileCommon):
"""Test the display_name computation for file answer lines."""
def test_display_name_filename(self):
answer = self._add_answer(self.survey, self.survey_manager.partner_id)
line = self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_file.id,
"answer_type": "file",
"skipped": False,
"value_file": self.file_b64,
"value_file_fname": "my_filename.pdf",
})
self.assertEqual(line.display_name, "my_filename.pdf")
class TestSurveyFileDownloadController(TestSurveyFileCommon):
"""Test the file download controller logic with mocked request."""
def _call_download(self, survey_token, answer_token, line_id):
"""Call the controller method with a mocked request context."""
from odoo.addons.survey_extra_fields.controllers.main import (
SurveyExtraFieldsController,
)
mock_request = MagicMock()
mock_request.env = self.env
mock_request.not_found.return_value = NotFound()
mock_request.make_response.side_effect = (
lambda content, headers=None: Response(content, headers=headers)
)
controller = SurveyExtraFieldsController()
with patch(
"odoo.addons.survey_extra_fields.controllers.main.request",
mock_request,
):
try:
return controller.survey_file_download(
survey_token, answer_token, line_id
)
except NotFound:
return NotFound()
def test_download_valid(self):
"""Valid tokens return the file content."""
answer, line = self._create_answer_with_file()
response = self._call_download(
self.survey.access_token, answer.access_token, line.id
)
self.assertIsInstance(response, Response)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, self.file_content)
def test_download_invalid_survey_token(self):
"""Invalid survey token returns not_found."""
answer, line = self._create_answer_with_file()
response = self._call_download(
"invalid-token", answer.access_token, line.id
)
self.assertIsInstance(response, NotFound)
def test_download_invalid_answer_token(self):
"""Invalid answer token returns not_found."""
answer, line = self._create_answer_with_file()
response = self._call_download(
self.survey.access_token, "invalid-token", line.id
)
self.assertIsInstance(response, NotFound)
def test_download_line_not_belonging_to_answer(self):
"""Accessing a line from another answer returns not_found."""
answer, line = self._create_answer_with_file()
other_answer = self._add_answer(self.survey, False, email="other@example.com")
response = self._call_download(
self.survey.access_token, other_answer.access_token, line.id
)
self.assertIsInstance(response, NotFound)
def test_download_no_file(self):
"""Accessing a line without file data returns not_found."""
answer = self._add_answer(self.survey, False, email="test@example.com")
line = self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_file.id,
"skipped": True,
})
response = self._call_download(
self.survey.access_token, answer.access_token, line.id
)
self.assertIsInstance(response, NotFound)

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_question_form_inh" model="ir.ui.view">
<field name="name">survey.question.form.inherit.extra_fields</field>
<field name="model">survey.question</field>
<field name="inherit_id" ref="survey.survey_question_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='options']//field[@name='matrix_subtype']" position="before">
<field name="max_file_size"
attrs="{'invisible': [('question_type', '!=', 'file')]}"
string="Max File Size (MB)"/>
<field name="allowed_extensions"
attrs="{'invisible': [('question_type', '!=', 'file')]}"
placeholder=".pdf,.docx,.xlsx"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,81 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend question_container (form view during survey filling) -->
<template
id="question_container_inh_type_file"
inherit_id="survey.question_container"
>
<xpath expr="//t[@t-call='survey.question_matrix']/.." position="after">
<t t-if="question.question_type == 'file'">
<t t-call="survey_extra_fields.question_file"/>
</t>
</xpath>
</template>
<template id="question_file" name="Question: File">
<div class="o_survey_comment_container p-0">
<t t-set="existing_fname" t-value="answer_lines and answer_lines[0].value_file_fname"/>
<t t-if="survey_form_readonly">
<p t-if="existing_fname" class="mb-1">
<i class="fa fa-paperclip me-1"/><t t-out="answer_lines[0].value_file_fname"/>
</p>
</t>
<t t-else="">
<!-- Uploaded file display, shown both for a fresh selection and when a
file was already stored server-side (e.g. navigating back). The raw
file input is hidden until the user clicks "Remove file". -->
<span t-attf-class="o_survey_file_selected d-inline-flex align-items-center #{'' if existing_fname else 'd-none'}">
<i class="fa fa-paperclip me-1"/>
<span class="o_survey_file_name"><t t-out="existing_fname or ''"/></span>
<button type="button" class="btn btn-link btn-sm text-danger o_survey_file_clear ms-2 py-0">
<i class="fa fa-times me-1"/>Remove file
</button>
</span>
<input
type="file"
t-attf-class="o_survey_question_file #{'d-none' if existing_fname else ''}"
t-att-name="question.id"
t-att-data-question-type="question.question_type"
t-att-accept="question.allowed_extensions or None"
t-att-data-max-file-size="question.max_file_size or None"
t-att-data-allowed-extensions="question.allowed_extensions or None"
/>
</t>
</div>
</template>
<!-- Extend print/review page to show file answers -->
<template
id="survey_page_print_inh_type_file"
inherit_id="survey.survey_page_print"
>
<xpath expr="//div[hasclass('o_survey_question_error')]" position="before">
<t t-if="question.question_type == 'file'">
<t t-if="answer_lines">
<t t-set="answer_line" t-value="answer_lines[0]"/>
<t t-if="answer_line.skipped">
<div class="row g-0">
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
<input type="text"
class="form-control fst-italic o_survey_question_file bg-transparent rounded-0 p-0"
value="Skipped"/>
</div>
</div>
</t>
<t t-elif="answer_line.value_file_fname">
<div class="row g-0">
<div class="col-12 col-md-6 col-lg-4">
<a t-attf-href="/survey/file/#{survey.access_token}/#{answer.access_token}/#{answer_line.id}"
target="_blank">
<i class="fa fa-download me-1"/><t t-out="answer_line.value_file_fname"/>
</a>
</div>
</div>
</t>
</t>
</t>
</xpath>
</template>
</odoo>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_user_input_line_view_form_inh" model="ir.ui.view">
<field name="name">survey.user_input.line.view.form.inherit.extra_fields</field>
<field name="model">survey.user_input.line</field>
<field name="inherit_id" ref="survey.survey_user_input_line_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='suggested_answer_id']" position="after">
<field name="value_file" filename="value_file_fname" colspan="2"
attrs="{'invisible': [('answer_type', '!=', 'file')]}"/>
<field name="value_file_fname" invisible="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -72,12 +72,7 @@ Record generation configuration
For m2o or m2m links, question should be configured before. See Question answers configuration section below.
* **other created record**: If value come from other created record (m2o case only)
#. Several options exist for the *record creation* :
#. You can check "Ignore creation if a mandatory field is missing" to prevent the form to crash if some record creations fail.
#. You can check "Update existing records" to update existing records instead of creating it. For this, you need to
precise the "Field to retrieve existing records". Only the first matched record will be updated. By default
the existing values are not replaced, except if you check the option "Update existing values".
Question answers configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -2,7 +2,7 @@
{
"name": "Survey record generation",
'summary': 'Allow to create or update record of any model when sending the form',
'summary': 'Allow to create record of any model when sending the form',
'description': """
Allow to create record of any model when sending the form :
----------------------------------------------------

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-09 07:55+0000\n"
"PO-Revision-Date: 2026-04-09 07:55+0000\n"
"POT-Creation-Date: 2025-04-15 10:34+0000\n"
"PO-Revision-Date: 2025-04-15 10:34+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -31,11 +31,6 @@ msgstr ""
msgid "Active survey input"
msgstr "Participation active"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__allowed_field_ids
msgid "Allowed Fields"
msgstr "Champs acceptés"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__allowed_question_ids
msgid "Allowed Question"
@@ -44,6 +39,8 @@ msgstr "Question autorisée"
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#, python-format
msgid "Answer to question: %s"
msgstr "Réponse à la question : %s"
@@ -58,16 +55,6 @@ msgstr "Associer une valeur à la réponse"
msgid "Attempts Count"
msgstr ""
#. module: survey_record_generation
#: model:ir.model.fields,help:survey_record_generation.field_survey_record_creation__field_to_retrieve_existing_records
msgid ""
"Choose the field you want to use to retrieve the existing record. WARNING: "
"We update only the first record found."
msgstr ""
"Choisissez le champs à partir duquel nous allons chercher l'enregistrement "
"existant. Attention : nous mettons à jour seulement le premier "
"enregistrement trouvé."
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record__create_uid
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__create_uid
@@ -115,11 +102,6 @@ msgstr ""
msgid "Field"
msgstr "Champ"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__field_to_retrieve_existing_records
msgid "Field To Retrieve Existing Records"
msgstr "Champs pour retrouver l'enregistrement existant"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__field_type
msgid "Field Type"
@@ -128,6 +110,8 @@ msgstr "Type de champ"
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#, python-format
msgid "Field type is : <b>%s</b>"
msgstr "Le type de champ est : <b>%s</b>"
@@ -187,20 +171,17 @@ msgid "ID"
msgstr ""
#. module: survey_record_generation
#: model:ir.model.fields,help:survey_record_generation.field_survey_record_creation__ignore_if_mandatory_field_is_missing
msgid ""
"If a mandatory field is missing when trying to create the record, an error "
"is raised when the survey is submitted. If this option is activated, the "
"error is ignored."
msgstr ""
"Si un champs requis est manquant lors de la création de l'enregistrement, "
"une erreur est levée lors de la soumission du formulaire. En activant cette "
"option, l'erreur sera ignorée."
#: model:ir.model,name:survey_record_generation.model_survey_question
msgid "Inherit Survey Question for extra fields"
msgstr "Question du sondage"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__ignore_if_mandatory_field_is_missing
msgid "Ignore creation if a mandatory field is missing"
msgstr "Ignorer la création si un champs requis est manquant"
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record____last_update
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation____last_update
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values____last_update
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values_x2m____last_update
msgid "Last Modified on"
msgstr "Dernière modification le"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record__write_uid
@@ -257,6 +238,8 @@ msgstr "Pas d'enregistrements générés trouvés"
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_question.py:0
#: code:addons/survey_record_generation/models/survey_question.py:0
#, python-format
msgid "No record found in %s"
msgstr "Pas d'enregistrements trouvés parmis %s"
@@ -274,19 +257,11 @@ msgstr ""
"Lors de la création d'un enregistrement, si un autre enregistrement existe "
"avec la même valeur, l'enregistrement ne sera pas créé."
#. module: survey_record_generation
#: model_terms:ir.ui.view,arch_db:survey_record_generation.survey_survey_view_form
msgid ""
"Only the first matched record will be updated.\n"
" Also to be noticed, the unicity check feature has priority over updating the existing record."
msgstr ""
"Attention, seul le premier enregistrement trouvé sera mis à jour. Aussi, si "
"vous avez des champs avec une contrainte d'unicité, cette contrainte aura la"
" priorité sur la mise à jour des enregistrements."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#, python-format
msgid "Other created record: "
msgstr "Autre enregistrement créé : "
@@ -337,6 +312,8 @@ msgstr "Modèle relatif"
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation.py:0
#: code:addons/survey_record_generation/models/survey_record_creation.py:0
#, python-format
msgid "Some required fields are not set : %s"
msgstr "Certains champs requis ne sont pas remplis : %s"
@@ -352,11 +329,6 @@ msgstr "Sondage"
msgid "Survey Label"
msgstr "Étiquette du sondage"
#. module: survey_record_generation
#: model:ir.model,name:survey_record_generation.model_survey_question
msgid "Survey Question"
msgstr "Question du sondage"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__survey_record_creation_id
msgid "Survey Record Creation"
@@ -369,7 +341,7 @@ msgstr "Sondage Création d'enregistrement Valeur des champs"
#. module: survey_record_generation
#: model:ir.model,name:survey_record_generation.model_survey_user_input
msgid "Survey User Input"
msgid "Survey User Input for custom matrix"
msgstr "Entrée utilisateur du sondage"
#. module: survey_record_generation
@@ -377,45 +349,25 @@ msgstr "Entrée utilisateur du sondage"
msgid "Survey record creation"
msgstr "Génération d'enregistrement depuis la participation"
#. module: survey_record_generation
#: model:ir.model.fields,help:survey_record_generation.field_survey_record_creation__update_existing_values
msgid ""
"The default behavior is to not update the existing fields. If checked, the "
"existing fields will be updated. "
msgstr ""
"Le comportement par défaut est de ne pas mettre à jour les valeurs existantes. Si cette option est cochée, "
"les valeurs existantes seront écrasées."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid ""
"The field %(field)s is mandatory for model %(model)s. In Record Creation "
"tab, drag %(record)s on top of the model %(model)s."
"The field %s is mandatory. In Record Creation tab, drag "
"%s at the top of the table"
msgstr ""
"Le champs %(field)s est obligatoire pour le modèle %(model)s. Dans l'onglet "
"Création d'un enregistrement, placez la ligne %(record)s au dessus de la "
"ligne du modèle %(model)s."
"Le champ %s est obligatoire. Dans l'onglet Création d'un enregistrement, glissez "
"%s sur la première ligne du tableau"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__unicity_check
msgid "Unicity constraint"
msgstr "Contrainte d'unicité"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__update_existing_values
msgid "Update existing values"
msgstr "Écraser les valeurs existantes"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__update_existing_records
msgid "Update existing records"
msgstr "Mettre à jour les enregistrements existants"
#. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_question_answer__value_char
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__displayed_value
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__fixed_value_boolean
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__fixed_value_char
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__fixed_value_date
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__fixed_value_datetime
@@ -441,52 +393,18 @@ msgstr "Message d'erreur"
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#, python-format
msgid "You should append at least one record in %s"
msgstr "Vous devez au moins ajouter un enregistrement dans %s"
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0
msgid ""
"[Survey record generation] The answer values type '%(type)s' is not "
"supported (for question %(question)s). Use 'record' or 'value' instead."
msgstr ""
"[Survey record generation] La valeur '%(type)s' pour le champs "
"'answer_values_type' n'est pas supportée (pour la question %(question)s). "
"Veuillez utiliser 'Enregistrement' ou 'Valeur' à la place."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0
msgid ""
"[Survey record generation] The boolean value %s(value)s is not supported "
"(for question %(question)s)."
msgstr ""
"[Survey record generation] La valeur booléenne %s(value)s n'est pas "
"supportée (pour la question %(question)s)."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0
msgid ""
"[Survey record generation] The question type %(type)s is not recognized (for"
" question %(question)s)."
msgstr ""
"[Survey record generation] Le type de question %(type)s n'est pas reconnu "
"(pour la question %(question)s)."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0
msgid ""
"[Survey record generation] The question type %(type)s is not supported yet."
msgstr ""
"[Survey record generation] Le type de question %(type)s n'est pas encore "
"supporté."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#: code:addons/survey_record_generation/models/survey_record_creation_field_values.py:0
#, python-format
msgid "possible values are %s"
msgstr "les valeurs possibles sont %s"
@@ -513,4 +431,4 @@ msgstr ""
#. module: survey_record_generation
#: model:ir.model,name:survey_record_generation.model_survey_record_creation_field_values_x2m
msgid "survey.record.creation.field.values.x2m"
msgstr ""
msgstr ""

View File

@@ -4,7 +4,6 @@ from odoo import models, fields, api
class SurveyGeneratedRecord(models.Model):
_name = "survey.generated.record"
_rec_name = "survey_record_creation_name"
survey_record_creation_name = fields.Char('Name', readonly=True)
survey_record_creation_id = fields.Many2one('survey.record.creation', 'Survey record creation', readonly=True)

View File

@@ -1,14 +1,12 @@
import logging
import ast
from typing import Literal
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
AnswerValuesType: Literal["no", "value", "record"]
class SurveyQuestion(models.Model):
_inherit = 'survey.question'
@@ -16,8 +14,8 @@ class SurveyQuestion(models.Model):
model_id = fields.Many2one('ir.model', string="Model")
model_name = fields.Char(related="model_id.model")
fill_domain = fields.Char("Domain", default="[]")
answer_values_type = fields.Selection([('no', 'No values'),('value','Value'),('record','Record')], string="Associate value to answer", default="no", required=True)
answer_values_type = fields.Selection([('no', 'No values'),('value','Value'),('record','Record')], string="Associate value to answer", default="no", required=True)
@api.onchange('model_id')
def onchange_model_id(self):
self.fill_domain = "[]"

View File

@@ -9,15 +9,15 @@ _logger = logging.getLogger(__name__)
class SurveyQuestionAnswer(models.Model):
_inherit = 'survey.question.answer'
record_id = fields.Reference(string="Referenced record", selection='_selection_target_model')
model_id = fields.Many2one('ir.model', related="question_id.model_id")
record_id = fields.Reference(string="Referenced record", selection='_selection_target_model')
model_id = fields.Many2one('ir.model', related="question_id.model_id")
answer_values_type = fields.Selection(related="question_id.answer_values_type")
value_char = fields.Char('Value')
@api.model
def _selection_target_model(self):
return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])]
@api.onchange('record_id')
def onchange_record_id(self):
if self.record_id:
@@ -31,7 +31,7 @@ class SurveyQuestionAnswer(models.Model):
or "record_id" not in fields
):
return result
model = self.env['ir.model'].browse(result.get("model_id")).model
res = self.env[model].search([], limit=1)
if res:
@@ -39,4 +39,4 @@ class SurveyQuestionAnswer(models.Model):
model,
res.id,
)
return result
return result

View File

@@ -1,3 +1,4 @@
import logging
from odoo import api, fields, models, _
@@ -12,60 +13,31 @@ class SurveyRecordCreation(models.Model):
name = fields.Char('Name')
survey_id = fields.Many2one('survey.survey', string="Survey")
model_id = fields.Many2one('ir.model', "Model", help="Model of generated record")
model_id = fields.Many2one('ir.model', "Model", help="Model of generated record")
field_values_ids = fields.One2many('survey.record.creation.field.values', 'survey_record_creation_id', string="Field values")
warning_message = fields.Html('Warning message', compute="_compute_warning_message")
sequence = fields.Integer("sequence")
update_existing_records = fields.Boolean(
string="Update existing records",
)
field_to_retrieve_existing_records = fields.Many2one(
"ir.model.fields",
domain="[('id', 'in', allowed_field_ids)]",
ondelete="cascade",
help="Choose the field you want to use to retrieve the existing record. "
"WARNING: We update only the first record found.",
)
update_existing_values = fields.Boolean(
string="Update existing values",
help="The default behavior is to not update the existing fields. "
"If checked, the existing fields will be updated. ",
)
allowed_field_ids = fields.Many2many(
"ir.model.fields",
compute="_compute_allowed_field_ids",
store=True,
string="Allowed Fields",
)
ignore_if_mandatory_field_is_missing = fields.Boolean(
string="Ignore creation if a mandatory field is missing",
help="If a mandatory field is missing when trying to create the record, "
"an error is raised when the survey is submitted. "
"If this option is activated, the error is ignored."
)
@api.depends("field_values_ids.field_id")
def _compute_allowed_field_ids(self):
for record in self:
record.allowed_field_ids = record.field_values_ids.mapped("field_id")
@api.onchange("model_id")
@api.onchange('model_id')
def clear_field_values_ids(self):
self.field_values_ids = None
@api.depends("model_id","field_values_ids")
@api.depends('model_id','field_values_ids')
def _compute_warning_message(self):
for record_creation in self:
# check if all mandatory fields set
if record_creation.model_id:
required_field_ids = record_creation.model_id.field_id.filtered(lambda f:f.required and "property_" not in f.name)
set_field_ids = record_creation.field_values_ids.field_id
required_field_ids = self.model_id.field_id.filtered(lambda f:f.required and "property_" not in f.name)
set_field_ids = self.field_values_ids.field_id
missing_fields = required_field_ids - set_field_ids
if missing_fields:
record_creation.warning_message = _("Some required fields are not set : %s",', '.join([f"<b>{f.field_description}</b> (<i>{f.name}</i>)" for f in missing_fields]))
else:
record_creation.warning_message = None
else:
record_creation.warning_message = None

View File

@@ -26,7 +26,6 @@ class SurveyRecordCreationFieldValues(models.Model):
"""Configure default values of records created on survey submission
"""
_name = 'survey.record.creation.field.values'
_rec_name = 'displayed_value'
survey_record_creation_id = fields.Many2one('survey.record.creation')
survey_id = fields.Many2one('survey.survey', related="survey_record_creation_id.survey_id")
@@ -34,7 +33,7 @@ class SurveyRecordCreationFieldValues(models.Model):
field_id = fields.Many2one(
'ir.model.fields',
domain="[('model_id','=',model_id),('ttype','in',['char','selection','text','html','integer','float','date','datetime','many2one','many2many', 'boolean'])]",
domain="[('model_id','=',model_id),('readonly','=',False),('ttype','in',['char','selection','text','html','integer','float','date','datetime','many2one','many2many', 'boolean'])]",
ondelete="cascade")
field_relation = fields.Char(related='field_id.relation')
field_type = fields.Selection(related="field_id.ttype")
@@ -176,7 +175,6 @@ class SurveyRecordCreationFieldValuesX2m(models.Model):
"""O2m an M2m default values
"""
_name = 'survey.record.creation.field.values.x2m'
_rec_name = 'value_reference'
survey_record_creation_field_values_id = fields.Many2one('survey.record.creation.field.values')
value_reference = fields.Reference(string='Record', selection='_selection_target_model')

View File

@@ -1,25 +1,13 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from typing import TYPE_CHECKING, Any, Literal
from odoo import _, fields, models
from odoo import models, fields, _
from odoo.exceptions import UserError
from odoo.fields import Command
if TYPE_CHECKING:
from .survey_question import AnswerValuesType
from .survey_record_creation import SurveyRecordCreation
from .survey_record_creation_field_values import SurveyRecordCreationFieldValues
class SurveyUserInput(models.Model):
_inherit = "survey.user_input"
generated_record_ids = fields.One2many(
"survey.generated.record", "user_input_id", "Generated records"
)
generated_records_count = fields.Integer(
"Attempts Count", compute="_compute_generated_records_count"
)
generated_record_ids = fields.One2many('survey.generated.record', 'user_input_id', 'Generated records')
generated_records_count = fields.Integer("Attempts Count", compute='_compute_generated_records_count')
def _compute_generated_records_count(self):
for user_input in self:
@@ -28,315 +16,112 @@ class SurveyUserInput(models.Model):
def action_redirect_to_generated_records(self):
self.ensure_one()
action = self.env["ir.actions.act_window"]._for_xml_id(
"survey_record_generation.survey_generated_record_action"
)
action['domain'] = [('user_input_id.survey_id', '=', self.survey_id.id)]
action = self.env['ir.actions.act_window']._for_xml_id('survey_record_generation.survey_generated_record_action')
""" context = dict(self.env.context or {})
context['create'] = False
context['search_default_survey_id'] = self.survey_id.id
context['search_default_group_by_survey'] = False
if self.partner_id:
context['search_default_partner_id'] = self.partner_id.id
elif self.email:
context['search_default_email'] = self.email
action['context'] = context """
return action
def _mark_done(self):
def _mark_done(self, ignore_when_res_partner_mandatory_fields_are_missing = False):
# generate records
for user_input in self:
created_records = {}
other_record_fields_to_update: list[SurveyRecordCreationFieldValues] = []
fields_to_update = []
record_creation: SurveyRecordCreation
for (
record_creation
) in user_input.survey_id.survey_record_creation_ids.sorted("sequence"):
model: str = record_creation.model_id.model
vals: dict = {}
for record_creation in user_input.survey_id.survey_record_creation_ids.sorted('sequence'):
model = record_creation.model_id.model
vals = {}
ModelClass = self.env[model]
field_value: SurveyRecordCreationFieldValues
for field_value in record_creation.field_values_ids:
value, other_record_fields_to_update = (
self.get_value_based_on_value_origin(
field_value=field_value,
user_input=user_input,
created_records=created_records,
model=model,
other_record_fields_to_update=other_record_fields_to_update,
)
)
field_name: str = field_value.field_id.name
if field_value.value_origin == 'fixed':
vals[field_value.field_id.name] = field_value.get_fixed_value_for_record_creation()
elif field_value.value_origin == 'question':
# find user_input_lines of the question
user_input_lines = [user_input_line for user_input_line in user_input.user_input_line_ids if user_input_line.question_id == field_value.question_id]
vals[field_name] = value
if not user_input_lines:
continue
existing_record = self.find_existing_record(record_creation, vals)
duplicate = self.find_duplicate_if_there_are_fields_with_unicity_check(
model, record_creation, vals
)
if field_value.question_id.question_type in ['simple_choice', 'multiple_choice','matrix']:
if field_value.question_id.answer_values_type == 'record':
record_ids = []
for user_input_line in user_input_lines:
if user_input_line.suggested_answer_id and user_input_line.suggested_answer_id.record_id:
record_ids.append(user_input_line.suggested_answer_id.record_id.id)
if field_value.question_id.question_type == 'simple_choice':
if record_ids:
vals[field_value.field_id.name] = record_ids[0]
else:
vals[field_value.field_id.name] = None
else:
vals[field_value.field_id.name] = record_ids
if field_value.question_id.answer_values_type == 'value':
if field_value.field_id.ttype == "boolean":
boolean_value = user_input_lines[0].suggested_answer_id.value_char in [True, 1, "1", "True", "true", "Oui", "oui"]
vals[field_value.field_id.name] = boolean_value
else:
vals[field_value.field_id.name] = user_input_lines[0].suggested_answer_id.value_char
elif user_input_lines[0].answer_type: # if value not filled by user, answer_type not set
vals[field_value.field_id.name] = user_input_lines[0][f"value_{user_input_lines[0].answer_type}"]
else:
vals[field_value.field_id.name] = None
elif field_value.value_origin == 'other_record':
fields_to_update.append(field_value)
# check if the field to update is mandatory
if ModelClass._fields[field_value.field_id.name].required:
# check if the other record is already created, if yes add it to vals
if len(created_records) > 0 and created_records[field_value.other_created_record_id.id]:
linked_record = created_records[field_value.other_created_record_id.id]
vals[field_value.field_id.name] = linked_record.id
else:
raise UserError(
_("The field %s is mandatory. In Record Creation tab, drag %s at the top of the table")
% (field_value.field_id.display_name, field_value.other_created_record_id.name)
)
# check duplicates
uniq_fields = [field_value.field_id.name for field_value in record_creation.field_values_ids.filtered(lambda r:r.unicity_check)]
duplicate = None
if uniq_fields:
uniq_domain = []
for uniq_field in uniq_fields:
uniq_domain.append((uniq_field,'=',vals[uniq_field]))
duplicate = self.env[model].search(uniq_domain, limit=1)
if duplicate:
record = duplicate
elif existing_record:
if record_creation.update_existing_values:
existing_record.write(vals)
else:
vals_with_keys_not_in_record = {
k: v
for k, v in vals.items()
if not getattr(existing_record, k, False)
}
existing_record.write(vals_with_keys_not_in_record)
record = existing_record
else:
try:
with self.env.cr.savepoint():
record = self.env[model].create(vals)
if model == "res.partner" and not self.partner_id:
self.partner_id = record.id
except Exception:
# This a broad exception because it could be IntegrityError,
# EmptyNamesError in case partner_firstname is installed etc...
if record_creation.ignore_if_mandatory_field_is_missing:
if model == "res.partner" and ignore_when_res_partner_mandatory_fields_are_missing:
# this part has been developed for Calim specific needs : being able to create several Contacts with the same survey
# TODO : find a way to make it generic for all models ?
if not vals.get("lastname") and not vals.get("firstname"):
continue
raise
# Create record
record = self.env[model].create(vals)
# Link generated records to user input
self.env["survey.generated.record"].create(
{
"survey_record_creation_name": record_creation.name,
"survey_record_creation_id": record_creation.id,
"user_input_id": user_input.id,
"created_record_id": f"{model},{record.id}",
}
)
self.env['survey.generated.record'].create({
'survey_record_creation_name':record_creation.name,
'survey_record_creation_id':record_creation.id,
'user_input_id':user_input.id,
"created_record_id":"%s,%s" % (model,record.id)
})
created_records[record_creation.id] = record
# update linked record
for field_to_update in other_record_fields_to_update:
record_to_update = created_records.get(
field_to_update.survey_record_creation_id.id
)
# update linked records
for field_to_update in fields_to_update:
record_to_update = created_records.get(field_to_update.survey_record_creation_id.id)
if record_to_update:
linked_record = created_records[
field_to_update.other_created_record_id.id
]
value = self.get_value_for_relational_field(
field_to_update, linked_record
)
record_to_update.write({field_to_update.field_id.name: value})
linked_record = created_records[field_to_update.other_created_record_id.id]
record_to_update.write({field_to_update.field_id.name:linked_record.id})
return super()._mark_done()
def find_existing_record(
self, record_creation: "SurveyRecordCreation", vals: dict
) -> Any:
if record_creation.update_existing_records:
model = record_creation.model_id.model
search_field = record_creation.field_to_retrieve_existing_records
user_answer_value = vals.get(search_field.name)
if user_answer_value:
return self.env[model].search(
[(search_field.name, "=", user_answer_value)], limit=1
)
return None
def get_value_based_on_value_origin(
self,
field_value: "SurveyRecordCreationFieldValues",
user_input: "SurveyUserInput",
created_records: dict[Any, Any],
model: str,
other_record_fields_to_update: list["SurveyRecordCreationFieldValues"],
) -> tuple[Any, list["SurveyRecordCreationFieldValues"]]:
value: Any = None
if field_value.value_origin == "fixed":
value = field_value.get_fixed_value_for_record_creation()
elif field_value.value_origin == "question":
value = self.get_value_from_user_answer(field_value, user_input)
elif field_value.value_origin == "other_record":
# if the other_record value is a required field, get it or raise
value = self.get_required_value_from_other_record(
model, created_records, field_value
)
# otherwise, we update the record later (out of this for loop)
if not value:
other_record_fields_to_update.append(field_value)
return value, other_record_fields_to_update
@staticmethod
def get_value_for_relational_field(
field_to_update: "SurveyRecordCreationFieldValues", linked_record
) -> Any:
field_type = field_to_update.field_id.ttype
if field_type == "many2one":
return linked_record.id
else:
# many2many or one2many
return [Command.set(linked_record.ids)]
def find_duplicate_if_there_are_fields_with_unicity_check(
self, model: str, record_creation: "SurveyRecordCreation", vals: dict[Any, Any]
) -> Any:
# check duplicates
unique_fields = [
field_value.field_id.name
for field_value in record_creation.field_values_ids.filtered(
lambda r: r.unicity_check
)
]
duplicate = None
if unique_fields:
uniq_domain = []
for uniq_field in unique_fields:
uniq_domain.append((uniq_field, "=", vals[uniq_field]))
duplicate = self.env[model].search(uniq_domain, limit=1)
return duplicate
def get_required_value_from_other_record(
self,
model: str,
created_records: dict[Any, Any],
field_value: "SurveyRecordCreationFieldValues",
) -> Any:
model_class = self.env[model]
if model_class._fields[field_value.field_id.name].required:
# check if the other record is already created,
# if yes add it to vals, else raise
if (
len(created_records) > 0
and created_records[field_value.other_created_record_id.id]
):
linked_record = created_records[field_value.other_created_record_id.id]
return self.get_value_for_relational_field(field_value, linked_record)
else:
raise UserError(
_(
"The field %(field)s is mandatory for model %(model)s. "
"In Record Creation tab, drag %(record)s "
"on top of the model %(model)s."
)
% {
"field": field_value.field_id.display_name,
"model": model,
"record": field_value.other_created_record_id.name,
}
)
def get_value_from_user_answer(
self,
field_value: "SurveyRecordCreationFieldValues",
user_input: "SurveyUserInput",
) -> Any:
# find user_input_lines (which are user's answers) for the question
user_input_lines = [
user_input_line
for user_input_line in user_input.user_input_line_ids
if user_input_line.question_id == field_value.question_id
]
if not user_input_lines:
# If the question has not been displayed to the user,
# there are no user_input_lines
return None
if user_input_lines[0].skipped:
# The question has been ignored by the user
return None
question_type = field_value.question_id.question_type
if question_type in [
"char_box",
"text_box",
"numerical_box",
"date",
"datetime",
]:
return user_input_lines[0][f"value_{user_input_lines[0].answer_type}"]
elif question_type in ["simple_choice", "multiple_choice", "matrix"]:
answer_values_type = field_value.question_id.answer_values_type
return self.get_value_based_on_answer_values_type(
answer_values_type, field_value, question_type, user_input_lines
)
else:
raise UserError(
_(
"[Survey record generation] The question type %(type)s is not "
"recognized (for question %(question)s)."
)
% {"type": question_type, "question": field_value.question_id.title}
)
def get_value_based_on_answer_values_type(
self,
answer_values_type: "AnswerValuesType",
field_value: "SurveyRecordCreationFieldValues",
question_type: Literal["simple_choice", "multiple_choice", "matrix"],
user_input_lines: list[Any],
) -> Any:
if answer_values_type == "record":
answered_record_ids = []
for user_input_line in user_input_lines:
if (
user_input_line.suggested_answer_id
and user_input_line.suggested_answer_id.record_id
):
answered_record_ids.append(
user_input_line.suggested_answer_id.record_id.id
)
if not answered_record_ids:
return None
if question_type == "simple_choice":
return answered_record_ids[0]
elif question_type == "multiple_choice":
return answered_record_ids
else:
raise UserError(
_(
"[Survey record generation] The question type"
" %(type)s is not supported yet."
)
% {"type": question_type}
)
elif answer_values_type == "value":
answer_value_char = user_input_lines[0].suggested_answer_id.value_char
if field_value.field_id.ttype != "boolean":
return answer_value_char
else:
return self.get_boolean_value(
answer_value_char=answer_value_char,
question_title=field_value.question_id.title,
)
else:
raise UserError(
_(
"[Survey record generation] The answer values type '%(type)s' "
"is not supported (for question %(question)s). Use 'record' or "
"'value' instead."
)
% {
"type": answer_values_type,
"question": field_value.question_id.title,
}
)
@staticmethod
def get_boolean_value(answer_value_char: str, question_title: str) -> bool:
# Below code is a trick to be able to use "simple_choice" question
# with values 'yes' and 'no' and transform it to boolean.
if boolean_value := answer_value_char in [
"1",
"True",
"true",
"Oui",
"oui",
"Yes",
"yes",
]:
return boolean_value
else:
raise UserError(
_(
"[Survey record generation] The boolean value %s(value)s "
"is not supported (for question %(question)s)."
)
% {
"value": answer_value_char,
"question": question_title,
}
)

View File

@@ -1,4 +1,3 @@
* `Elabore <https://www.elabore.coop>`_
* Clément Thomas
* Quentin Mondot
* Clément Thomas

File diff suppressed because it is too large Load Diff

View File

@@ -19,8 +19,8 @@
<field name="arch" type="xml">
<search>
<filter string="Active survey input" name="active_input"
domain="[('user_input_id.id', '=', context.get('active_id'))]"
/>
domain="[('user_input_id.id', '=', active_id)]"
/>
<field name="survey_record_creation_name" />
<field name="survey_record_creation_id" />
<field name="user_input_id" />

View File

@@ -16,36 +16,18 @@
</tree>
<form>
<group>
<group colspan="4">
<field name="name" />
<field name="model_id" />
<field name="ignore_if_mandatory_field_is_missing" />
<field name="allowed_field_ids" attrs="{'invisible': True}"/>
</group>
<group>
<field name="update_existing_records" />
</group>
<group attrs="{'invisible': [('update_existing_records', '=', False)]}">
<field name="field_to_retrieve_existing_records"/>
<field name="update_existing_values"/>
</group>
<div colspan="2" style="width:100%;">
<div class="alert alert-warning"
attrs="{'invisible': [('update_existing_records', '=', False)]}">
Only the first matched record will be updated.
Also to be noticed, the unicity check feature has priority over updating the existing record.
</div>
</div>
<field name="name" />
<field name="model_id" />
<field name="field_values_ids">
<tree>
<field name="field_id" />
<field name="displayed_value" />
<field name="unicity_check" />
</tree>
</tree>
<form>
<group>
<field name="model_id" invisible="1" />
<field name="field_id" />
<field name="model_id" invisible="1" />
<field name="field_id" />
<field name="unicity_check" />
<field name="field_relation" invisible="1" />
<field name="field_type" invisible="1" />
@@ -58,37 +40,37 @@
</group>
<div attrs="{'invisible':['|',('value_origin','!=','fixed'),('field_id','=',False)]}">
<group>
<field name="displayed_value" invisible="1" />
<field
name="fixed_value_char"
<field name="displayed_value" invisible="1" />
<field
name="fixed_value_char"
attrs="{'invisible':[('field_type','!=','char')]}"
/>
<field
name="fixed_value_selection"
<field
name="fixed_value_selection"
attrs="{'invisible':[('field_type','!=','selection')]}"
/>
<field
name="fixed_value_text"
<field
name="fixed_value_text"
attrs="{'invisible':[('field_type','!=','text')]}"
/>
<field
name="fixed_value_html"
<field
name="fixed_value_html"
attrs="{'invisible':[('field_type','!=', 'html')]}"
/>
<field
name="fixed_value_integer"
<field
name="fixed_value_integer"
attrs="{'invisible':[('field_type','!=', 'integer')]}"
/>
<field
name="fixed_value_float"
<field
name="fixed_value_float"
attrs="{'invisible':[('field_type','!=', 'float')]}"
/>
<field
name="fixed_value_date"
<field
name="fixed_value_date"
attrs="{'invisible':[('field_type','!=', 'date')]}"
/>
<field
name="fixed_value_datetime"
<field
name="fixed_value_datetime"
attrs="{'invisible':[('field_type','!=', 'datetime')]}"
/>
<field
@@ -105,7 +87,7 @@
attrs="{'invisible':[('field_type','not in',['one2many','many2many'])]}">
<tree editable="bottom">
<field name="survey_record_creation_field_values_id" invisible="1" />
<field name="value_reference"
<field name="value_reference"
options="{'hide_model': True, 'no_create': True, 'no_edit': True, 'no_open': True}"
/>
</tree>
@@ -122,7 +104,7 @@
<field name="other_created_record_id" />
</group>
</div>
</form>
</form>
</field>
<div colspan="2">
<field name="warning_message" />

View File

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

View File

@@ -1,20 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{
"name": "Survey record generation extra fields",
"summary": "Allow to save files in any model when submitting the survey",
"description": "",
"version": "16.0.1.0.0",
"license": "AGPL-3",
"author": "Elabore",
"website": "https://elabore.coop",
"maintainer": "Elabore",
"category": "Survey",
"depends": ["survey_record_generation", "survey_extra_fields"],
"data": [
],
"installable": True,
# Install this module automatically if all dependency have been previously
# and independently installed. Used for synergetic or glue modules.
"auto_install": True,
}

View File

@@ -1,2 +0,0 @@
from . import survey_record_creation_field_values
from . import survey_user_input

View File

@@ -1,65 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
BINARY_QUESTION_TYPES = ["file"]
class SurveyRecordCreationFieldValues(models.Model):
_inherit = "survey.record.creation.field.values"
# add file type to the domain
field_id = fields.Many2one(
domain="[('model_id','=',model_id),"
"('ttype','in',['char','selection','text','html','integer','float',"
"'date','datetime','many2one','many2many','boolean','binary'])]",
)
fixed_value_binary = fields.Binary("Value")
fixed_value_binary_fname = fields.Char("Filename")
@api.depends("survey_id")
def _compute_allowed_question_ids(self):
super()._compute_allowed_question_ids()
for record in self.filtered(lambda r: r.field_id.ttype == "binary"):
if not record.survey_id:
record.allowed_question_ids = False
continue
record.allowed_question_ids = self.env["survey.question"].search([
("survey_id", "=", record.survey_id.id),
("question_type", "in", BINARY_QUESTION_TYPES),
])
@api.onchange("fixed_value_binary", "fixed_value_binary_fname")
def _compute_displayed_value(self):
# display filename instead of file content
binary_values = self.filtered(
lambda r: r.field_id.ttype == "binary" and r.value_origin == "fixed"
)
super(
SurveyRecordCreationFieldValues, self - binary_values
)._compute_displayed_value()
for record in binary_values:
if record.fixed_value_binary_fname:
record.displayed_value = record.fixed_value_binary_fname
elif record.fixed_value_binary:
record.displayed_value = _("File")
else:
record.displayed_value = None
def clean_values(self):
res = super().clean_values()
self.fixed_value_binary = None
self.fixed_value_binary_fname = None
return res
@api.constrains("field_id", "unicity_check")
def _check_binary_unicity_check(self):
for record in self:
if record.field_id.ttype == "binary" and record.unicity_check:
raise ValidationError(_(
"The unicity constraint cannot be used on the binary field "
"%s: binary fields are stored as attachments and cannot be "
"searched.", record.field_id.display_name
))

View File

@@ -1,26 +0,0 @@
from odoo import models
FILE_QUESTION_TYPES = ["file"]
class SurveyUserInput(models.Model):
_inherit = "survey.user_input"
def get_value_from_user_answer(self, field_value, user_input):
# we check for file type before parent checks
if field_value.question_id.question_type not in FILE_QUESTION_TYPES:
return super().get_value_from_user_answer(field_value, user_input)
line = self._get_file_answer_line(field_value, user_input)
if not line:
return None
return line.with_context(bin_size=False).value_file or None
def _get_file_answer_line(self, field_value, user_input):
# return possible lines for this question, otherwise empty recordset
lines = user_input.user_input_line_ids.filtered(
lambda line: line.question_id == field_value.question_id
)
if not lines or lines[0].skipped:
return self.env["survey.user_input.line"]
return lines[0]

View File

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

View File

@@ -1,214 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import base64
from odoo.tests import tagged
from odoo.addons.survey.tests.common import SurveyCase
@tagged("post_install", "-at_install")
class TestSurveyRecordCreationFileCommon(SurveyCase):
"""Socle commun : un questionnaire avec une question "nom" et une question
"fichier", mappées vers res.partner.name et vers un champ binaire
personnalisé res.partner.x_document.
"""
# Champ binaire cible, créé dynamiquement (voir _setup_target_field).
# Le préfixe x_ est imposé par la contrainte sur state="manual".
FILE_FIELD = "x_document"
def setUp(self):
super().setUp()
# `_add_answer_line` s'appuie sur cette table pour déduire le couple
# (answer_type, champ de stockage) à partir du type de question.
# Le type "file" vient de survey_extra_fields, il n'y figure pas.
self._type_match["file"] = ("file", "value_file")
self.res_partner_model = self.env["ir.model"]._get("res.partner")
self._setup_target_field()
self._setup_survey()
self._setup_record_creation()
self.file_content = b"%PDF-1.4 contenu de test"
self.file_b64 = base64.b64encode(self.file_content)
self.file_name = "document.pdf"
# ------------------------------------------------------------------
# Setup helpers
# ------------------------------------------------------------------
def _setup_target_field(self):
"""Crée un champ binaire personnalisé sur res.partner.
Le module pont doit fonctionner avec n'importe quel champ binaire de
n'importe quel modèle : on teste donc avec un champ personnalisé
plutôt qu'avec un champ existant comme image_1920, dont le type Image
applique un redimensionnement et refuserait un PDF.
"""
self.env["ir.model.fields"].create({
"name": self.FILE_FIELD,
"model_id": self.res_partner_model.id,
"field_description": "Document justificatif",
"ttype": "binary",
"state": "manual",
})
# create() a déjà appelé setup_models() + init_models(), mais ne lève
# pas ce drapeau (seul unlink() le fait). Sans lui, le reset_changes()
# enregistré en addClassCleanup par TransactionCase ne resynchronise
# pas le registry et le champ survit au rollback.
self.registry.registry_invalidated = True
self.file_field = self.env["ir.model.fields"]._get(
"res.partner", self.FILE_FIELD
)
self.name_field = self.env["ir.model.fields"]._get("res.partner", "name")
def _setup_survey(self):
self.survey = self.env["survey.survey"].create({"title": "Test Survey"})
# res.partner.name est obligatoire : sans cette question, la création
# échoue en IntegrityError avant même d'atteindre le fichier.
self.question_name = self._add_question(
page=None,
name="Name",
qtype="char_box",
survey_id=self.survey.id,
sequence=1,
)
self.question_document = self._add_question(
page=None,
name="Document",
qtype="file",
constr_mandatory=False,
survey_id=self.survey.id,
sequence=2,
)
def _setup_record_creation(self):
self.survey_record_creation = self.env["survey.record.creation"].create({
"name": "Contact",
"survey_id": self.survey.id,
"model_id": self.res_partner_model.id,
})
self.name_field_values = self._map_question(
self.name_field, self.question_name
)
self.file_field_values = self._map_question(
self.file_field, self.question_document
)
def _map_question(self, field, question, **kwargs):
values = {
"survey_record_creation_id": self.survey_record_creation.id,
"survey_id": self.survey.id,
"model_id": self.res_partner_model.id,
"field_id": field.id,
"value_origin": "question",
"question_id": question.id,
}
values.update(kwargs)
return self.env["survey.record.creation.field.values"].create(values)
# ------------------------------------------------------------------
# Answer helpers
# ------------------------------------------------------------------
def _answer_survey(self, name="Jean", file_b64=None, file_name=None):
"""Répond au questionnaire et le valide. Retourne le user_input."""
answer = self._add_answer(
survey=self.survey, partner=False, email="jean@test.fr"
)
if name is not None:
self._add_answer_line(
question=self.question_name, answer=answer, answer_value=name
)
if file_b64 is not None:
self._add_answer_line(
question=self.question_document,
answer=answer,
answer_value=file_b64,
value_file_fname=file_name or self.file_name,
)
answer._mark_done()
return answer
def _read_binary(self, record, fname=None):
"""Relit un champ binaire depuis la base, hors cache et hors bin_size."""
fname = fname or self.FILE_FIELD
record.invalidate_recordset([fname])
return record.with_context(bin_size=False)[fname]
class TestSurveyRecordCreationFile(TestSurveyRecordCreationFileCommon):
def test_file_is_copied_to_binary_field(self):
"""Le fichier répondu se retrouve dans le champ binaire cible."""
self._answer_survey(file_b64=self.file_b64)
partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(len(partner), 1)
stored = self._read_binary(partner)
self.assertTrue(stored, "Le champ binaire devrait être renseigné")
self.assertEqual(base64.b64decode(stored), self.file_content)
def test_file_stored_as_attachment(self):
"""Un champ binaire manuel est stocké en pièce jointe, pas en colonne.
Le domaine doit mentionner res_field, sinon ir.attachment._search
injecte ('res_field', '=', False) et masque la pièce jointe.
"""
self._answer_survey(file_b64=self.file_b64)
partner = self.env["res.partner"].search([("name", "=", "Jean")])
attachment = self.env["ir.attachment"].sudo().search([
("res_model", "=", "res.partner"),
("res_id", "=", partner.id),
("res_field", "=", self.FILE_FIELD),
])
self.assertEqual(len(attachment), 1)
self.assertEqual(attachment.raw, self.file_content)
def test_no_file_answered(self):
"""Sans réponse fichier, l'enregistrement est créé, champ binaire vide."""
self._answer_survey(file_b64=None)
partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(len(partner), 1)
self.assertFalse(self._read_binary(partner))
def test_skipped_file_answer(self):
"""Une ligne de réponse explicitement passée ne remplit pas le champ."""
answer = self._add_answer(
survey=self.survey, partner=False, email="jean@test.fr"
)
self._add_answer_line(
question=self.question_name, answer=answer, answer_value="Jean"
)
self.env["survey.user_input.line"].create({
"user_input_id": answer.id,
"question_id": self.question_document.id,
"skipped": True,
})
answer._mark_done()
partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(len(partner), 1)
self.assertFalse(self._read_binary(partner))
def test_file_updates_existing_record(self):
"""En mode mise à jour, le fichier est écrit sur le partner existant."""
partner = self.env["res.partner"].create({"name": "Jean"})
self.survey_record_creation.write({
"update_existing_records": True,
"field_to_retrieve_existing_records": self.name_field.id,
})
self._answer_survey(file_b64=self.file_b64)
self.assertEqual(
self.env["res.partner"].search_count([("name", "=", "Jean")]), 1
)
self.assertEqual(
base64.b64decode(self._read_binary(partner)), self.file_content
)