diff --git a/survey_xlsx_expand_multiple_choice/__init__.py b/survey_xlsx_expand_multiple_choice/__init__.py new file mode 100644 index 0000000..1806b02 --- /dev/null +++ b/survey_xlsx_expand_multiple_choice/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import report diff --git a/survey_xlsx_expand_multiple_choice/__manifest__.py b/survey_xlsx_expand_multiple_choice/__manifest__.py new file mode 100644 index 0000000..7857d81 --- /dev/null +++ b/survey_xlsx_expand_multiple_choice/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Survey XLSX - Expand Multiple Choice", + "summary": """ + Expands multiple_choice questions into one Oui/Non column per option, and + matrix questions into one column per row (value = selected option)""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "installable": True, + "application": False, + "author": "Elabore", + "website": "https://elabore.coop", + "depends": ["survey_xlsx"], +} diff --git a/survey_xlsx_expand_multiple_choice/report/__init__.py b/survey_xlsx_expand_multiple_choice/report/__init__.py new file mode 100644 index 0000000..fa0a90c --- /dev/null +++ b/survey_xlsx_expand_multiple_choice/report/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import report_survey_xlsx diff --git a/survey_xlsx_expand_multiple_choice/report/report_survey_xlsx.py b/survey_xlsx_expand_multiple_choice/report/report_survey_xlsx.py new file mode 100644 index 0000000..65b534d --- /dev/null +++ b/survey_xlsx_expand_multiple_choice/report/report_survey_xlsx.py @@ -0,0 +1,70 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models + + +class ReportSurveyXlsx(models.AbstractModel): + _inherit = "report.survey.xlsx" + + def _write_question_header(self, sheet, question, cols, bold): + if question.question_type == "multiple_choice": + for answer in question.suggested_answer_ids: + col_key = f"question_{question.id}_answer_{answer.id}" + sheet.write( + 0, + cols[col_key], + f"{question.title} / {answer.value}", + bold, + ) + return + if question.question_type == "matrix": + for row in question.matrix_row_ids: + col_key = f"question_{question.id}_row_{row.id}" + sheet.write( + 0, + cols[col_key], + f"{question.title} / {row.value}", + bold, + ) + return + return super()._write_question_header(sheet, question, cols, bold) + + def _process_user_answer(self, data, user_input_id, user_answer, cols): + question = user_answer.question_id + if question.question_type == "multiple_choice": + if user_answer.skipped: + return + col_key = f"question_{question.id}_answer_{user_answer.suggested_answer_id.id}" + if col_key not in cols: + return + data[user_input_id][cols[col_key]] = ["Oui"] + return + if question.question_type == "matrix": + if user_answer.skipped: + return + col_key = f"question_{question.id}_row_{user_answer.matrix_row_id.id}" + if col_key not in cols: + return + data[user_input_id][cols[col_key]].append( + user_answer.suggested_answer_id.value + ) + return + return super()._process_user_answer(data, user_input_id, user_answer, cols) + + def _post_process_user_input(self, data, user_input, cols): + super()._post_process_user_input(data, user_input, cols) + answered_mc = set() + for line in user_input.user_input_line_ids: + if line.question_id.question_type == "multiple_choice" and not line.skipped: + answered_mc.add(line.question_id.id) + for question in user_input.survey_id.question_ids: + if question.question_type != "multiple_choice": + continue + if question.id not in answered_mc: + continue + for answer in question.suggested_answer_ids: + col_key = f"question_{question.id}_answer_{answer.id}" + if col_key in cols: + col_idx = cols[col_key] + if col_idx not in data[user_input.id]: + data[user_input.id][col_idx] = ["Non"] diff --git a/survey_xlsx_expand_multiple_choice/tests/__init__.py b/survey_xlsx_expand_multiple_choice/tests/__init__.py new file mode 100644 index 0000000..5c5c97a --- /dev/null +++ b/survey_xlsx_expand_multiple_choice/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from . import test_report diff --git a/survey_xlsx_expand_multiple_choice/tests/test_report.py b/survey_xlsx_expand_multiple_choice/tests/test_report.py new file mode 100644 index 0000000..512a874 --- /dev/null +++ b/survey_xlsx_expand_multiple_choice/tests/test_report.py @@ -0,0 +1,148 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import io + +import openpyxl + +from odoo.addons.survey.tests import common + + +class TestExpandMultipleChoice(common.TestSurveyCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.mc_question = cls._add_question( + cls, + page=cls.page_0, + name="Favorite colors", + qtype="multiple_choice", + labels=[ + {"value": "Red"}, + {"value": "Green"}, + {"value": "Blue"}, + ], + survey_id=cls.survey.id, + sequence=10, + ) + cls.answer_red = cls.mc_question.suggested_answer_ids[0] + cls.answer_green = cls.mc_question.suggested_answer_ids[1] + cls.answer_blue = cls.mc_question.suggested_answer_ids[2] + + # Input 1: selects Red and Green + input1 = cls._add_answer(cls, cls.survey, cls.survey_manager.partner_id) + cls._add_answer_line(cls, cls.mc_question, input1, cls.answer_red.id) + cls._add_answer_line(cls, cls.mc_question, input1, cls.answer_green.id) + input1._mark_done() + + # Input 2: selects Blue only + input2 = cls._add_answer(cls, cls.survey, False, email="test2@example.com") + cls._add_answer_line(cls, cls.mc_question, input2, cls.answer_blue.id) + input2._mark_done() + + # Matrix question (one choice per row): satisfaction grid + cls.matrix_question = cls._add_question( + cls, + page=cls.page_0, + name="Satisfaction", + qtype="matrix", + matrix_subtype="simple", + labels=[ + {"value": "Pas du tout satisfait"}, + {"value": "Satisfait"}, + ], + labels_2=[ + {"value": "Tableaux de bord"}, + {"value": "Relation client"}, + ], + survey_id=cls.survey.id, + sequence=20, + ) + cls.col_unhappy = cls.matrix_question.suggested_answer_ids[0] + cls.col_happy = cls.matrix_question.suggested_answer_ids[1] + cls.row_dashboard = cls.matrix_question.matrix_row_ids[0] + cls.row_client = cls.matrix_question.matrix_row_ids[1] + + # input1 answers the matrix: dashboard -> unhappy, client -> happy + cls._add_answer_line( + cls, + cls.matrix_question, + input1, + cls.col_unhappy.id, + answer_value_row=cls.row_dashboard.id, + ) + cls._add_answer_line( + cls, + cls.matrix_question, + input1, + cls.col_happy.id, + answer_value_row=cls.row_client.id, + ) + + def _get_sheet(self): + report = self.env.ref("survey_xlsx.report_survey_xlsx") + rep = self.env["ir.actions.report"]._render(report, self.survey.ids, {}) + wb = openpyxl.load_workbook(io.BytesIO(rep[0])) + return wb.worksheets[0] + + def _find_col(self, sheet, header): + for col in range(1, sheet.max_column + 1): + if sheet.cell(1, col).value == header: + return col + return None + + def test_headers(self): + sheet = self._get_sheet() + self.assertIsNotNone( + self._find_col(sheet, "Favorite colors / Red") + ) + self.assertIsNotNone( + self._find_col(sheet, "Favorite colors / Green") + ) + self.assertIsNotNone( + self._find_col(sheet, "Favorite colors / Blue") + ) + + def test_values(self): + sheet = self._get_sheet() + col_red = self._find_col(sheet, "Favorite colors / Red") + col_green = self._find_col(sheet, "Favorite colors / Green") + col_blue = self._find_col(sheet, "Favorite colors / Blue") + + # Collect rows by (red, green, blue) values + rows = set() + for row in range(2, sheet.max_row + 1): + rows.add(( + sheet.cell(row, col_red).value, + sheet.cell(row, col_green).value, + sheet.cell(row, col_blue).value, + )) + + self.assertIn(("Oui", "Oui", "Non"), rows) + self.assertIn(("Non", "Non", "Oui"), rows) + + def test_matrix_headers(self): + sheet = self._get_sheet() + self.assertIsNotNone( + self._find_col(sheet, "Satisfaction / Tableaux de bord") + ) + self.assertIsNotNone( + self._find_col(sheet, "Satisfaction / Relation client") + ) + # No per-column expansion for matrices + self.assertIsNone( + self._find_col(sheet, "Satisfaction / Pas du tout satisfait") + ) + + def test_matrix_values(self): + sheet = self._get_sheet() + col_dashboard = self._find_col(sheet, "Satisfaction / Tableaux de bord") + col_client = self._find_col(sheet, "Satisfaction / Relation client") + + values = set() + for row in range(2, sheet.max_row + 1): + values.add(( + sheet.cell(row, col_dashboard).value, + sheet.cell(row, col_client).value, + )) + + self.assertIn(("Pas du tout satisfait", "Satisfait"), values) diff --git a/survey_xlsx_extra_fields/__init__.py b/survey_xlsx_extra_fields/__init__.py new file mode 100644 index 0000000..4c4f242 --- /dev/null +++ b/survey_xlsx_extra_fields/__init__.py @@ -0,0 +1 @@ +from . import report diff --git a/survey_xlsx_extra_fields/__manifest__.py b/survey_xlsx_extra_fields/__manifest__.py new file mode 100644 index 0000000..9c7bcea --- /dev/null +++ b/survey_xlsx_extra_fields/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Survey XLSX - Extra Fields Bridge", + "summary": """ + Excludes 'file' question types from the survey XLSX export""", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "installable": True, + "application": False, + "auto_install": True, + "author": "Elabore", + "website": "https://elabore.coop", + "depends": [ + "survey_xlsx_expand_multiple_choice", + "survey_extra_fields", + ], +} diff --git a/survey_xlsx_extra_fields/report/__init__.py b/survey_xlsx_extra_fields/report/__init__.py new file mode 100644 index 0000000..2a41f6f --- /dev/null +++ b/survey_xlsx_extra_fields/report/__init__.py @@ -0,0 +1 @@ +from . import report_survey_xlsx diff --git a/survey_xlsx_extra_fields/report/report_survey_xlsx.py b/survey_xlsx_extra_fields/report/report_survey_xlsx.py new file mode 100644 index 0000000..5c99bae --- /dev/null +++ b/survey_xlsx_extra_fields/report/report_survey_xlsx.py @@ -0,0 +1,15 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import models + + +class ReportSurveyXlsx(models.AbstractModel): + _inherit = "report.survey.xlsx" + + def _write_question_header(self, sheet, question, cols, bold): + # "file" questions store uploaded attachments that cannot be rendered + # in a spreadsheet cell: skip them so no column is created. Without a + # column, _process_user_answer ignores their answers automatically. + if question.question_type == "file": + return + return super()._write_question_header(sheet, question, cols, bold) diff --git a/survey_xlsx_extra_fields/tests/__init__.py b/survey_xlsx_extra_fields/tests/__init__.py new file mode 100644 index 0000000..32ae3c2 --- /dev/null +++ b/survey_xlsx_extra_fields/tests/__init__.py @@ -0,0 +1 @@ +from . import test_report diff --git a/survey_xlsx_extra_fields/tests/test_report.py b/survey_xlsx_extra_fields/tests/test_report.py new file mode 100644 index 0000000..187e704 --- /dev/null +++ b/survey_xlsx_extra_fields/tests/test_report.py @@ -0,0 +1,55 @@ +# Copyright 2025 Elabore +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import io + +import openpyxl + +from odoo.addons.survey.tests import common + + +class TestExcludeFileQuestion(common.TestSurveyCommon): + def setUp(self): + super().setUp() + self.text_question = self._add_question( + self.page_0, + "Your name", + "char_box", + survey_id=self.survey.id, + ) + self.file_question = self._add_question( + self.page_0, + "Upload your document", + "file", + constr_mandatory=False, + survey_id=self.survey.id, + ) + answer = self._add_answer(self.survey, False, email="test@example.com") + self._add_answer_line(self.text_question, answer, "Alice") + self.env["survey.user_input.line"].create({ + "user_input_id": answer.id, + "question_id": self.file_question.id, + "answer_type": "file", + "skipped": False, + "value_file": "ZmFrZQ==", + "value_file_fname": "doc.pdf", + }) + answer._mark_done() + + def _get_sheet(self): + report = self.env.ref("survey_xlsx.report_survey_xlsx") + rep = self.env["ir.actions.report"]._render(report, self.survey.ids, {}) + wb = openpyxl.load_workbook(io.BytesIO(rep[0])) + return wb.worksheets[0] + + def _find_col(self, sheet, header): + for col in range(1, sheet.max_column + 1): + if sheet.cell(1, col).value == header: + return col + return None + + def test_file_question_excluded(self): + sheet = self._get_sheet() + # Regular questions are still exported + self.assertIsNotNone(self._find_col(sheet, "Your name")) + # File questions get no column at all + self.assertIsNone(self._find_col(sheet, "Upload your document"))