[ADD] survey_xlsx_expand_multiple_choice, survey_xlsx_extra_fields
survey_xlsx_expand_multiple_choice: expand multiple_choice questions into one Oui/Non column per option, and matrix questions into one column per row (value = selected option). Relies on the extension hooks added to survey_xlsx. survey_xlsx_extra_fields: bridge (auto_install) with survey_extra_fields that excludes 'file' question types from the XLSX export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
3
survey_xlsx_expand_multiple_choice/__init__.py
Normal file
3
survey_xlsx_expand_multiple_choice/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copyright 2025 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from . import report
|
||||
16
survey_xlsx_expand_multiple_choice/__manifest__.py
Normal file
16
survey_xlsx_expand_multiple_choice/__manifest__.py
Normal file
@@ -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"],
|
||||
}
|
||||
3
survey_xlsx_expand_multiple_choice/report/__init__.py
Normal file
3
survey_xlsx_expand_multiple_choice/report/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copyright 2025 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from . import report_survey_xlsx
|
||||
@@ -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"]
|
||||
3
survey_xlsx_expand_multiple_choice/tests/__init__.py
Normal file
3
survey_xlsx_expand_multiple_choice/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Copyright 2025 Elabore
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
from . import test_report
|
||||
148
survey_xlsx_expand_multiple_choice/tests/test_report.py
Normal file
148
survey_xlsx_expand_multiple_choice/tests/test_report.py
Normal file
@@ -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)
|
||||
1
survey_xlsx_extra_fields/__init__.py
Normal file
1
survey_xlsx_extra_fields/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import report
|
||||
19
survey_xlsx_extra_fields/__manifest__.py
Normal file
19
survey_xlsx_extra_fields/__manifest__.py
Normal file
@@ -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",
|
||||
],
|
||||
}
|
||||
1
survey_xlsx_extra_fields/report/__init__.py
Normal file
1
survey_xlsx_extra_fields/report/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import report_survey_xlsx
|
||||
15
survey_xlsx_extra_fields/report/report_survey_xlsx.py
Normal file
15
survey_xlsx_extra_fields/report/report_survey_xlsx.py
Normal file
@@ -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)
|
||||
1
survey_xlsx_extra_fields/tests/__init__.py
Normal file
1
survey_xlsx_extra_fields/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_report
|
||||
55
survey_xlsx_extra_fields/tests/test_report.py
Normal file
55
survey_xlsx_extra_fields/tests/test_report.py
Normal file
@@ -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"))
|
||||
Reference in New Issue
Block a user