3 Commits

Author SHA1 Message Date
0020f35399 [FIX] survey_record_generation: questions without answer no longer empty fields
When a question was not displayed (conditional question) or skipped, its
field was still given to create() with a None value. The field was then
emptied instead of keeping its default or computed value.

Since the link to an already created record is given at creation, this
also made sale.subscription.line price_unit equal to 0 when the "free
amount" question was not displayed, instead of the product price.

Fields without value are now left out of the values, which also allows
several conditional questions to fill up the same field (e.g. one list
of amounts per periodicity).

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:54:48 +00:00
bfe59096db [FIX] survey_record_generation: link to already created record at creation
A NOT required "other_record" field was always set after the loop with a
write(), even when the linked record was already created. This late write
triggers the recomputation of stored computed fields depending on the
relation, overriding the values answered by the user.

E.g. with subscription_oca, sale.subscription.line price_unit depends on
sale_subscription_id.partner_id/pricelist_id: a free amount typed in a
numerical_box question (33) was replaced by the product price (20) when
the line was linked to its subscription.

Now the relation is given to create() when the linked record already
exists, so that the user's values are protected from recomputation. The
deferred write is kept for records created later.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 08:54:48 +00:00
9d55588f18 [ADD] survey_dropdown_simple_choice
Some checks failed
pre-commit / pre-commit (pull_request) Failing after 1m35s
tests / test (pull_request) Successful in 1m14s
Display "Multiple choice: only one answer" (simple_choice) survey
questions as a dropdown list instead of radio buttons, per question.

The native radios are kept (hidden by CSS) and still carry the answer:
the dropdown only clicks them, so validation, conditional questions,
"Other" comment, auto-submit and AJAX submit are handled natively.
In review / print mode, only the selected and correct answers are
displayed. Native rendering is kept when suggested answers contain
images.

Assisted-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:30:55 +02:00
16 changed files with 503 additions and 27 deletions

View File

@@ -0,0 +1,23 @@
# survey_dropdown_simple_choice
Ajoute une option « Afficher en liste déroulante » aux questions
« Choix multiple : une seule réponse » des sondages : les réponses sont
proposées dans une liste déroulante au lieu de boutons radio.
Pratique pour les longues listes (pays, départements…).
## Utilisation
Dans le sondage, éditez la question → onglet **Options** → cochez
**« Afficher en liste déroulante »**.
L'option « Autre » (commentaire) et les questions conditionnelles
fonctionnent comme d'habitude.
## Limites
- Si une réponse proposée contient une image, la question reste affichée
en boutons radio.
- À l'impression et dans la revue des réponses (« Consulter vos réponses »),
seule la réponse choisie est affichée, ainsi que la bonne réponse pour
un sondage noté.

View File

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

View File

@@ -0,0 +1,27 @@
{
"name": "Survey Dropdown Simple Choice",
"summary": "Display some single choice survey questions as a dropdown list",
"description": "Adds a boolean on survey.question to display a "
"\"Multiple choice: only one answer\" question as a dropdown list "
"instead of radio buttons, with support for conditional questions "
"and \"Other\" comments.",
"author": "Elabore",
"website": "https://www.elabore.coop",
"category": "Marketing/Surveys",
"license": "AGPL-3",
"version": "18.0.1.0.0",
"depends": ["survey"],
"data": [
"views/survey_question_views.xml",
"views/survey_templates.xml",
],
"assets": {
"survey.survey_assets": [
"survey_dropdown_simple_choice/static/src/scss/survey_dropdown.scss",
"survey_dropdown_simple_choice/static/src/js/survey_form_dropdown.js",
],
},
"installable": True,
"application": False,
"auto_install": True,
}

View File

@@ -0,0 +1,41 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * survey_dropdown_simple_choice
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-20 17:08+0000\n"
"PO-Revision-Date: 2026-10-20 17:08+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_dropdown_simple_choice
#: model:ir.model.fields,field_description:survey_dropdown_simple_choice.field_survey_question__display_dropdown
msgid "Display as dropdown list"
msgstr "Afficher en liste déroulante"
#. module: survey_dropdown_simple_choice
#: model:ir.model.fields,help:survey_dropdown_simple_choice.field_survey_question__display_dropdown
msgid ""
"Display this single choice question as a dropdown list instead of radio "
"buttons. Ignored if the suggested answers contain images."
msgstr ""
"Affiche cette question à choix unique sous la forme d'une liste déroulante "
"au lieu de boutons radio. Ignoré si les réponses proposées contiennent des "
"images."
#. module: survey_dropdown_simple_choice
#: model:ir.model.fields,field_description:survey_dropdown_simple_choice.field_survey_question__smart_search
msgid "Smart Search"
msgstr "Recherche intelligente"
#. module: survey_dropdown_simple_choice
#: model:ir.model,name:survey_dropdown_simple_choice.model_survey_question
msgid "Survey Question"
msgstr "Question du sondage"

View File

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

View File

@@ -0,0 +1,11 @@
from odoo import fields, models
class SurveyQuestion(models.Model):
_inherit = 'survey.question'
display_dropdown = fields.Boolean(
string="Display as dropdown list",
help="Display this single choice question as a dropdown list instead "
"of radio buttons. Ignored if the suggested answers contain images.",
)

View File

@@ -0,0 +1,72 @@
/** @odoo-module **/
import SurveyFormWidget from "@survey/js/survey_form";
// The dropdown only drives the native radios (hidden by CSS): choosing an
// option clicks the matching radio, so validation, conditional questions,
// "Other" comment, auto-submit and AJAX submit are all handled natively.
SurveyFormWidget.include({
events: Object.assign({}, SurveyFormWidget.prototype.events, {
'change select.o_survey_dropdown_choice': '_onDropdownChoiceChange',
'change [data-survey-dropdown="edit"] .o_survey_form_choice_item': '_onDropdownRadioChange',
}),
/**
* A focused dropdown is handled like a text input: its keys (letters,
* arrows, enter) must not trigger the survey shortcuts.
*
* @override
*/
_onKeyDown: function (event) {
if (event.target.matches?.('select.o_survey_dropdown_choice')
&& !((event.ctrlKey || event.metaKey) && event.key === 'Enter')) {
return;
}
return this._super.apply(this, arguments);
},
/**
* Correct answers are colored on the native radios: show them (only the
* selected and correct ones, see "review" mode) instead of the dropdown.
*
* @override
*/
_showQuestionAnswer: function (correctAnswers, questionId) {
this._super.apply(this, arguments);
const group = this.el.querySelector(
`.js_question-wrapper[id="${questionId}"] [data-survey-dropdown="edit"]`
);
if (group) {
group.dataset.surveyDropdown = 'review';
group.querySelector('.o_survey_dropdown_select').classList.add('d-none');
}
},
/**
* Click the radio of the chosen option, or the checked one to untick it
* when the empty option is chosen (native untick behavior).
*/
_onDropdownChoiceChange: function (ev) {
if (this.readonly) {
return;
}
const select = ev.currentTarget;
const group = select.closest('.o_survey_form_choice');
const radio = select.value
? group.querySelector(`.o_survey_form_choice_item[value="${select.value}"]`)
: group.querySelector('.o_survey_form_choice_item:checked');
if (radio && !(select.value && radio.checked)) {
radio.click();
}
},
/**
* Keep the dropdown in sync when a radio changes by other means
* (A-Z keyboard shortcuts).
*/
_onDropdownRadioChange: function (ev) {
const group = ev.currentTarget.closest('.o_survey_form_choice');
const checked = group.querySelector('.o_survey_form_choice_item:checked');
group.querySelector('select.o_survey_dropdown_choice').value = checked ? checked.value : '';
},
});

View File

@@ -0,0 +1,20 @@
.o_survey_form {
select.o_survey_dropdown_choice {
min-height: 44px;
option {
color: initial;
}
}
}
// Native radios still carry the answer but are replaced by the dropdown
.o_survey_form_choice[data-survey-dropdown="edit"] > div:has(> .o_survey_choice_btn) {
display: none;
}
// Review / print / correction: only show the selected and correct answers
.o_survey_form_choice[data-survey-dropdown="review"]
> div:has(> .o_survey_choice_btn):not(:has(> .o_survey_selected)):not(:has(> .bg-success)) {
display: none;
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="survey_question_form_dropdown" model="ir.ui.view">
<field name="name">survey.question.form.dropdown</field>
<field name="model">survey.question</field>
<field name="inherit_id" ref="survey.survey_question_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='comments_allowed']" position="before">
<field name="display_dropdown" invisible="question_type != 'simple_choice'"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add a dropdown on top of the native radios when the display_dropdown
flag is set. The radios are kept (hidden by CSS) and still carry the
answer: the dropdown only clicks them (see survey_form_dropdown.js).
In readonly mode (review / print), the radios are shown but only the
selected and correct answers are kept visible (data-survey-dropdown
"review"). The native rendering is kept when a suggestion carries an
image. -->
<template id="question_simple_choice" inherit_id="survey.question_simple_choice" name="Question: simple choice (dropdown)">
<xpath expr="//t[@t-set='comment_line']" position="after">
<t t-set="dropdown_enabled" t-value="question.display_dropdown and not any(a.value_image for a in question.suggested_answer_ids)"/>
<t t-set="use_dropdown" t-value="dropdown_enabled and not survey_form_readonly"/>
</xpath>
<xpath expr="//div[hasclass('o_survey_form_choice')]" position="attributes">
<attribute name="t-att-data-survey-dropdown">('review' if survey_form_readonly else 'edit') if dropdown_enabled else None</attribute>
</xpath>
<xpath expr="//t[@t-set='has_correct_answer']" position="after">
<div t-if="use_dropdown" class="col-sm-12 o_survey_dropdown_select">
<select class="form-select o_survey_dropdown_choice" t-att-aria-label="question.title">
<option value=""/>
<option t-foreach="question.suggested_answer_ids" t-as="label"
t-att-value="label.id"
t-att-selected="'selected' if answer_line.suggested_answer_id == label else None"
t-out="label.value"/>
<option t-if="question.comments_allowed and question.comment_count_as_answer" value="-1"
t-att-selected="'selected' if comment_line else None"
t-out="question.comments_message or default_comments_message"/>
</select>
</div>
</xpath>
</template>
</odoo>

View File

@@ -11,7 +11,7 @@ Allow to create record of any model when sending the form :
* Associate question with fields
* For x2m fields : Associate values to questions
""",
"version": "18.0.1.0.1",
"version": "18.0.1.0.2",
"license": "AGPL-3",
"author": "Elabore",
"website": "https://www.elabore.coop",

View File

@@ -61,6 +61,11 @@ class SurveyUserInput(models.Model):
)
field_name: str = field_value.field_id.name
if value is None:
# No value (e.g. question not displayed or skipped):
# keep the default/computed value of the field, and
# let another question fill up the same field.
continue
vals[field_name] = value
existing_record = self.find_existing_record(record_creation, vals)
@@ -160,8 +165,9 @@ class SurveyUserInput(models.Model):
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(
# if the other record is already created, get it
# (or raise if it is not created yet and the field is required)
value = self.get_value_from_other_record(
model, created_records, field_value
)
# otherwise, we update the record later (out of this for loop)
@@ -194,27 +200,21 @@ class SurveyUserInput(models.Model):
if unique_fields:
uniq_domain = []
for uniq_field in unique_fields:
uniq_domain.append((uniq_field, "=", vals[uniq_field]))
uniq_domain.append((uniq_field, "=", vals.get(uniq_field, False)))
duplicate = self.env[model].search(uniq_domain, limit=1)
return duplicate
def get_required_value_from_other_record(
def get_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]
linked_record = created_records.get(field_value.other_created_record_id.id)
if linked_record:
# The other record is already created: link it right away.
return self.get_value_for_relational_field(field_value, linked_record)
else:
if self.env[model]._fields[field_value.field_id.name].required:
raise UserError(
_(
"The field %(field)s is mandatory for model %(model)s. "
@@ -227,6 +227,7 @@ class SurveyUserInput(models.Model):
"record": field_value.other_created_record_id.name,
}
)
return None
def get_value_from_user_answer(
self,

View File

@@ -1,2 +1,3 @@
from . import test_survey_record_creation
from . import test_end_migration_18_0_1_0_1
from . import test_computed_fields

View File

@@ -0,0 +1,33 @@
from odoo import api, fields, models
class SurveyRecordGenerationFakeParent(models.Model):
_name = "survey.record.generation.fake.parent"
_description = "Fake parent record (tests only)"
name = fields.Char()
price = fields.Float()
class SurveyRecordGenerationFakeLine(models.Model):
"""Line whose price is computed from its parent, like the price of a
subscription line (subscription_oca).
Pattern reproduced: the survey creates a parent, then a line linked to
it, with a price answered by the user. The user's price must be kept,
the parent's price only applies when the user gave none.
"""
_name = "survey.record.generation.fake.line"
_description = "Fake line record (tests only)"
parent_id = fields.Many2one("survey.record.generation.fake.parent")
price_unit = fields.Float(
compute="_compute_price_unit", store=True, readonly=False
)
@api.depends("parent_id.price")
def _compute_price_unit(self):
for line in self:
if line.parent_id:
line.price_unit = line.parent_id.price

View File

@@ -0,0 +1,120 @@
from odoo_test_helper import FakeModelLoader
from odoo.addons.survey.tests.common import SurveyCase
class TestComputedFields(SurveyCase):
"""User's answers must not be overridden by computed fields depending on
a link to another generated record.
Use case: a membership form creating a subscription and a subscription
line whose price is typed by the participant (free amount), while the
line price is computed from the subscription.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.loader = FakeModelLoader(cls.env, cls.__module__)
cls.loader.backup_registry()
from .fake_models import (
SurveyRecordGenerationFakeLine,
SurveyRecordGenerationFakeParent,
)
cls.loader.update_registry(
(SurveyRecordGenerationFakeParent, SurveyRecordGenerationFakeLine)
)
@classmethod
def tearDownClass(cls):
cls.loader.restore_registry()
super().tearDownClass()
def setUp(self):
super().setUp()
self.survey = self.env["survey.survey"].create({"title": "Join us"})
self.question_amount = self._add_question(
page=None,
name="Free amount",
qtype="numerical_box",
survey_id=self.survey.id,
sequence=1,
)
# Parent, with a default price of 20
self.parent_creation = self._add_record_creation(
"Parent", "survey.record.generation.fake.parent", 0
)
self._add_field_value(
self.parent_creation, "name", value_origin="fixed",
fixed_value_char="Membership",
)
self._add_field_value(
self.parent_creation, "price", value_origin="fixed",
fixed_value_float=20.0,
)
# Line, linked to the parent, with the price answered by the user
self.line_creation = self._add_record_creation(
"Line", "survey.record.generation.fake.line", 1
)
self._add_field_value(
self.line_creation, "parent_id", value_origin="other_record",
other_created_record_id=self.parent_creation.id,
)
self._add_field_value(
self.line_creation, "price_unit", value_origin="question",
question_id=self.question_amount.id,
)
def _add_record_creation(self, name, model, sequence):
return self.env["survey.record.creation"].create(
{
"name": name,
"survey_id": self.survey.id,
"model_id": self.env["ir.model"]._get(model).id,
"sequence": sequence,
}
)
def _add_field_value(self, record_creation, field_name, **vals):
field = self.env["ir.model.fields"]._get(
record_creation.model_id.model, field_name
)
return self.env["survey.record.creation.field.values"].create(
{
"survey_record_creation_id": record_creation.id,
"survey_id": self.survey.id,
"model_id": record_creation.model_id.id,
"field_id": field.id,
**vals,
}
)
def _submit(self, amount=None):
answer = self._add_answer(
survey=self.survey, partner=False, email="jean@test.fr"
)
if amount is not None:
self._add_answer_line(
question=self.question_amount, answer=answer, answer_value=amount
)
answer._mark_done()
return self.env["survey.record.generation.fake.line"].search(
[("parent_id.name", "=", "Membership")]
)
def test_answer_is_not_overridden_by_computed_field(self):
line = self._submit(amount=33)
self.assertEqual(len(line), 1)
self.assertEqual(line.price_unit, 33.0)
def test_computed_field_applies_when_question_is_not_displayed(self):
# E.g. the "free amount" question is only displayed when the user
# chose "Other amount": otherwise the line keeps the computed price.
line = self._submit()
self.assertEqual(len(line), 1)
self.assertEqual(line.price_unit, 20.0)

View File

@@ -1072,3 +1072,82 @@ class TestSurveyRecordCreation(SurveyCase):
self.assertEqual(self.answer.partner_id, jean)
self.assertNotEqual(self.answer.partner_id, jeanne)
self.assertEqual(self.answer.email, jean.email)
def test_question_not_displayed_does_not_override_default_value(self):
# When a question is not displayed to the user (e.g. a conditional
# question), the field must keep its default (or computed) value
# instead of being emptied.
question_type = self._add_question(
page=None,
name="Type",
qtype="simple_choice",
labels=[{"value": "contact"}, {"value": "other"}],
survey_id=self.survey.id,
sequence=2,
answer_values_type="value",
)
for suggested_answer in question_type.suggested_answer_ids:
suggested_answer.value_char = suggested_answer.value
type_field = self.env["ir.model.fields"]._get("res.partner", "type")
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": type_field.id,
"value_origin": "question",
"question_id": question_type.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.answer._mark_done()
partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(partner.type, "contact")
def test_several_conditional_questions_on_the_same_field(self):
# Several questions can fill up the same field (e.g. one list of
# amounts per periodicity, displayed conditionally): the answered
# question wins, the questions not displayed are ignored.
street_field = self.env["ir.model.fields"]._get("res.partner", "street")
questions = self.env["survey.question"]
for sequence, name in enumerate(["Street (France)", "Street (abroad)"], 2):
question = self._add_question(
page=None,
name=name,
qtype="char_box",
survey_id=self.survey.id,
sequence=sequence,
)
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": street_field.id,
"value_origin": "question",
"question_id": question.id,
}
)
questions |= question
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"
)
# Only the first question is displayed and answered
self._add_answer_line(
question=questions[0], answer=self.answer, answer_value="1 rue de Paris"
)
self.answer._mark_done()
partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(partner.street, "1 rue de Paris")