diff --git a/survey_record_generation_extra_fields/__init__.py b/survey_record_generation_extra_fields/__init__.py new file mode 100644 index 0000000..9a7e03e --- /dev/null +++ b/survey_record_generation_extra_fields/__init__.py @@ -0,0 +1 @@ +from . import models \ No newline at end of file diff --git a/survey_record_generation_extra_fields/__manifest__.py b/survey_record_generation_extra_fields/__manifest__.py new file mode 100644 index 0000000..b07e1ba --- /dev/null +++ b/survey_record_generation_extra_fields/__manifest__.py @@ -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, +} diff --git a/survey_record_generation_extra_fields/models/__init__.py b/survey_record_generation_extra_fields/models/__init__.py new file mode 100644 index 0000000..703382a --- /dev/null +++ b/survey_record_generation_extra_fields/models/__init__.py @@ -0,0 +1,2 @@ +from . import survey_record_creation_field_values +from . import survey_user_input \ No newline at end of file diff --git a/survey_record_generation_extra_fields/models/survey_record_creation_field_values.py b/survey_record_generation_extra_fields/models/survey_record_creation_field_values.py new file mode 100644 index 0000000..88694a1 --- /dev/null +++ b/survey_record_generation_extra_fields/models/survey_record_creation_field_values.py @@ -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 + )) diff --git a/survey_record_generation_extra_fields/models/survey_user_input.py b/survey_record_generation_extra_fields/models/survey_user_input.py new file mode 100644 index 0000000..ba6ba55 --- /dev/null +++ b/survey_record_generation_extra_fields/models/survey_user_input.py @@ -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] \ No newline at end of file diff --git a/survey_record_generation_extra_fields/tests/__init__.py b/survey_record_generation_extra_fields/tests/__init__.py new file mode 100644 index 0000000..a5010ab --- /dev/null +++ b/survey_record_generation_extra_fields/tests/__init__.py @@ -0,0 +1 @@ +from . import test_record_generation_extra_fields \ No newline at end of file diff --git a/survey_record_generation_extra_fields/tests/test_record_generation_extra_fields.py b/survey_record_generation_extra_fields/tests/test_record_generation_extra_fields.py new file mode 100644 index 0000000..207a34d --- /dev/null +++ b/survey_record_generation_extra_fields/tests/test_record_generation_extra_fields.py @@ -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 + ) \ No newline at end of file