5 Commits

Author SHA1 Message Date
b0c17e0c96 [ADD] survey_record_generation_extra_field save file in record binary field
Some checks are pending
pre-commit / pre-commit (pull_request) Waiting to run
2026-08-28 17:03:27 +02:00
1cece14084 [IMP] survey_extra_fields : handle file question on page navigation
Some checks failed
pre-commit / pre-commit (pull_request) Has been cancelled
(cherry picked from commit d0afa2310d)
2026-08-12 16:49:23 +02:00
5c0962c1fd [IMP] survey_record_generation : new option update_existing_fields
(cherry picked from commit aff1a6caae)
2026-08-12 16:49:23 +02:00
843e747a5f [IMP] survey_record_generation: add default filter on survey id and active user_input for generated records
(cherry picked from commit 0d1866ace3)
2026-08-07 16:13:44 +02:00
ecbeb913d2 [IMP] survey_record_generation: added _rec_name for some models without name fields and corrected use of self in _compute_warning_message
(cherry picked from commit 4b66618686)
2026-08-07 15:50:18 +02:00
22 changed files with 628 additions and 82 deletions

View File

@@ -20,6 +20,11 @@ msgstr ""
msgid ".pdf,.docx,.xlsx" msgid ".pdf,.docx,.xlsx"
msgstr "" 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 #. module: survey_extra_fields
#: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__allowed_extensions #: model:ir.model.fields,field_description:survey_extra_fields.field_survey_question__allowed_extensions
msgid "Allowed Extensions" msgid "Allowed Extensions"

View File

@@ -23,14 +23,25 @@ class SurveyUserInput(models.Model):
("user_input_id", "=", self.id), ("user_input_id", "=", self.id),
("question_id", "=", question.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 = { vals = {
"user_input_id": self.id, "user_input_id": self.id,
"question_id": question.id, "question_id": question.id,
"skipped": False, "skipped": False,
"answer_type": "file", "answer_type": "file",
} }
if answer: file_data = json.loads(answer) if answer else {}
file_data = json.loads(answer) 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_b64 = file_data.get("data", "")
file_name = file_data.get("name", "") file_name = file_data.get("name", "")
self._check_file_constraints(question, file_b64, file_name) self._check_file_constraints(question, file_b64, file_name)

View File

@@ -6,6 +6,68 @@ odoo.define("survey_extra_fields.survey_form", function (require) {
var survey_form = require("survey.form"); var survey_form = require("survey.form");
survey_form.include({ 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) { _readFileAsDataURL: function (file) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
var reader = new FileReader(); var reader = new FileReader();
@@ -19,18 +81,28 @@ odoo.define("survey_extra_fields.survey_form", function (require) {
}); });
}, },
/**
* @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) { _submitForm: function (options) {
var self = this; var self = this;
var $fileInputs = this.$('input[data-question-type="file"]'); var $fileInputs = this.$('input[data-question-type="file"]');
var hasFiles = false; // 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 () { $fileInputs.each(function () {
if (this.files && this.files.length > 0) { if ((this.files && this.files.length > 0) || this.dataset.fileCleared) {
hasFiles = true; hasFileAction = true;
return false; return false;
} }
}); });
if (!hasFiles || this.options.isStartScreen) { if (!hasFileAction || this.options.isStartScreen) {
return this._super(options); return this._super(options);
} }
@@ -51,7 +123,9 @@ odoo.define("survey_extra_fields.survey_form", function (require) {
this._prepareSubmitValues(formData, params); this._prepareSubmitValues(formData, params);
// Read all selected files as base64 // 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 = []; var filePromises = [];
$fileInputs.each(function () { $fileInputs.each(function () {
if (this.files && this.files.length > 0) { if (this.files && this.files.length > 0) {
@@ -65,6 +139,8 @@ odoo.define("survey_extra_fields.survey_form", function (require) {
}); });
}) })
); );
} else if (this.dataset.fileCleared) {
params[this.name] = JSON.stringify({ cleared: true });
} }
}); });
@@ -107,7 +183,16 @@ odoo.define("survey_extra_fields.survey_form", function (require) {
$questionWrapper.data("constrErrorMsg") || $questionWrapper.data("constrErrorMsg") ||
_t("This question requires an answer."); _t("This question requires an answer.");
if (questionRequired && !(this.files && this.files.length > 0)) { if (questionRequired && !(this.files && this.files.length > 0)) {
errors[questionId] = constrErrorMsg; // A file may already be stored server-side (e.g. uploaded
// then navigating back): the chip is visible even though the
// input is empty. Treat that as a valid answer.
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; return;
} }
if (this.files && this.files.length > 0) { if (this.files && this.files.length > 0) {
@@ -143,4 +228,4 @@ odoo.define("survey_extra_fields.survey_form", function (require) {
return result; return result;
}, },
}); });
}); });

View File

@@ -94,19 +94,38 @@ class TestSurveyFileSaveLines(TestSurveyFileCommon):
self.assertEqual(lines.value_file, new_b64.encode()) self.assertEqual(lines.value_file, new_b64.encode())
self.assertEqual(lines.value_file_fname, "second.pdf") self.assertEqual(lines.value_file_fname, "second.pdf")
def test_save_file_then_skip(self): def test_save_file_then_empty_keeps_file(self):
"""Uploading a file then submitting empty marks line as skipped.""" """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) answer = self._add_answer(self.survey, self.survey_manager.partner_id)
file_json = json.dumps({"data": self.file_b64, "name": self.file_name}) file_json = json.dumps({"data": self.file_b64, "name": self.file_name})
answer.save_lines(self.question_file, file_json) answer.save_lines(self.question_file, file_json)
answer.save_lines(self.question_file, "") answer.save_lines(self.question_file, "")
line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file
)
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( line = answer.user_input_line_ids.filtered(
lambda l: l.question_id == self.question_file lambda l: l.question_id == self.question_file
) )
self.assertEqual(len(line), 1) self.assertEqual(len(line), 1)
self.assertTrue(line.skipped) self.assertTrue(line.skipped)
self.assertFalse(line.value_file)
self.assertFalse(line.value_file_fname)
class TestSurveyFileConstraints(TestSurveyFileCommon): class TestSurveyFileConstraints(TestSurveyFileCommon):

View File

@@ -15,13 +15,26 @@
<template id="question_file" name="Question: File"> <template id="question_file" name="Question: File">
<div class="o_survey_comment_container p-0"> <div class="o_survey_comment_container p-0">
<t t-if="survey_form_readonly and answer_lines and answer_lines[0].value_file_fname"> <t t-set="existing_fname" t-value="answer_lines and answer_lines[0].value_file_fname"/>
<p><t t-out="answer_lines[0].value_file_fname"/></p> <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 t-if="not survey_form_readonly"> <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 <input
type="file" type="file"
class="o_survey_question_file" t-attf-class="o_survey_question_file #{'d-none' if existing_fname else ''}"
t-att-name="question.id" t-att-name="question.id"
t-att-data-question-type="question.question_type" t-att-data-question-type="question.question_type"
t-att-accept="question.allowed_extensions or None" t-att-accept="question.allowed_extensions or None"

View File

@@ -72,7 +72,12 @@ Record generation configuration
For m2o or m2m links, question should be configured before. See Question answers configuration section below. 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) * **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 Question answers configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

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

View File

@@ -6,8 +6,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Odoo Server 16.0\n" "Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-13 16:41+0000\n" "POT-Creation-Date: 2026-04-09 07:55+0000\n"
"PO-Revision-Date: 2025-11-13 16:41+0000\n" "PO-Revision-Date: 2026-04-09 07:55+0000\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@@ -44,8 +44,6 @@ msgstr "Question autorisée"
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. 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
#, python-format
msgid "Answer to question: %s" msgid "Answer to question: %s"
msgstr "Réponse à la question : %s" msgstr "Réponse à la question : %s"
@@ -130,8 +128,6 @@ msgstr "Type de champ"
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. 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
#, python-format
msgid "Field type is : <b>%s</b>" msgid "Field type is : <b>%s</b>"
msgstr "Le type de champ est : <b>%s</b>" msgstr "Le type de champ est : <b>%s</b>"
@@ -198,22 +194,14 @@ msgid ""
"error is ignored." "error is ignored."
msgstr "" msgstr ""
"Si un champs requis est manquant lors de la création de l'enregistrement, " "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. " "une erreur est levée lors de la soumission du formulaire. En activant cette "
"En activant cette option, l'erreur sera ignorée." "option, l'erreur sera ignorée."
#. module: survey_record_generation #. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__ignore_if_mandatory_field_is_missing #: 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" msgid "Ignore creation if a mandatory field is missing"
msgstr "Ignorer la création si un champs requis est manquant" msgstr "Ignorer la création si un champs requis est manquant"
#. module: survey_record_generation
#: 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 #. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record__write_uid #: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record__write_uid
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__write_uid #: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__write_uid
@@ -269,8 +257,6 @@ msgstr "Pas d'enregistrements générés trouvés"
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_question.py:0 #: 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" msgid "No record found in %s"
msgstr "Pas d'enregistrements trouvés parmis %s" msgstr "Pas d'enregistrements trouvés parmis %s"
@@ -292,7 +278,7 @@ msgstr ""
#: model_terms:ir.ui.view,arch_db:survey_record_generation.survey_survey_view_form #: model_terms:ir.ui.view,arch_db:survey_record_generation.survey_survey_view_form
msgid "" msgid ""
"Only the first matched record will be updated.\n" "Only the first matched record will be updated.\n"
" Also to be noticed, the unicity check feature has priority over updating the existing record." " Also to be noticed, the unicity check feature has priority over updating the existing record."
msgstr "" msgstr ""
"Attention, seul le premier enregistrement trouvé sera mis à jour. Aussi, si " "Attention, seul le premier enregistrement trouvé sera mis à jour. Aussi, si "
"vous avez des champs avec une contrainte d'unicité, cette contrainte aura la" "vous avez des champs avec une contrainte d'unicité, cette contrainte aura la"
@@ -301,8 +287,6 @@ msgstr ""
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. 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
#, python-format
msgid "Other created record: " msgid "Other created record: "
msgstr "Autre enregistrement créé : " msgstr "Autre enregistrement créé : "
@@ -353,8 +337,6 @@ msgstr "Modèle relatif"
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_record_creation.py:0 #: 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" msgid "Some required fields are not set : %s"
msgstr "Certains champs requis ne sont pas remplis : %s" msgstr "Certains champs requis ne sont pas remplis : %s"
@@ -388,17 +370,25 @@ msgstr "Sondage Création d'enregistrement Valeur des champs"
#. module: survey_record_generation #. module: survey_record_generation
#: model:ir.model,name:survey_record_generation.model_survey_user_input #: model:ir.model,name:survey_record_generation.model_survey_user_input
msgid "Survey User Input" msgid "Survey User Input"
msgstr "Saisie utilisateur du sondage" msgstr "Entrée utilisateur du sondage"
#. module: survey_record_generation #. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record__survey_record_creation_id #: model:ir.model.fields,field_description:survey_record_generation.field_survey_generated_record__survey_record_creation_id
msgid "Survey record creation" msgid "Survey record creation"
msgstr "Génération d'enregistrement depuis la participation" 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 #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0 #: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid "" msgid ""
"The field %(field)s is mandatory for model %(model)s. In Record Creation " "The field %(field)s is mandatory for model %(model)s. In Record Creation "
"tab, drag %(record)s on top of the model %(model)s." "tab, drag %(record)s on top of the model %(model)s."
@@ -407,20 +397,16 @@ msgstr ""
"Création d'un enregistrement, placez la ligne %(record)s au dessus de la " "Création d'un enregistrement, placez la ligne %(record)s au dessus de la "
"ligne du modèle %(model)s." "ligne du modèle %(model)s."
#. module: survey_record_generation
#. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid ""
"The field %s is mandatory. In Record Creation tab, drag %s at the top of the"
" table"
msgstr ""
#. module: survey_record_generation #. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__unicity_check #: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation_field_values__unicity_check
msgid "Unicity constraint" msgid "Unicity constraint"
msgstr "Contrainte d'unicité" 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 #. module: survey_record_generation
#: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__update_existing_records #: model:ir.model.fields,field_description:survey_record_generation.field_survey_record_creation__update_existing_records
msgid "Update existing records" msgid "Update existing records"
@@ -455,17 +441,12 @@ msgstr "Message d'erreur"
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. 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
#: 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" msgid "You should append at least one record in %s"
msgstr "Vous devez au moins ajouter un enregistrement dans %s" msgstr "Vous devez au moins ajouter un enregistrement dans %s"
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0 #: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid "" msgid ""
"[Survey record generation] The answer values type '%(type)s' is not " "[Survey record generation] The answer values type '%(type)s' is not "
"supported (for question %(question)s). Use 'record' or 'value' instead." "supported (for question %(question)s). Use 'record' or 'value' instead."
@@ -477,7 +458,6 @@ msgstr ""
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0 #: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid "" msgid ""
"[Survey record generation] The boolean value %s(value)s is not supported " "[Survey record generation] The boolean value %s(value)s is not supported "
"(for question %(question)s)." "(for question %(question)s)."
@@ -488,7 +468,6 @@ msgstr ""
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0 #: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid "" msgid ""
"[Survey record generation] The question type %(type)s is not recognized (for" "[Survey record generation] The question type %(type)s is not recognized (for"
" question %(question)s)." " question %(question)s)."
@@ -499,7 +478,6 @@ msgstr ""
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. odoo-python
#: code:addons/survey_record_generation/models/survey_user_input.py:0 #: code:addons/survey_record_generation/models/survey_user_input.py:0
#, python-format
msgid "" msgid ""
"[Survey record generation] The question type %(type)s is not supported yet." "[Survey record generation] The question type %(type)s is not supported yet."
msgstr "" msgstr ""
@@ -509,8 +487,6 @@ msgstr ""
#. module: survey_record_generation #. module: survey_record_generation
#. odoo-python #. 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
#, python-format
msgid "possible values are %s" msgid "possible values are %s"
msgstr "les valeurs possibles sont %s" msgstr "les valeurs possibles sont %s"

View File

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

View File

@@ -27,6 +27,11 @@ class SurveyRecordCreation(models.Model):
help="Choose the field you want to use to retrieve the existing record. " help="Choose the field you want to use to retrieve the existing record. "
"WARNING: We update only the first record found.", "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( allowed_field_ids = fields.Many2many(
"ir.model.fields", "ir.model.fields",
compute="_compute_allowed_field_ids", compute="_compute_allowed_field_ids",
@@ -54,8 +59,8 @@ class SurveyRecordCreation(models.Model):
for record_creation in self: for record_creation in self:
# check if all mandatory fields set # check if all mandatory fields set
if record_creation.model_id: if record_creation.model_id:
required_field_ids = self.model_id.field_id.filtered(lambda f:f.required and "property_" not in f.name) required_field_ids = record_creation.model_id.field_id.filtered(lambda f:f.required and "property_" not in f.name)
set_field_ids = self.field_values_ids.field_id set_field_ids = record_creation.field_values_ids.field_id
missing_fields = required_field_ids - set_field_ids missing_fields = required_field_ids - set_field_ids
if missing_fields: if missing_fields:

View File

@@ -26,6 +26,7 @@ class SurveyRecordCreationFieldValues(models.Model):
"""Configure default values of records created on survey submission """Configure default values of records created on survey submission
""" """
_name = 'survey.record.creation.field.values' _name = 'survey.record.creation.field.values'
_rec_name = 'displayed_value'
survey_record_creation_id = fields.Many2one('survey.record.creation') survey_record_creation_id = fields.Many2one('survey.record.creation')
survey_id = fields.Many2one('survey.survey', related="survey_record_creation_id.survey_id") survey_id = fields.Many2one('survey.survey', related="survey_record_creation_id.survey_id")
@@ -175,6 +176,7 @@ class SurveyRecordCreationFieldValuesX2m(models.Model):
"""O2m an M2m default values """O2m an M2m default values
""" """
_name = 'survey.record.creation.field.values.x2m' _name = 'survey.record.creation.field.values.x2m'
_rec_name = 'value_reference'
survey_record_creation_field_values_id = fields.Many2one('survey.record.creation.field.values') survey_record_creation_field_values_id = fields.Many2one('survey.record.creation.field.values')
value_reference = fields.Reference(string='Record', selection='_selection_target_model') value_reference = fields.Reference(string='Record', selection='_selection_target_model')

View File

@@ -31,7 +31,7 @@ class SurveyUserInput(models.Model):
action = self.env["ir.actions.act_window"]._for_xml_id( action = self.env["ir.actions.act_window"]._for_xml_id(
"survey_record_generation.survey_generated_record_action" "survey_record_generation.survey_generated_record_action"
) )
action['domain'] = [('user_input_id.survey_id', '=', self.survey_id.id)]
return action return action
def _mark_done(self): def _mark_done(self):
@@ -71,12 +71,15 @@ class SurveyUserInput(models.Model):
if duplicate: if duplicate:
record = duplicate record = duplicate
elif existing_record: elif existing_record:
vals_with_keys_not_in_record = { if record_creation.update_existing_values:
k: v existing_record.write(vals)
for k, v in vals.items() else:
if not getattr(existing_record, k, False) vals_with_keys_not_in_record = {
} k: v
existing_record.write(vals_with_keys_not_in_record) 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 record = existing_record
else: else:
try: try:

View File

@@ -3,6 +3,7 @@ from datetime import date
from psycopg2 import IntegrityError from psycopg2 import IntegrityError
from odoo.addons.survey.tests.common import SurveyCase from odoo.addons.survey.tests.common import SurveyCase
from odoo.tools import mute_logger
class TestSurveyRecordCreation(SurveyCase): class TestSurveyRecordCreation(SurveyCase):
@@ -745,9 +746,91 @@ class TestSurveyRecordCreation(SurveyCase):
self.answer._mark_done() self.answer._mark_done()
partner = self.env["res.partner"].search([("name", "=", "Jean")]) partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertTrue(len(partner) == 1) self.assertEqual(len(partner), 1)
self.assertTrue(partner.email == "jean@test.fr") self.assertEqual(partner.email, "jean@test.fr")
self.assertTrue(partner.function == "happiness office manager") self.assertEqual(partner.function, "happiness office manager")
def test_update_all_fields_when_updating_records(self):
# A contact with name 'Jean' and email 'jean@test.fr' already exists.
# We'll update the fields 'function' AND 'email' of this partner
# because the option 'update_existing_values' is True
self.env["res.partner"].create(
{
"name": "Jean",
"email": "jean@test.fr",
}
)
self.question_email = self._add_question(
page=None,
name="Email",
qtype="char_box",
survey_id=self.survey.id,
sequence=1,
)
self.question_function = self._add_question(
page=None,
name="Function",
qtype="char_box",
survey_id=self.survey.id,
sequence=1,
)
self.survey_record_creation.write(
{
"update_existing_records": True,
"field_to_retrieve_existing_records": self.name_field.id,
"update_existing_values": True,
}
)
email_field = self.env["ir.model.fields"].search(
[("model", "=", "res.partner"), ("name", "=", "email")]
)
self.env["survey.record.creation.field.values"].create(
{
"survey_record_creation_id": self.survey_record_creation.id,
"survey_id": self.survey.id,
"model_id": self.res_partner_model.id,
"field_id": email_field.id,
"value_origin": "question",
"question_id": self.question_email.id,
}
)
function_field = self.env["ir.model.fields"].search(
[("model", "=", "res.partner"), ("name", "=", "function")]
)
self.env["survey.record.creation.field.values"].create(
{
"survey_record_creation_id": self.survey_record_creation.id,
"survey_id": self.survey.id,
"model_id": self.res_partner_model.id,
"field_id": function_field.id,
"value_origin": "question",
"question_id": self.question_function.id,
}
)
self.answer = self._add_answer(
survey=self.survey, partner=False, email="jean@test.fr"
)
self._add_answer_line(
question=self.question_name, answer=self.answer, answer_value="Jean"
)
self._add_answer_line(
question=self.question_email,
answer=self.answer,
answer_value="IAmTheNewEmailReplacingTheOldOne@test.fr",
)
self._add_answer_line(
question=self.question_function,
answer=self.answer,
answer_value="happiness office manager",
)
self.answer._mark_done()
partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(len(partner), 1)
self.assertEqual(partner.email, "IAmTheNewEmailReplacingTheOldOne@test.fr")
self.assertEqual(partner.function, "happiness office manager")
def test_unicity_check_has_priority_over_update(self): def test_unicity_check_has_priority_over_update(self):
# In this test, we verify that if a field is set up with unicity_check # In this test, we verify that if a field is set up with unicity_check
@@ -810,7 +893,8 @@ class TestSurveyRecordCreation(SurveyCase):
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
# TODO : propose a better user experience than IntegrityError when # TODO : propose a better user experience than IntegrityError when
# a mandatory field is missing # a mandatory field is missing
self.answer._mark_done() with mute_logger("odoo.sql_db"):
self.answer._mark_done()
def test_ignore_if_mandatory_field_is_missing(self): def test_ignore_if_mandatory_field_is_missing(self):
# In this test, we check the behavior of ignore_if_mandatory_field_is_missing # In this test, we check the behavior of ignore_if_mandatory_field_is_missing
@@ -823,7 +907,8 @@ class TestSurveyRecordCreation(SurveyCase):
survey=self.survey, partner=False, email="jean@test.fr" survey=self.survey, partner=False, email="jean@test.fr"
) )
self.answer._mark_done() with mute_logger("odoo.sql_db"):
self.answer._mark_done()
# No partner has been created, and no IntegrityError has been raised # No partner has been created, and no IntegrityError has been raised
partner = self.env["res.partner"].search([("name", "=", "Jean")]) partner = self.env["res.partner"].search([("name", "=", "Jean")])

View File

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

View File

@@ -16,12 +16,19 @@
</tree> </tree>
<form> <form>
<group> <group>
<field name="name" /> <group colspan="4">
<field name="model_id" /> <field name="name" />
<field name="ignore_if_mandatory_field_is_missing" /> <field name="model_id" />
<field name="update_existing_records" /> <field name="ignore_if_mandatory_field_is_missing" />
<field name="allowed_field_ids" attrs="{'invisible': True}"/> <field name="allowed_field_ids" attrs="{'invisible': True}"/>
<field name="field_to_retrieve_existing_records" attrs="{'invisible': [('update_existing_records', '=', False)]}"/> </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 colspan="2" style="width:100%;">
<div class="alert alert-warning" <div class="alert alert-warning"
attrs="{'invisible': [('update_existing_records', '=', False)]}"> attrs="{'invisible': [('update_existing_records', '=', False)]}">

View File

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

View File

@@ -0,0 +1,20 @@
# 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

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

View File

@@ -0,0 +1,65 @@
# 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

@@ -0,0 +1,26 @@
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

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

View File

@@ -0,0 +1,214 @@
# 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
)