1 Commits

Author SHA1 Message Date
07d0af5e7f [ADD] survey_xlsx_expand_multiple_choice, survey_xlsx_extra_fields
Some checks failed
pre-commit / pre-commit (pull_request) Has been cancelled
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>
2026-07-03 15:49:48 +02:00
36 changed files with 1750 additions and 954 deletions

View File

@@ -1,42 +0,0 @@
#!/bin/bash
#
# Run the Python tests of the given Odoo modules.
#
# This script is meant to be executed *inside* an Elabore doodba image
# (docker.0k.io/mirror/odoo:rc_<version>-ELABORE-LIGHT), with a
# PostgreSQL service reachable at $PGHOST. The modules under test are
# read from the current working directory (the checked-out repository),
# which is prepended to the addons path so that the PR's code takes
# precedence over the modules already baked in the image.
#
# Usage: odoo-tests.sh <module1,module2,...>
set -euo pipefail
modules="${1:?usage: odoo-tests.sh <module1,module2,...>}"
export PGHOST=postgres
export PGUSER=odoo
export PGPASSWORD=odoopassword
export PGDATABASE="test_$(basename "$PWD" | tr '.-' '__')"
export WITHOUT_DEMO=all
export DB_FILTER='.*'
# Generate /opt/odoo/auto/odoo.conf from the environment and wait for
# the database to accept connections.
/opt/odoo/common/entrypoint
# Odoo test-tags format: "/module1,/module2" (one leading slash).
tags="/$(echo "$modules" | tr ',' ',/')"
exec odoo \
--config=/opt/odoo/auto/odoo.conf \
--max-cron-threads=0 \
--limit-time-real=10000 \
--workers=0 \
--test-enable \
--stop-after-init \
--addons-path="$PWD,/opt/odoo/auto/addons" \
-d "$PGDATABASE" \
-i "$modules" \
--test-tags "$tags"

View File

@@ -2,6 +2,8 @@ name: pre-commit
on: on:
pull_request: pull_request:
branches:
- "18.0*"
jobs: jobs:
pre-commit: pre-commit:
@@ -13,10 +15,10 @@ jobs:
python-version: "3.11" python-version: "3.11"
- name: Get python version - name: Get python version
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
- uses: https://gitea.com/actions/cache@v3 # - uses: actions/cache@v4
with: # with:
path: ~/.cache/pre-commit # path: ~/.cache/pre-commit
key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} # key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit - name: Install pre-commit
run: pip install pre-commit run: pip install pre-commit
- name: Run pre-commit - name: Run pre-commit

View File

@@ -1,79 +0,0 @@
name: tests
on:
pull_request:
branches:
- "18.0*"
workflow_dispatch:
jobs:
test:
runs-on: odoo-ci
container:
image: docker.0k.io/mirror/odoo:rc_18.0-ELABORE-LIGHT
services:
postgres:
image: docker.0k.io/postgres:17.2.0-myc
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoopassword
POSTGRES_DB: postgres
steps:
# The doodba image ships no Node.js, so actions/checkout@v4 cannot
# run here. The repository is private, so the job token is required.
- name: Checkout
env:
SECRET_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
token="${GITEA_TOKEN:-${GITHUB_TOKEN:-${SECRET_GITEA_TOKEN:-${SECRET_GITHUB_TOKEN:-}}}}"
if [ -z "$token" ]; then
echo "::error::No job token available to clone the repository"
exit 1
fi
server="${GITHUB_SERVER_URL:-https://git.elabore.coop}"
repo="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}"
ref="${GITHUB_REF:-${GITHUB_SHA:?neither GITHUB_REF nor GITHUB_SHA is set}}"
auth_url="$(printf '%s' "${server%/}/${repo}.git" |
sed -E "s#^https://#https://${token}@#")"
git init -q .
git config --global --add safe.directory "$PWD"
git remote add origin "$auth_url"
git fetch -q --depth=1 origin "$ref"
git checkout -q FETCH_HEAD
- name: Detect changed modules
id: changed
run: |
set -eu
if [ -n "${GITHUB_BASE_REF:-}" ]; then
git fetch -q --depth=1 origin "refs/heads/$GITHUB_BASE_REF"
base_rev="FETCH_HEAD"
scope="diff against $GITHUB_BASE_REF"
else
base_rev=""
scope="all installable modules (manual run)"
fi
if [ -n "$base_rev" ]; then
modules="$(
git diff --name-only "$base_rev" HEAD |
awk -F/ '{print $1}' | sort -u |
while read -r dir; do
[ -f "$dir/__manifest__.py" ] && echo "$dir"
done | paste -sd, -
)"
else
modules="$(
for dir in */; do
dir="${dir%/}"
[ -f "$dir/__manifest__.py" ] && echo "$dir"
done | paste -sd, -
)"
fi
echo "modules=$modules" >> "$GITHUB_OUTPUT"
echo "Modules to test ($scope): ${modules:-<none>}"
- name: Run Odoo tests
if: steps.changed.outputs.modules != ''
run: bash .gitea/scripts/odoo-tests.sh "${{ steps.changed.outputs.modules }}"

161
.gitignore vendored
View File

@@ -1,42 +1,40 @@
*.docx
.~*
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/.venv *$py.class
/.pytest_cache
/.ruff_cache
# C extensions # C extensions
*.so *.so
# Distribution / packaging # Distribution / packaging
.Python .Python
env/
bin/
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/
downloads/
eggs/ eggs/
.eggs/
lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
var/ var/
wheels/
share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
*.eggs MANIFEST
# Windows installers # PyInstaller
*.msi # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
# Debian packages *.manifest
*.deb *.spec
# Redhat packages
*.rpm
# MacOS packages
*.dmg
*.pkg
# Installer logs # Installer logs
pip-log.txt pip-log.txt
@@ -45,44 +43,123 @@ pip-delete-this-directory.txt
# Unit test / coverage reports # Unit test / coverage reports
htmlcov/ htmlcov/
.tox/ .tox/
.nox/
.coverage .coverage
.coverage.*
.cache .cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations # Translations
*.mo *.mo
*.pot
# Pycharm
.idea
# Eclipse
.settings
# Visual Studio cache/options directory
.vs/
.vscode
# OSX Files
.DS_Store
# Django stuff: # Django stuff:
*.log *.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Mr Developer # Flask stuff:
.mr.developer.cfg instance/
.project .webassets-cache
.pydevproject
# Rope # Scrapy stuff:
.ropeproject .scrapy
# Sphinx documentation # Sphinx documentation
docs/_build/ docs/_build/
# Backup files # PyBuilder
*~ .pybuilder/
*.swp target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# OCA rules
!static/lib/

View File

@@ -22,7 +22,7 @@ exclude: |
(LICENSE.*|COPYING.*) (LICENSE.*|COPYING.*)
default_language_version: default_language_version:
python: python3 python: python3
node: "22.9.0" node: "16.17.0"
repos: repos:
- repo: local - repo: local
hooks: hooks:
@@ -38,12 +38,8 @@ repos:
entry: found a en.po file entry: found a en.po file
language: fail language: fail
files: '[a-zA-Z0-9_]*/i18n/en\.po$' files: '[a-zA-Z0-9_]*/i18n/en\.po$'
- repo: https://github.com/sbidoul/whool
rev: v1.3
hooks:
- id: whool-init
- repo: https://github.com/oca/maintainer-tools - repo: https://github.com/oca/maintainer-tools
rev: b89f767503be6ab2b11e4f50a7557cb20066e667 rev: f9b919b9868143135a9c9cb03021089cabba8223
hooks: hooks:
# update the NOT INSTALLABLE ADDONS section above # update the NOT INSTALLABLE ADDONS section above
- id: oca-update-pre-commit-excluded-addons - id: oca-update-pre-commit-excluded-addons
@@ -53,13 +49,16 @@ repos:
$(git rev-parse --show-toplevel))"' $(git rev-parse --show-toplevel))"'
- id: oca-gen-addon-readme - id: oca-gen-addon-readme
entry: entry:
bash -c 'oca-gen-addon-readme --addons-dir=. --branch=$(git symbolic-ref bash -c 'oca-gen-addon-readme
--addons-dir=.
--branch=$(git symbolic-ref
refs/remotes/origin/HEAD | sed "s@^refs/remotes/origin/@@") refs/remotes/origin/HEAD | sed "s@^refs/remotes/origin/@@")
--repo-name=$(basename $(git rev-parse --show-toplevel)) --org-name="Elabore" --repo-name=$(basename $(git rev-parse --show-toplevel))
--if-source-changed --keep-source-digest --convert-fragments-to-markdown' --org-name="Elabore"
- id: oca-gen-external-dependencies --if-source-changed --keep-source-digest'
- repo: https://github.com/OCA/odoo-pre-commit-hooks - repo: https://github.com/OCA/odoo-pre-commit-hooks
rev: v0.0.33 rev: v0.1.4
hooks: hooks:
- id: oca-checks-odoo-module - id: oca-checks-odoo-module
- id: oca-checks-po - id: oca-checks-po
@@ -78,8 +77,8 @@ repos:
files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$ files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
language: node language: node
additional_dependencies: additional_dependencies:
- "prettier@3.3.3" - "prettier@2.7.1"
- "@prettier/plugin-xml@3.4.1" - "@prettier/plugin-xml@2.2.0"
- repo: local - repo: local
hooks: hooks:
- id: eslint - id: eslint
@@ -92,11 +91,10 @@ repos:
types: [javascript] types: [javascript]
language: node language: node
additional_dependencies: additional_dependencies:
- "eslint@9.12.0" - "eslint@8.24.0"
- "eslint-plugin-jsdoc@50.3.1" - "eslint-plugin-jsdoc@"
- "globals@16.0.0"
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v5.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
# exclude autogenerated files # exclude autogenerated files
@@ -117,8 +115,23 @@ repos:
- id: check-xml - id: check-xml
- id: mixed-line-ending - id: mixed-line-ending
args: ["--fix=lf"] args: ["--fix=lf"]
- repo: https://github.com/PyCQA/docformatter
rev: v1.7.7
hooks:
- id: docformatter
args: [
"--in-place", # modify the files
"--recursive", # run on all the files
"--wrap-summaries",
"88", # max length of 1st line
"--wrap-descriptions",
"88", # max length of other lines
"--pre-summary-newline", # new line before a long summary
"--make-summary-multi-line", # force summary on multilines
]
additional_dependencies: ["tomli"] # if Python <3.11
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.8 rev: v0.12.0
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]

View File

@@ -25,25 +25,19 @@ disable=all
enable=anomalous-backslash-in-string, enable=anomalous-backslash-in-string,
api-one-deprecated, api-one-deprecated,
api-one-multi-together, api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase, class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority, dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv, duplicate-id-csv,
duplicate-key,
duplicate-xml-fields, duplicate-xml-fields,
duplicate-xml-record-id, duplicate-xml-record-id,
eval-referenced, eval-referenced,
incoherent-interpreter-exec-perm,
openerp-exception-warning,
redundant-modulename-xml,
relative-import,
rst-syntax-error,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
assignment-from-none,
attribute-deprecated,
dangerous-default-value,
development-status-allowed,
duplicate-key,
eval-used, eval-used,
incoherent-interpreter-exec-perm,
license-allowed, license-allowed,
manifest-author-string, manifest-author-string,
manifest-deprecated-key, manifest-deprecated-key,
@@ -54,68 +48,73 @@ enable=anomalous-backslash-in-string,
method-inverse, method-inverse,
method-required-super, method-required-super,
method-search, method-search,
openerp-exception-warning,
pointless-statement, pointless-statement,
pointless-string-statement, pointless-string-statement,
print-used, print-used,
redundant-keyword-arg, redundant-keyword-arg,
redundant-modulename-xml,
reimported, reimported,
relative-import,
return-in-init, return-in-init,
rst-syntax-error,
sql-injection, sql-injection,
too-few-format-args, too-few-format-args,
translation-field, translation-field,
translation-required, translation-required,
unreachable, unreachable,
use-vim-comment, use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link, character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password, create-user-wo-reset-password,
dangerous-filter-wo-user, dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority, dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node, deprecated-data-xml-node,
deprecated-openerp-xml-node, deprecated-openerp-xml-node,
duplicate-po-message-definition, duplicate-po-message-definition,
file-not-used,
missing-newline-extrafiles,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
str-format-used,
unnecessary-utf8-coding-comment,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
attribute-string-redundant,
consider-merging-classes-inherited,
context-overridden,
except-pass, except-pass,
file-not-used,
invalid-commit, invalid-commit,
manifest-maintainers-list, manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme, missing-readme,
missing-return, missing-return,
odoo-addons-relative-import, odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter, renamed-field-parameter,
resource-not-exist, resource-not-exist,
str-format-used,
test-folder-imported, test-folder-imported,
translation-contains-variable, translation-contains-variable,
translation-positional-used, translation-positional-used,
website-manifest-key-not-valid-uri,
external-request-timeout,
missing-manifest-dependency,
too-complex,,
create-user-wo-reset-password,
dangerous-filter-wo-user,
file-not-used,
missing-newline-extrafiles,
no-utf8-coding-comment,
old-api7-method-defined,
unnecessary-utf8-coding-comment, unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout,
# messages that do not cause the lint step to fail # messages that do not cause the lint step to fail
consider-merging-classes-inherited, consider-merging-classes-inherited,
create-user-wo-reset-password,
dangerous-filter-wo-user,
deprecated-module, deprecated-module,
file-not-used,
invalid-commit, invalid-commit,
missing-manifest-dependency,
missing-newline-extrafiles,
missing-readme, missing-readme,
no-utf8-coding-comment,
odoo-addons-relative-import, odoo-addons-relative-import,
old-api7-method-defined,
redefined-builtin, redefined-builtin,
manifest-external-assets too-complex,
unnecessary-utf8-coding-comment
[REPORTS] [REPORTS]

View File

@@ -17,25 +17,19 @@ disable=all
enable=anomalous-backslash-in-string, enable=anomalous-backslash-in-string,
api-one-deprecated, api-one-deprecated,
api-one-multi-together, api-one-multi-together,
assignment-from-none,
attribute-deprecated,
class-camelcase, class-camelcase,
dangerous-default-value,
dangerous-view-replace-wo-priority, dangerous-view-replace-wo-priority,
development-status-allowed,
duplicate-id-csv, duplicate-id-csv,
duplicate-key,
duplicate-xml-fields, duplicate-xml-fields,
duplicate-xml-record-id, duplicate-xml-record-id,
eval-referenced, eval-referenced,
incoherent-interpreter-exec-perm,
openerp-exception-warning,
redundant-modulename-xml,
relative-import,
rst-syntax-error,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
assignment-from-none,
attribute-deprecated,
dangerous-default-value,
development-status-allowed,
duplicate-key,
eval-used, eval-used,
incoherent-interpreter-exec-perm,
license-allowed, license-allowed,
manifest-author-string, manifest-author-string,
manifest-deprecated-key, manifest-deprecated-key,
@@ -46,50 +40,56 @@ enable=anomalous-backslash-in-string,
method-inverse, method-inverse,
method-required-super, method-required-super,
method-search, method-search,
openerp-exception-warning,
pointless-statement, pointless-statement,
pointless-string-statement, pointless-string-statement,
print-used, print-used,
redundant-keyword-arg, redundant-keyword-arg,
redundant-modulename-xml,
reimported, reimported,
relative-import,
return-in-init, return-in-init,
rst-syntax-error,
sql-injection, sql-injection,
too-few-format-args, too-few-format-args,
translation-field, translation-field,
translation-required, translation-required,
unreachable, unreachable,
use-vim-comment, use-vim-comment,
wrong-tabs-instead-of-spaces,
xml-syntax-error,
attribute-string-redundant,
character-not-valid-in-resource-link, character-not-valid-in-resource-link,
consider-merging-classes-inherited,
context-overridden,
create-user-wo-reset-password, create-user-wo-reset-password,
dangerous-filter-wo-user, dangerous-filter-wo-user,
dangerous-qweb-replace-wo-priority, dangerous-qweb-replace-wo-priority,
deprecated-data-xml-node, deprecated-data-xml-node,
deprecated-openerp-xml-node, deprecated-openerp-xml-node,
duplicate-po-message-definition, duplicate-po-message-definition,
file-not-used,
missing-newline-extrafiles,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
str-format-used,
unnecessary-utf8-coding-comment,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
attribute-string-redundant,
consider-merging-classes-inherited,
context-overridden,
except-pass, except-pass,
file-not-used,
invalid-commit, invalid-commit,
manifest-maintainers-list, manifest-maintainers-list,
missing-newline-extrafiles,
missing-readme, missing-readme,
missing-return, missing-return,
odoo-addons-relative-import, odoo-addons-relative-import,
old-api7-method-defined,
po-msgstr-variables,
po-syntax-error,
renamed-field-parameter, renamed-field-parameter,
resource-not-exist, resource-not-exist,
str-format-used,
test-folder-imported, test-folder-imported,
translation-contains-variable, translation-contains-variable,
translation-positional-used, translation-positional-used,
unnecessary-utf8-coding-comment,
website-manifest-key-not-valid-uri, website-manifest-key-not-valid-uri,
xml-attribute-translatable,
xml-deprecated-qweb-directive,
xml-deprecated-tree-attribute,
external-request-timeout external-request-timeout
[REPORTS] [REPORTS]

View File

@@ -1,205 +0,0 @@
var globals = require('globals');
jsdoc = require("eslint-plugin-jsdoc");
const config = [{
plugins: {
jsdoc,
},
languageOptions: {
globals: {
_: "readonly",
$: "readonly",
fuzzy: "readonly",
jQuery: "readonly",
moment: "readonly",
odoo: "readonly",
openerp: "readonly",
owl: "readonly",
luxon: "readonly",
QUnit: "readonly",
...globals.browser,
},
ecmaVersion: 2024,
sourceType: "script",
},
rules: {
"accessor-pairs": "warn",
"array-callback-return": "warn",
"callback-return": "warn",
"capitalized-comments": ["warn", "always", {
ignoreConsecutiveComments: true,
ignoreInlineComments: true,
}],
complexity: ["warn", 15],
"constructor-super": "warn",
"dot-notation": "warn",
eqeqeq: "warn",
"global-require": "warn",
"handle-callback-err": "warn",
"id-blacklist": "warn",
"id-match": "warn",
"init-declarations": "error",
"max-depth": "warn",
"max-nested-callbacks": "warn",
"max-statements-per-line": "warn",
"no-alert": "warn",
"no-array-constructor": "warn",
"no-caller": "warn",
"no-case-declarations": "warn",
"no-class-assign": "warn",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "error",
"no-delete-var": "warn",
"no-div-regex": "warn",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-duplicate-imports": "error",
"no-else-return": "warn",
"no-empty-character-class": "warn",
"no-empty-function": "error",
"no-empty-pattern": "error",
"no-empty": "warn",
"no-eq-null": "error",
"no-eval": "error",
"no-ex-assign": "error",
"no-extend-native": "warn",
"no-extra-bind": "warn",
"no-extra-boolean-cast": "warn",
"no-extra-label": "warn",
"no-fallthrough": "warn",
"no-func-assign": "error",
"no-global-assign": "error",
"no-implicit-coercion": ["warn", {
allow: ["~"],
}],
"no-implicit-globals": "warn",
"no-implied-eval": "warn",
"no-inline-comments": "warn",
"no-inner-declarations": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-iterator": "warn",
"no-label-var": "warn",
"no-labels": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "error",
"no-mixed-requires": "error",
"no-multi-str": "warn",
"no-native-reassign": "error",
"no-negated-condition": "warn",
"no-negated-in-lhs": "error",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-require": "warn",
"no-new-symbol": "warn",
"no-new-wrappers": "warn",
"no-new": "warn",
"no-obj-calls": "warn",
"no-octal-escape": "warn",
"no-octal": "warn",
"no-param-reassign": "warn",
"no-path-concat": "warn",
"no-process-env": "warn",
"no-process-exit": "warn",
"no-proto": "warn",
"no-prototype-builtins": "warn",
"no-redeclare": "warn",
"no-regex-spaces": "warn",
"no-restricted-globals": "warn",
"no-restricted-imports": "warn",
"no-restricted-modules": "warn",
"no-restricted-syntax": "warn",
"no-return-assign": "error",
"no-script-url": "warn",
"no-self-assign": "warn",
"no-self-compare": "warn",
"no-sequences": "warn",
"no-shadow-restricted-names": "warn",
"no-shadow": "warn",
"no-sparse-arrays": "warn",
"no-sync": "warn",
"no-this-before-super": "warn",
"no-throw-literal": "warn",
"no-undef-init": "warn",
"no-undef": "error",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unused-expressions": "error",
"no-unused-labels": "error",
"no-unused-vars": "error",
"no-use-before-define": "error",
"no-useless-call": "warn",
"no-useless-computed-key": "warn",
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-void": "warn",
"no-with": "warn",
"operator-assignment": ["error", "always"],
"prefer-const": "warn",
radix: "warn",
"require-yield": "warn",
"sort-imports": "warn",
"spaced-comment": ["error", "always"],
strict: ["error", "function"],
"use-isnan": "error",
"jsdoc/check-tag-names": "warn",
"jsdoc/check-types": "warn",
"jsdoc/require-param-description": "off",
"jsdoc/require-return": "off",
"jsdoc/require-return-description": "off",
"jsdoc/require-return-type": "off",
"valid-typeof": "warn",
yoda: "warn",
},
settings: {
jsdoc: {
tagNamePreference: {
arg: "param",
argument: "param",
augments: "extends",
constructor: "class",
exception: "throws",
func: "function",
method: "function",
prop: "property",
return: "returns",
virtual: "abstract",
yield: "yields",
},
preferredTypes: {
array: "Array",
bool: "Boolean",
boolean: "Boolean",
number: "Number",
object: "Object",
str: "String",
string: "String",
},
},
},
}, {
files: ["**/*.esm.js", "**/*test.js"],
languageOptions: {
ecmaVersion: 2024,
sourceType: "module",
},
}];
module.exports = config

View File

@@ -1,14 +0,0 @@
/** @type {import('prettier').Config} */
const config = {
// https://github.com/prettier/prettier/issues/15388#issuecomment-1717746872
plugins: [require.resolve("@prettier/plugin-xml")],
bracketSpacing: false,
printWidth: 88,
proseWrap: "always",
semi: true,
trailingComma: "es5",
xmlWhitespaceSensitivity: "preserve",
};
module.exports = config;

View File

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

View File

@@ -1,159 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
"""Relink survey.user_input.partner_id/email to the contact that actually
matches each participant's own answers.
Before this version, survey.user_input.partner_id/email were only updated
when _mark_done() created a *new* res.partner. If the answer already had a
partner_id/email (e.g. inherited from the Odoo user who was logged in when
the /survey/start link was opened, or from some other unidentified cause),
_mark_done() silently kept that value even when the survey_record_creation
config found or created a different, correct contact from the participant's
own answers. This backfill re-runs that resolution for every already-done
answer and fixes partner_id/email accordingly.
It never re-triggers the other survey.user_input._mark_done() overrides
(crm lead / event registration / notification modules, ...): it only calls
the res.partner-matching helpers directly, so it can't create duplicate
leads, registrations, etc. for historical submissions.
A res.partner is expected to already exist for every done submission (it
was necessarily found or created the first time _mark_done() ran), so this
backfill never creates one. It first looks for the survey.generated.record
row _mark_done() logged when it created that partner, and falls back to
re-deriving the match through find_existing_record()/
find_duplicate_if_there_are_fields_with_unicity_check() when there is no
such row (existing partner matched instead of created) or it points to a
partner that has since been deleted (e.g. merged into another contact). If
neither approach finds anything, the record is left untouched and logged
for manual review instead.
"""
import logging
from odoo import SUPERUSER_ID, api
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
user_input_model = env["survey.user_input"]
surveys_with_partner_creation = env["survey.record.creation"].search(
[("model_id.model", "=", "res.partner")]
).survey_id
user_inputs = user_input_model.search(
[
("survey_id", "in", surveys_with_partner_creation.ids),
("state", "=", "done"),
]
)
_logger.info(
"survey_record_generation: relinking partner_id/email on %d done "
"survey.user_input records",
len(user_inputs),
)
fixed_count = 0
not_found_count = 0
for user_input in user_inputs:
record_creations = user_input.survey_id.survey_record_creation_ids.filtered(
lambda rc: rc.model_id.model == "res.partner"
).sorted("sequence")
record_creation = record_creations[:1]
if not record_creation:
continue
# 1) Prefer the res.partner this very submission created, if any
# (the first one, by id, in the rare case there is more than one):
# it's the exact record _mark_done() produced for this answer, no
# guessing involved. This is also the only option for surveys whose
# record creation has neither update_existing_records nor any
# unicity_check field configured, since find_existing_record()/
# find_duplicate...() can then never find anything (nothing to
# search on).
record = False
for generated in user_input.generated_record_ids.sorted("id"):
if (
generated.survey_record_creation_id == record_creation
and generated.created_record_id
and generated.created_record_id._name == "res.partner"
and generated.created_record_id.exists()
):
# The referenced partner may have since been deleted (e.g.
# merged into another contact): in that case it's not usable
# and we fall through to the search-based lookup below.
record = generated.created_record_id
break
# 2) Otherwise, this submission matched an already-existing partner
# instead of creating one (find_existing_record()/find_duplicate...()
# branch of _mark_done()): re-derive it the same way.
if not record:
# Only compute the fields find_existing_record()/find_duplicate...()
# actually read (the search field, and any unicity_check field),
# not every field of the record creation: other fields (e.g. a
# "record" reference to a model defined in a module that depends
# on this one) may not be loadable yet at this point of the
# upgrade, and are useless here anyway since this backfill never
# writes to res.partner.
needed_field_names = set()
if (
record_creation.update_existing_records
and record_creation.field_to_retrieve_existing_records
):
needed_field_names.add(
record_creation.field_to_retrieve_existing_records.name
)
unicity_field_values = record_creation.field_values_ids.filtered(
lambda field_value: field_value.unicity_check
)
needed_field_names.update(unicity_field_values.mapped("field_id.name"))
vals = {}
for field_value in record_creation.field_values_ids:
if field_value.field_id.name not in needed_field_names:
continue
value, __ = user_input_model.get_value_based_on_value_origin(
field_value=field_value,
user_input=user_input,
created_records={},
model="res.partner",
other_record_fields_to_update=[],
)
vals[field_value.field_id.name] = value
existing_record = user_input_model.find_existing_record(
record_creation, vals
)
duplicate = (
user_input_model.find_duplicate_if_there_are_fields_with_unicity_check(
"res.partner", record_creation, vals
)
)
record = duplicate or existing_record
if not record:
_logger.warning(
"survey_record_generation: no existing res.partner found for "
"survey.user_input %s while backfilling partner_id/email, "
"leaving it untouched",
user_input.id,
)
not_found_count += 1
continue
if user_input.partner_id != record or user_input.email != record.email:
user_input.partner_id = record.id
user_input.email = record.email
fixed_count += 1
_logger.info(
"survey_record_generation: fixed %d survey.user_input records "
"(%d without a matching res.partner)",
fixed_count,
not_found_count,
)

View File

@@ -39,7 +39,6 @@ class SurveyUserInput(models.Model):
for user_input in self: for user_input in self:
created_records = {} created_records = {}
other_record_fields_to_update: list[SurveyRecordCreationFieldValues] = [] other_record_fields_to_update: list[SurveyRecordCreationFieldValues] = []
partner_linked_in_this_run = False
record_creation: SurveyRecordCreation record_creation: SurveyRecordCreation
for ( for (
@@ -86,6 +85,8 @@ class SurveyUserInput(models.Model):
try: try:
with self.env.cr.savepoint(): with self.env.cr.savepoint():
record = self.env[model].create(vals) record = self.env[model].create(vals)
if model == "res.partner" and not self.partner_id:
self.partner_id = record.id
except Exception: except Exception:
# This a broad exception because it could be IntegrityError, # This a broad exception because it could be IntegrityError,
# EmptyNamesError in case partner_firstname is installed etc... # EmptyNamesError in case partner_firstname is installed etc...
@@ -102,17 +103,6 @@ class SurveyUserInput(models.Model):
} }
) )
if model == "res.partner" and not partner_linked_in_this_run:
# Always reflect the partner actually matched/created from
# this participant's own answers, even if partner_id/email
# were already set on the answer (e.g. inherited from the
# logged-in user when the survey link was opened). Only the
# first res.partner record creation of this run wins, in
# case several are configured on the same survey.
user_input.partner_id = record.id
user_input.email = record.email
partner_linked_in_this_run = True
created_records[record_creation.id] = record created_records[record_creation.id] = record
# update linked record # update linked record
@@ -325,44 +315,28 @@ class SurveyUserInput(models.Model):
} }
) )
@staticmethod
def _get_boolean_true_values(self): def get_boolean_value(answer_value_char: str, question_title: str) -> bool:
"""Tokens interpreted as true, normalized to lowercase. # Below code is a trick to be able to use "simple_choice" question
# with values 'yes' and 'no' and transform it to boolean.
Instance method so that a third-party module or a localization can if boolean_value := answer_value_char in [
extend the list without rewriting get_boolean_value. "1",
""" "True",
return {"1", "true", "vrai", "yes", "y", "oui", "o", "on", "x"} "true",
"Oui",
def _get_boolean_false_values(self): "oui",
"""Tokens interpreted as false, normalized to lowercase.""" "Yes",
return {"0", "false", "faux", "no", "n", "non", "off", ""} "yes",
]:
def get_boolean_value(self, answer_value_char: str, question_title: str) -> bool: return boolean_value
"""Convert the technical value of an answer into a boolean. else:
raise UserError(
Allows a boolean field to be filled from a "simple_choice" question _(
whose suggested answers carry yes/no values. "[Survey record generation] The boolean value %s(value)s "
""" "is not supported (for question %(question)s)."
if not answer_value_char: )
# Empty answer: an unset boolean is false, this is not a % {
# configuration error. "value": answer_value_char,
return False "question": question_title,
}
token = str(answer_value_char).strip().casefold()
if token in self._get_boolean_true_values():
return True
if token in self._get_boolean_false_values():
return False
raise UserError(
_(
"[Survey record generation] The boolean value %(value)s is not "
"supported (for question %(question)s)"
) )
% {
"value": answer_value_char,
"question": question_title,
}
)

View File

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

View File

@@ -1,156 +0,0 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import importlib.util
import os
from odoo.addons.survey.tests.common import SurveyCase
def _load_end_migration():
migration_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"migrations",
"18.0.1.0.1",
"end-migration.py",
)
spec = importlib.util.spec_from_file_location(
"survey_record_generation_end_migration_18_0_1_0_1", migration_path
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TestEndMigration180101(SurveyCase):
"""Covers the use cases the 18.0.1.0.1 end-migration backfill must
handle: relinking survey.user_input.partner_id/email to the contact
that actually matches each participant's own answers, for submissions
whose partner_id/email were left wrong before the fix in this version.
"""
def setUp(self):
super().setUp()
self.migration = _load_end_migration()
self.survey = self.env["survey.survey"].create({"title": "Test Survey"})
self.question_name = self._add_question(
page=None,
name="Name",
qtype="char_box",
survey_id=self.survey.id,
sequence=1,
)
self.res_partner_model = self.env["ir.model"]._get("res.partner")
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 = self.env["ir.model.fields"].search(
[("model", "=", "res.partner"), ("name", "=", "name")]
)
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": self.name_field.id,
"value_origin": "question",
"question_id": self.question_name.id,
}
)
def _submit_answer(self, name):
answer = self._add_answer(survey=self.survey, partner=False, email=False)
self._add_answer_line(
question=self.question_name, answer=answer, answer_value=name
)
answer._mark_done()
return answer
def _corrupt_with_wrong_partner(self, answer):
# Simulate a pre-fix record: partner_id/email point to someone
# unrelated to this participant's own answers.
wrong_partner = self.env["res.partner"].create({"name": "Wrong Partner"})
answer.write({"partner_id": wrong_partner.id, "email": "wrong@test.fr"})
return wrong_partner
def test_migrate_uses_generated_record_when_available(self):
# The submission created its own res.partner: the migration must
# relink partner_id/email to that exact contact.
answer = self._submit_answer("Jean")
jean = self.env["res.partner"].search([("name", "=", "Jean")])
self._corrupt_with_wrong_partner(answer)
self.migration.migrate(self.cr, "18.0.1.0.0")
answer.invalidate_recordset()
self.assertEqual(answer.partner_id, jean)
self.assertEqual(answer.email, jean.email)
def test_migrate_falls_back_to_search_when_no_generated_record(self):
# The submission matched an already-existing partner instead of
# creating one: no survey.generated.record row exists for it, so the
# migration must fall back to re-deriving the match through search.
jean = self.env["res.partner"].create(
{"name": "Jean", "email": "jean@test.fr"}
)
self.survey_record_creation.write(
{
"update_existing_records": True,
"field_to_retrieve_existing_records": self.name_field.id,
}
)
answer = self._submit_answer("Jean")
self.assertFalse(answer.generated_record_ids)
self._corrupt_with_wrong_partner(answer)
self.migration.migrate(self.cr, "18.0.1.0.0")
answer.invalidate_recordset()
self.assertEqual(answer.partner_id, jean)
self.assertEqual(answer.email, jean.email)
def test_migrate_falls_back_to_search_when_generated_partner_was_deleted(self):
# The res.partner the submission created has since been deleted
# (e.g. merged into another contact): the migration must not crash
# on the stale reference and must fall back to search instead.
self.survey_record_creation.write(
{
"update_existing_records": True,
"field_to_retrieve_existing_records": self.name_field.id,
}
)
answer = self._submit_answer("Jean")
first_jean = self.env["res.partner"].search([("name", "=", "Jean")])
first_jean.unlink()
# A new contact with the same name takes over.
second_jean = self.env["res.partner"].create(
{"name": "Jean", "email": "jean2@test.fr"}
)
self._corrupt_with_wrong_partner(answer)
self.migration.migrate(self.cr, "18.0.1.0.0")
answer.invalidate_recordset()
self.assertEqual(answer.partner_id, second_jean)
self.assertEqual(answer.email, second_jean.email)
def test_migrate_leaves_answer_untouched_when_no_partner_can_be_found(self):
# No survey.generated.record row (e.g. historical data predating
# that log) and no way to search (neither update_existing_records
# nor unicity_check configured): the migration must leave
# partner_id/email as they are instead of guessing or crashing.
answer = self._submit_answer("Jean")
answer.generated_record_ids.unlink()
wrong_partner = self._corrupt_with_wrong_partner(answer)
self.migration.migrate(self.cr, "18.0.1.0.0")
answer.invalidate_recordset()
self.assertEqual(answer.partner_id, wrong_partner)
self.assertEqual(answer.email, "wrong@test.fr")

View File

@@ -906,71 +906,6 @@ class TestSurveyRecordCreation(SurveyCase):
partner = self.env["res.partner"].search([("name", "=", "Jean")]) partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(self.answer.partner_id, partner) self.assertEqual(self.answer.partner_id, partner)
def test_partner_id_and_email_are_corrected_when_creating_a_new_partner(self):
# A wrong partner_id/email is already set on the answer (e.g.
# inherited from the Odoo user who was logged in when the
# /survey/start link was opened). The record creation must still
# fill partner_id/email with the contact actually created from the
# participant's own answers, overriding that wrong value.
wrong_partner = self.env["res.partner"].create({"name": "Wrong Partner"})
self.answer = self._add_answer(
survey=self.survey, partner=wrong_partner, email="wrong@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(self.answer.partner_id, partner)
self.assertNotEqual(self.answer.partner_id, wrong_partner)
self.assertEqual(self.answer.email, partner.email)
def test_partner_id_and_email_are_corrected_when_matching_an_existing_partner(self):
# Same as above, but this time the record creation matches an
# already-existing partner (via update_existing_records) instead of
# creating a new one: partner_id/email must still be corrected to
# that matched partner, not left as the wrong pre-existing value.
jean = self.env["res.partner"].create({"name": "Jean", "email": "jean@test.fr"})
wrong_partner = self.env["res.partner"].create({"name": "Wrong Partner"})
self.survey_record_creation.write(
{
"update_existing_records": True,
"field_to_retrieve_existing_records": self.name_field.id,
}
)
self.answer = self._add_answer(
survey=self.survey, partner=wrong_partner, email="wrong@test.fr"
)
self._add_answer_line(
question=self.question_name, answer=self.answer, answer_value="Jean"
)
self.answer._mark_done()
self.assertEqual(self.answer.partner_id, jean)
self.assertEqual(self.answer.email, jean.email)
def test_partner_id_and_email_are_corrected_when_matching_a_duplicate(self):
# Same as above, but through the unicity_check/find_duplicate branch
# instead of update_existing_records.
self.name_survey_record_creation_field_values.unicity_check = True
jean = self.env["res.partner"].create({"name": "Jean", "email": "jean@test.fr"})
wrong_partner = self.env["res.partner"].create({"name": "Wrong Partner"})
self.answer = self._add_answer(
survey=self.survey, partner=wrong_partner, email="wrong@test.fr"
)
self._add_answer_line(
question=self.question_name, answer=self.answer, answer_value="Jean"
)
self.answer._mark_done()
self.assertEqual(self.answer.partner_id, jean)
self.assertEqual(self.answer.email, jean.email)
def test_partner_id_in_survey_input_is_filled_up_by_first_contact_record_creation(self): def test_partner_id_in_survey_input_is_filled_up_by_first_contact_record_creation(self):
# In this test, we verify that when creating several contacts with the same survey, # In this test, we verify that when creating several contacts with the same survey,
# the 1st created contact is used to fill up survey_input.partner_id # the 1st created contact is used to fill up survey_input.partner_id
@@ -1017,58 +952,3 @@ class TestSurveyRecordCreation(SurveyCase):
partner = self.env["res.partner"].search([("name", "=", "Jean")]) partner = self.env["res.partner"].search([("name", "=", "Jean")])
self.assertEqual(self.answer.partner_id, partner) self.assertEqual(self.answer.partner_id, partner)
def test_partner_id_is_not_overridden_by_second_contact_record_creation(self):
# When several res.partner record creations run on the same survey,
# partner_id/email must be corrected to match the first contact
# created, and the second contact created must not override that
# choice, even though the answer started with a wrong
# partner_id/email.
self.second_question_name = self._add_question(
page=None,
name="Name of second person",
qtype="char_box",
survey_id=self.survey.id,
sequence=1,
)
self.second_contact_creation = self.env["survey.record.creation"].create(
{
"name": "Contact 2",
"survey_id": self.survey.id,
"model_id": self.res_partner_model.id,
}
)
self.env["survey.record.creation.field.values"].create(
{
"survey_record_creation_id": self.second_contact_creation.id,
"survey_id": self.survey.id,
"model_id": self.res_partner_model.id,
"field_id": self.name_field.id,
"value_origin": "question",
"question_id": self.second_question_name.id,
}
)
wrong_partner = self.env["res.partner"].create({"name": "Wrong Partner"})
self.answer = self._add_answer(
survey=self.survey, partner=wrong_partner, email="wrong@test.fr"
)
self._add_answer_line(
question=self.question_name,
answer=self.answer,
answer_value="Jean",
)
self._add_answer_line(
question=self.second_question_name,
answer=self.answer,
answer_value="Jeanne",
)
self.answer._mark_done()
jean = self.env["res.partner"].search([("name", "=", "Jean")])
jeanne = self.env["res.partner"].search([("name", "=", "Jeanne")])
self.assertEqual(self.answer.partner_id, jean)
self.assertNotEqual(self.answer.partner_id, jeanne)
self.assertEqual(self.answer.email, jean.email)

View File

@@ -0,0 +1,109 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
====================================
Survey XLSX - Expand Multiple Choice
====================================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2ec53fabb2863ebd536f204a7cb4fa4833a634a5711a9e325ed64f50a4c3c4b6
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/gitea-Elabore%2Fsurvey--tools-lightgray.png
:target: https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice
:alt: Elabore/survey-tools
|badge1| |badge2| |badge3|
This module improves the **Survey Results XLSX export** provided by
``survey_xlsx`` for questions that can hold several answers.
By default such questions are exported as a single column containing every
selected value joined together, which is hard to analyse in a spreadsheet.
This module splits them into dedicated columns:
* **Multiple choice** questions (*multiple answers allowed*): one column per
possible answer, with ``Oui`` / ``Non`` as value.
* **Matrix** questions: one column per matrix row, with the selected option
as value.
.. warning::
This module relies on report extension hooks that are **not part of the
standard** ``survey_xlsx`` yet. They are introduced by this pull request:
https://github.com/elabore-coop/survey/pull/1
You must run a ``survey_xlsx`` that includes these hooks (the PR branch,
until it is merged upstream). Installed against a plain ``survey_xlsx``,
this module installs without error but the export **silently falls back**
to the default one-column-per-question behaviour.
**Table of contents**
.. contents::
:local:
Usage
=====
Export the results of a survey as usual:
#. Go to *Surveys* and open a survey.
#. Print the *Survey Results XLSX* report.
In the generated spreadsheet:
* A multiple choice question ``Favorite colors`` with options *Red*, *Green*
and *Blue* produces three columns ``Favorite colors / Red``,
``Favorite colors / Green`` and ``Favorite colors / Blue``, each containing
``Oui`` or ``Non``.
* A matrix question ``Satisfaction`` with rows *Dashboards* and *Customer
relationship* produces two columns ``Satisfaction / Dashboards`` and
``Satisfaction / Customer relationship``, each containing the selected
option (e.g. *Not satisfied at all*).
Other question types keep their standard single-column export.
Bug Tracker
===========
Bugs are tracked on `Gitea Issues <https://git.elabore.coop/Elabore/survey-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://git.elabore.coop/Elabore/survey-tools/issues/new?body=module:%20survey_xlsx_expand_multiple_choice%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Elabore
Contributors
~~~~~~~~~~~~
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot
Maintainers
~~~~~~~~~~~
This module is part of the `Elabore/survey-tools <https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice>`_ project on git.elabore.coop.
You are welcome to contribute.

View File

@@ -0,0 +1,3 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import report

View 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"], # WARNING : besoin des hooks créés dans cette PR pour fonctionner : https://github.com/elabore-coop/survey/pull/1
}

View File

@@ -0,0 +1,3 @@
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot

View File

@@ -0,0 +1,23 @@
This module improves the **Survey Results XLSX export** provided by
``survey_xlsx`` for questions that can hold several answers.
By default such questions are exported as a single column containing every
selected value joined together, which is hard to analyse in a spreadsheet.
This module splits them into dedicated columns:
* **Multiple choice** questions (*multiple answers allowed*): one column per
possible answer, with ``Oui`` / ``Non`` as value.
* **Matrix** questions: one column per matrix row, with the selected option
as value.
.. warning::
This module relies on report extension hooks that are **not part of the
standard** ``survey_xlsx`` yet. They are introduced by this pull request:
https://github.com/elabore-coop/survey/pull/1
You must run a ``survey_xlsx`` that includes these hooks (the PR branch,
until it is merged upstream). Installed against a plain ``survey_xlsx``,
this module installs without error but the export **silently falls back**
to the default one-column-per-question behaviour.

View File

@@ -0,0 +1,17 @@
Export the results of a survey as usual:
#. Go to *Surveys* and open a survey.
#. Print the *Survey Results XLSX* report.
In the generated spreadsheet:
* A multiple choice question ``Favorite colors`` with options *Red*, *Green*
and *Blue* produces three columns ``Favorite colors / Red``,
``Favorite colors / Green`` and ``Favorite colors / Blue``, each containing
``Oui`` or ``Non``.
* A matrix question ``Satisfaction`` with rows *Dashboards* and *Customer
relationship* produces two columns ``Satisfaction / Dashboards`` and
``Satisfaction / Customer relationship``, each containing the selected
option (e.g. *Not satisfied at all*).
Other question types keep their standard single-column export.

View 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

View File

@@ -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"]

View File

@@ -0,0 +1,466 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document">
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
</a>
<div class="section" id="survey-xlsx-expand-multiple-choice">
<h1>Survey XLSX - Expand Multiple Choice</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2ec53fabb2863ebd536f204a7cb4fa4833a634a5711a9e325ed64f50a4c3c4b6
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice"><img alt="elabore-coop/survey-tools" src="https://img.shields.io/badge/github-elabore--coop%2Fsurvey--tools-lightgray.png?logo=github" /></a></p>
<p>This module improves the <strong>Survey Results XLSX export</strong> provided by
<tt class="docutils literal">survey_xlsx</tt> for questions that can hold several answers.</p>
<p>By default such questions are exported as a single column containing every
selected value joined together, which is hard to analyse in a spreadsheet.
This module splits them into dedicated columns:</p>
<ul class="simple">
<li><strong>Multiple choice</strong> questions (<em>multiple answers allowed</em>): one column per
possible answer, with <tt class="docutils literal">Oui</tt> / <tt class="docutils literal">Non</tt> as value.</li>
<li><strong>Matrix</strong> questions: one column per matrix row, with the selected option
as value.</li>
</ul>
<div class="admonition warning">
<p class="first admonition-title">Warning</p>
<p>This module relies on report extension hooks that are <strong>not part of the
standard</strong> <tt class="docutils literal">survey_xlsx</tt> yet. They are introduced by this pull request:</p>
<p><a class="reference external" href="https://github.com/elabore-coop/survey/pull/1">https://github.com/elabore-coop/survey/pull/1</a></p>
<p class="last">You must run a <tt class="docutils literal">survey_xlsx</tt> that includes these hooks (the PR branch,
until it is merged upstream). Installed against a plain <tt class="docutils literal">survey_xlsx</tt>,
this module installs without error but the export <strong>silently falls back</strong>
to the default one-column-per-question behaviour.</p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-6">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
<p>Export the results of a survey as usual:</p>
<ol class="arabic simple">
<li>Go to <em>Surveys</em> and open a survey.</li>
<li>Print the <em>Survey Results XLSX</em> report.</li>
</ol>
<p>In the generated spreadsheet:</p>
<ul class="simple">
<li>A multiple choice question <tt class="docutils literal">Favorite colors</tt> with options <em>Red</em>, <em>Green</em>
and <em>Blue</em> produces three columns <tt class="docutils literal">Favorite colors / Red</tt>,
<tt class="docutils literal">Favorite colors / Green</tt> and <tt class="docutils literal">Favorite colors / Blue</tt>, each containing
<tt class="docutils literal">Oui</tt> or <tt class="docutils literal">Non</tt>.</li>
<li>A matrix question <tt class="docutils literal">Satisfaction</tt> with rows <em>Dashboards</em> and <em>Customer
relationship</em> produces two columns <tt class="docutils literal">Satisfaction / Dashboards</tt> and
<tt class="docutils literal">Satisfaction / Customer relationship</tt>, each containing the selected
option (e.g. <em>Not satisfied at all</em>).</li>
</ul>
<p>Other question types keep their standard single-column export.</p>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues/new?body=module:%20survey_xlsx_expand_multiple_choice%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-3">Credits</a></h2>
<div class="section" id="authors">
<h3><a class="toc-backref" href="#toc-entry-4">Authors</a></h3>
<ul class="simple">
<li>Elabore</li>
</ul>
</div>
<div class="section" id="contributors">
<h3><a class="toc-backref" href="#toc-entry-5">Contributors</a></h3>
<ul class="simple">
<li><a class="reference external" href="https://www.elabore.coop">Elabore</a><ul>
<li>Quentin Mondot</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h3><a class="toc-backref" href="#toc-entry-6">Maintainers</a></h3>
<p>This module is part of the <a class="reference external" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_expand_multiple_choice">elabore-coop/survey-tools</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
# Copyright 2025 Elabore
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_report

View 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)

View File

@@ -0,0 +1,87 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
=================================
Survey XLSX - Extra Fields Bridge
=================================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:8baa47af03e5b4b4e70ca6db224a5ac3e73aa66f287c42053a9a8f631efd10c2
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/gitea-Elabore%2Fsurvey--tools-lightgray.png
:target: https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_extra_fields
:alt: Elabore/survey-tools
|badge1| |badge2| |badge3|
This is a **bridge module** between ``survey_xlsx_expand_multiple_choice``
and ``survey_extra_fields``.
``survey_extra_fields`` adds a *File* question type, whose answers are
uploaded attachments that cannot be represented in a spreadsheet cell. This
module excludes those *File* questions from the **Survey Results XLSX
export**: they get no column at all, instead of an unusable one.
It installs automatically (``auto_install``) as soon as both
``survey_xlsx_expand_multiple_choice`` and ``survey_extra_fields`` are
installed, and is uninstalled when either of them is removed. There is
nothing to configure.
.. warning::
The exclusion is implemented through the report extension hooks provided
by ``survey_xlsx_expand_multiple_choice``, which itself relies on hooks
added to ``survey_xlsx`` by this pull request:
https://github.com/elabore-coop/survey/pull/1
Without those hooks, *File* questions are not filtered out of the export.
**Table of contents**
.. contents::
:local:
Bug Tracker
===========
Bugs are tracked on `Gitea Issues <https://git.elabore.coop/Elabore/survey-tools/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://git.elabore.coop/Elabore/survey-tools/issues/new?body=module:%20survey_xlsx_extra_fields%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Elabore
Contributors
~~~~~~~~~~~~
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot
Maintainers
~~~~~~~~~~~
This module is part of the `Elabore/survey-tools <https://git.elabore.coop/Elabore/survey-tools/tree/18.0/survey_xlsx_extra_fields>`_ project on git.elabore.coop.
You are welcome to contribute.

View File

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

View 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",
],
}

View File

@@ -0,0 +1,3 @@
* `Elabore <https://www.elabore.coop>`_
* Quentin Mondot

View File

@@ -0,0 +1,22 @@
This is a **bridge module** between ``survey_xlsx_expand_multiple_choice``
and ``survey_extra_fields``.
``survey_extra_fields`` adds a *File* question type, whose answers are
uploaded attachments that cannot be represented in a spreadsheet cell. This
module excludes those *File* questions from the **Survey Results XLSX
export**: they get no column at all, instead of an unusable one.
It installs automatically (``auto_install``) as soon as both
``survey_xlsx_expand_multiple_choice`` and ``survey_extra_fields`` are
installed, and is uninstalled when either of them is removed. There is
nothing to configure.
.. warning::
The exclusion is implemented through the report extension hooks provided
by ``survey_xlsx_expand_multiple_choice``, which itself relies on hooks
added to ``survey_xlsx`` by this pull request:
https://github.com/elabore-coop/survey/pull/1
Without those hooks, *File* questions are not filtered out of the export.

View File

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

View 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)

View File

@@ -0,0 +1,442 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document">
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
</a>
<div class="section" id="survey-xlsx-extra-fields-bridge">
<h1>Survey XLSX - Extra Fields Bridge</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:8baa47af03e5b4b4e70ca6db224a5ac3e73aa66f287c42053a9a8f631efd10c2
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_extra_fields"><img alt="elabore-coop/survey-tools" src="https://img.shields.io/badge/github-elabore--coop%2Fsurvey--tools-lightgray.png?logo=github" /></a></p>
<p>This is a <strong>bridge module</strong> between <tt class="docutils literal">survey_xlsx_expand_multiple_choice</tt>
and <tt class="docutils literal">survey_extra_fields</tt>.</p>
<p><tt class="docutils literal">survey_extra_fields</tt> adds a <em>File</em> question type, whose answers are
uploaded attachments that cannot be represented in a spreadsheet cell. This
module excludes those <em>File</em> questions from the <strong>Survey Results XLSX
export</strong>: they get no column at all, instead of an unusable one.</p>
<p>It installs automatically (<tt class="docutils literal">auto_install</tt>) as soon as both
<tt class="docutils literal">survey_xlsx_expand_multiple_choice</tt> and <tt class="docutils literal">survey_extra_fields</tt> are
installed, and is uninstalled when either of them is removed. There is
nothing to configure.</p>
<div class="admonition warning">
<p class="first admonition-title">Warning</p>
<p>The exclusion is implemented through the report extension hooks provided
by <tt class="docutils literal">survey_xlsx_expand_multiple_choice</tt>, which itself relies on hooks
added to <tt class="docutils literal">survey_xlsx</tt> by this pull request:</p>
<p><a class="reference external" href="https://github.com/elabore-coop/survey/pull/1">https://github.com/elabore-coop/survey/pull/1</a></p>
<p class="last">Without those hooks, <em>File</em> questions are not filtered out of the export.</p>
</div>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-1">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-2">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-3">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-4">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-5">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-1">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/elabore-coop/survey-tools/issues/new?body=module:%20survey_xlsx_extra_fields%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-2">Credits</a></h2>
<div class="section" id="authors">
<h3><a class="toc-backref" href="#toc-entry-3">Authors</a></h3>
<ul class="simple">
<li>Elabore</li>
</ul>
</div>
<div class="section" id="contributors">
<h3><a class="toc-backref" href="#toc-entry-4">Contributors</a></h3>
<ul class="simple">
<li><a class="reference external" href="https://www.elabore.coop">Elabore</a><ul>
<li>Quentin Mondot</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h3><a class="toc-backref" href="#toc-entry-5">Maintainers</a></h3>
<p>This module is part of the <a class="reference external" href="https://github.com/elabore-coop/survey-tools/tree/18.0/survey_xlsx_extra_fields">elabore-coop/survey-tools</a> project on GitHub.</p>
<p>You are welcome to contribute.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View File

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

View 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"))