diff --git a/.copier-answers.yml b/.copier-answers.yml new file mode 100644 index 0000000000..62a0f7b722 --- /dev/null +++ b/.copier-answers.yml @@ -0,0 +1,21 @@ +# Do NOT update manually; changes here will be overwritten by Copier +_commit: v1.14.2 +_src_path: https://github.com/OCA/oca-addons-repo-template.git +ci: GitHub +generate_requirements_txt: true +github_check_license: false +github_ci_extra_env: {} +github_enable_codecov: true +github_enable_makepot: true +github_enable_stale_action: true +github_enforce_dev_status_compatibility: false +include_wkhtmltopdf: false +odoo_version: 12.0 +org_name: Odoo Community Association (OCA) +org_slug: OCA +rebel_module_groups: [] +repo_description: Electronic Data Interchange modules +repo_name: Electronic Data Interchange modules +repo_slug: edi +repo_website: https://github.com/OCA/edi + diff --git a/.editorconfig b/.editorconfig index 62276b0d58..bfd7ac53df 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,11 +7,11 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[.eslintrc,*.{json,yml,yaml,rst,md}] +[*.{json,yml,yaml,rst,md}] indent_size = 2 # Do not configure editor for libs and autogenerated content -[*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst] +[{*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst}] charset = unset end_of_line = unset indent_size = unset diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000..6363964b54 --- /dev/null +++ b/.flake8 @@ -0,0 +1,11 @@ +[flake8] +# E123,E133,E226,E241,E242 are ignored by default by pep8 and flake8 +# F811 is legal in odoo 8 when we implement 2 interfaces for a method +# F601 pylint support this case with expected tests +# W503 changed by W504 and OCA prefers allow both +# E203: whitespace before ':' (black behaviour and not pep8 compliant) +ignore = E123,E133,E226,E241,E242,F811,F601,W503,W504,E203 +max-line-length = 88 +per-file-ignores= + __init__.py:F401 + diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000000..f86d418269 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,37 @@ +name: pre-commit + +on: + pull_request: + branches: + - "12.0*" + push: + branches: + - "12.0" + - "12.0-ocabot-*" + +jobs: + pre-commit: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: "3.6" + - name: Get python version + run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV + - uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Install pre-commit + run: pip install pre-commit + - name: Run pre-commit + run: pre-commit run --all-files --show-diff-on-failure --color=always + - name: Check that all files generated by pre-commit are in git + run: | + newfiles="$(git ls-files --others --exclude-from=.gitignore)" + if [ "$newfiles" != "" ] ; then + echo "Please check-in the following files:" + echo "$newfiles" + exit 1 + fi diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..1693a1253b --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,69 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "0 12 * * 0" + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - name: Stale PRs and issues policy + uses: actions/stale@v4 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # General settings. + ascending: true + remove-stale-when-updated: true + # Pull Requests settings. + # 120+30 day stale policy for PRs + # * Except PRs marked as "no stale" + days-before-pr-stale: 120 + days-before-pr-close: 30 + exempt-pr-labels: "no stale" + stale-pr-label: "stale" + stale-pr-message: > + There hasn't been any activity on this pull request in the past 4 months, so + it has been marked as stale and it will be closed automatically if no + further activity occurs in the next 30 days. + + If you want this PR to never become stale, please ask a PSC member to apply + the "no stale" label. + # Issues settings. + # 180+30 day stale policy for open issues + # * Except Issues marked as "no stale" + days-before-issue-stale: 180 + days-before-issue-close: 30 + exempt-issue-labels: "no stale,needs more information" + stale-issue-label: "stale" + stale-issue-message: > + There hasn't been any activity on this issue in the past 6 months, so it has + been marked as stale and it will be closed automatically if no further + activity occurs in the next 30 days. + + If you want this issue to never become stale, please ask a PSC member to + apply the "no stale" label. + + # 15+30 day stale policy for issues pending more information + # * Issues that are pending more information + # * Except Issues marked as "no stale" + - name: Needs more information stale issues policy + uses: actions/stale@v4 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + ascending: true + only-labels: "needs more information" + exempt-issue-labels: "no stale" + days-before-stale: 15 + days-before-close: 30 + days-before-pr-stale: -1 + days-before-pr-close: -1 + remove-stale-when-updated: true + stale-issue-label: "stale" + stale-issue-message: > + This issue needs more information and there hasn't been any activity + recently, so it has been marked as stale and it will be closed automatically + if no further activity occurs in the next 30 days. + + If you think this is a mistake, please ask a PSC member to remove the "needs + more information" label. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000000..562e73a1a4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,71 @@ +name: tests + +on: + pull_request: + branches: + - "12.0*" + push: + branches: + - "12.0" + - "12.0-ocabot-*" + +jobs: + unreleased-deps: + runs-on: ubuntu-latest + name: Detect unreleased dependencies + steps: + - uses: actions/checkout@v2 + - run: | + for reqfile in requirements.txt test-requirements.txt ; do + if [ -f ${reqfile} ] ; then + result=0 + # reject non-comment lines that contain a / (i.e. URLs, relative paths) + grep "^[^#].*/" ${reqfile} || result=$? + if [ $result -eq 0 ] ; then + echo "Unreleased dependencies found in ${reqfile}." + exit 1 + fi + fi + done + test: + runs-on: ubuntu-20.04 + container: ${{ matrix.container }} + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - container: ghcr.io/oca/oca-ci/py3.6-odoo12.0:latest + makepot: "true" + name: test with Odoo + - container: ghcr.io/oca/oca-ci/py3.6-ocb12.0:latest + name: test with OCB + services: + postgres: + image: postgres:9.6 + env: + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + POSTGRES_DB: odoo + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v2 + with: + persist-credentials: false + - name: Install addons and dependencies + run: oca_install_addons + - name: Check licenses + run: manifestoo -d . check-licenses + continue-on-error: true + - name: Check development status + run: manifestoo -d . check-dev-status --default-dev-status=Beta + continue-on-error: true + - name: Initialize test db + run: oca_init_test_database + - name: Run tests + run: oca_run_tests + - uses: codecov/codecov-action@v1 + - name: Update .pot files + run: oca_export_and_push_pot https://x-access-token:${{ secrets.GIT_PUSH_TOKEN }}@github.com/${{ github.repository }} + if: ${{ matrix.makepot == 'true' && github.event_name == 'push' && github.repository_owner == 'OCA' }} diff --git a/.gitignore b/.gitignore index f7f8a408be..9c283fd41f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] +/.venv +/.pytest_cache # C extensions *.so @@ -13,7 +15,6 @@ build/ develop-eggs/ dist/ eggs/ -lib/ lib64/ parts/ sdist/ @@ -21,6 +22,7 @@ var/ *.egg-info/ .installed.cfg *.egg +*.eggs # Installer logs pip-log.txt @@ -40,6 +42,19 @@ coverage.xml # Pycharm .idea +# Eclipse +.settings + +# Visual Studio cache/options directory +.vs/ +.vscode + +# OSX Files +.DS_Store + +# Django stuff: +*.log + # Mr Developer .mr.developer.cfg .project @@ -55,5 +70,5 @@ docs/_build/ *~ *.swp -# OSX Files -*.DS_Store +# OCA rules +!static/lib/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..cbc99efc86 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,59 @@ +exclude: | + (?x) + # NOT INSTALLABLE ADDONS + # END NOT INSTALLABLE ADDONS + # Files and folders generated by bots, to avoid loops + ^setup/|/static/description/index\.html$| + # We don't want to mess with tool-generated files + .svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/| + # Maybe reactivate this when all README files include prettier ignore tags? + ^README\.md$| + # Library files can have extraneous formatting (even minimized) + /static/(src/)?lib/| + # Repos using Sphinx to generate docs don't need prettying + ^docs/_templates/.*\.html$| + # You don't usually want a bot to modify your legal texts + (LICENSE.*|COPYING.*) +default_language_version: + python: python3.6 +repos: + - repo: https://github.com/oca/maintainer-tools + rev: ab1d7f6 + hooks: + # update the NOT INSTALLABLE ADDONS section above + - id: oca-update-pre-commit-excluded-addons + - id: oca-fix-manifest-website + args: ["https://github.com/OCA/edi"] + - repo: https://github.com/acsone/setuptools-odoo + rev: 3.1.8 + hooks: + - id: setuptools-odoo-make-default + - id: setuptools-odoo-get-requirements + args: + - --output + - requirements.txt + - --header + - "# generated from manifests external_dependencies" + - repo: https://github.com/OCA/mirrors-flake8 + rev: v3.4.1 + hooks: + - id: flake8 + name: flake8 excluding __init__.py + exclude: __init__\.py + - repo: https://github.com/pre-commit/mirrors-pylint + rev: v2.5.3 + hooks: + - id: pylint + name: pylint with optional checks + args: + - --rcfile=.pylintrc + - --exit-zero + verbose: true + additional_dependencies: &pylint_deps + - pylint-odoo==3.5.0 + - id: pylint + name: pylint with mandatory checks + args: + - --rcfile=.pylintrc-mandatory + additional_dependencies: *pylint_deps + diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000000..d8abd71292 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,156 @@ +[MASTER] +load-plugins=pylint_odoo +score=n + +[ODOOLINT] +readme_template_url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" +manifest_required_authors=Odoo Community Association (OCA) +manifest_required_keys=license +manifest_deprecated_keys=description,active +license_allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 +valid_odoo_versions=12.0 + +[MESSAGES CONTROL] +disable=all + +# Enable message and code: +# anomalous-backslash-in-string - W1401 +# assignment-from-none - W1111 +# dangerous-default-value - W0102 +# duplicate-key - W0109 +# missing-import-error - W7935 +# missing-manifest-dependency - W7936 +# pointless-statement - W0104 +# pointless-string-statement - W0105 +# print-statement - E1601 +# redundant-keyword-arg - E1124 +# reimported - W0404 +# relative-import - W0403 +# return-in-init - E0101 +# rst-syntax-error - E7901 +# too-few-format-args - E1306 +# unreachable - W0101 + + +# This .pylintrc contains optional AND mandatory checks and is meant to be +# loaded in an IDE to have it check everything, in the hope this will make +# optional checks more visible to contributors who otherwise never look at a +# green travis to see optional checks that failed. +# .pylintrc-mandatory containing only mandatory checks is used the pre-commit +# config as a blocking check. + +# Beta message and code: +# api-one-deprecated - W8104 +# api-one-multi-together - W8101 +# attribute-deprecated - W8105 +# class-camelcase - C8104 +# create-user-wo-reset-password - W7905 +# consider-merging-classes-inherited - R7980 +# copy-wo-api-one - W8102 +# dangerous-filter-wo-user - W7901 +# dangerous-view-replace-wo-priority - W7940 +# deprecated-module - W0402 +# duplicate-id-csv - W7906 +# duplicate-xml-fields - W7907 +# duplicate-xml-record-id - W7902 +# file-not-used - W7930 +# incoherent-interpreter-exec-perm - W8201 +# invalid-commit - E8102 +# javascript-lint - W7903 +# manifest-deprecated-key - C8103 +# method-compute - C8108 +# method-inverse - C8110 +# method-required-super - W8106 +# method-search - C8109 +# missing-newline-extrafiles - W7908 +# missing-readme - C7902 +# no-utf8-coding-comment - C8201 +# unnecessary-utf8-coding-comment - C8202 +# odoo-addons-relative-import - W7950 +# old-api7-method-defined - R8110 +# openerp-exception-warning - R8101 +# redundant-modulename-xml - W7909 +# sql-injection - E8103 +# too-complex - C0901 +# translation-field - W8103 +# translation-required - C8107 +# use-vim-comment - W8202 +# wrong-tabs-instead-of-spaces - W7910 +# xml-syntax-error - E7902 + + +enable=anomalous-backslash-in-string, + assignment-from-none, + dangerous-default-value, + development-status-allowed, + duplicate-key, + duplicate-po-message-definition, + missing-import-error, + missing-manifest-dependency, + po-msgstr-variables, + po-syntax-error, + pointless-statement, + pointless-string-statement, + print-used, + redundant-keyword-arg, + reimported, + relative-import, + return-in-init, + rst-syntax-error, + too-few-format-args, + unreachable, + eval-used, + eval-referenced, + license-allowed, + manifest-author-string, + manifest-required-author, + manifest-required-key, + manifest-version-format, + api-one-deprecated, + api-one-multi-together, + attribute-deprecated, + class-camelcase, + create-user-wo-reset-password, + consider-merging-classes-inherited, + copy-wo-api-one, + dangerous-filter-wo-user, + dangerous-view-replace-wo-priority, + deprecated-module, + duplicate-id-csv, + duplicate-po-message-definition, + duplicate-xml-fields, + duplicate-xml-record-id, + file-not-used, + incoherent-interpreter-exec-perm, + invalid-commit, + javascript-lint, + manifest-deprecated-key, + method-compute, + method-inverse, + method-required-super, + method-search, + missing-newline-extrafiles, + missing-readme, + po-msgstr-variables, + po-syntax-error, + no-utf8-coding-comment, + unnecessary-utf8-coding-comment, + odoo-addons-relative-import, + old-api7-method-defined, + openerp-exception-warning, + redefined-builtin, + redundant-modulename-xml, + sql-injection, + too-complex, + translation-field, + translation-required, + use-vim-comment, + wrong-tabs-instead-of-spaces, + xml-syntax-error, + + +[REPORTS] +msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} +output-format=colorized +reports=no + diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory new file mode 100644 index 0000000000..9b44956f4f --- /dev/null +++ b/.pylintrc-mandatory @@ -0,0 +1,68 @@ +[MASTER] +load-plugins=pylint_odoo +score=n + +[ODOOLINT] +readme_template_url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" +manifest_required_authors=Odoo Community Association (OCA) +manifest_required_keys=license +manifest_deprecated_keys=description,active +license_allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 +valid_odoo_versions=12.0 + +[MESSAGES CONTROL] +disable=all + +# Enable message and code: +# anomalous-backslash-in-string - W1401 +# assignment-from-none - W1111 +# dangerous-default-value - W0102 +# duplicate-key - W0109 +# missing-import-error - W7935 +# missing-manifest-dependency - W7936 +# pointless-statement - W0104 +# pointless-string-statement - W0105 +# print-statement - E1601 +# redundant-keyword-arg - E1124 +# reimported - W0404 +# relative-import - W0403 +# return-in-init - E0101 +# rst-syntax-error - E7901 +# too-few-format-args - E1306 +# unreachable - W0101 + + + +enable=anomalous-backslash-in-string, + assignment-from-none, + dangerous-default-value, + development-status-allowed, + duplicate-key, + duplicate-po-message-definition, + missing-import-error, + missing-manifest-dependency, + po-msgstr-variables, + po-syntax-error, + pointless-statement, + pointless-string-statement, + print-used, + redundant-keyword-arg, + reimported, + relative-import, + return-in-init, + rst-syntax-error, + too-few-format-args, + unreachable, + eval-used, + eval-referenced, + license-allowed, + manifest-author-string, + manifest-required-author, + manifest-required-key, + manifest-version-format + +[REPORTS] +msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} +output-format=colorized +reports=no + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1baaadf9fd..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -language: python -sudo: true -cache: - apt: true - directories: - - $HOME/.cache/pip - -python: - - "3.5" - -addons: - postgresql: "9.6" - apt: - packages: - - expect-dev # provides unbuffer utility - - python-lxml # because pip installation is slow - - python-simplejson - - python-serial - - python-yaml - - poppler-utils - -env: - global: - - VERSION="12.0" TESTS="0" LINT_CHECK="0" MAKEPOT="0" MQT_DEP="PIP" - - WKHTMLTOPDF_VERSION="0.12.5" - - matrix: - - LINT_CHECK="1" - - TESTS="1" ODOO_REPO="OCA/OCB" - - TESTS="1" ODOO_REPO="odoo/odoo" MAKEPOT="1" - - -install: - - git clone --depth=1 https://github.com/OCA/maintainer-quality-tools.git ${HOME}/maintainer-quality-tools - - export PATH=${HOME}/maintainer-quality-tools/travis:${PATH} - - travis_install_nightly - # what we have here is not compatible with Odoo's account_facturx which is autoinstall - - rm -fr ${HOME}/{odoo,OCB}-12.0/addons/account_facturx - -script: - - travis_run_tests - -after_success: - - travis_after_tests_success diff --git a/LICENSE b/LICENSE index 3ffc567893..be3f7b28e5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ -GNU AFFERO GENERAL PUBLIC LICENSE + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. @@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, @@ -643,7 +643,7 @@ the "copyright" line and a pointer to where the full notice is found. GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. @@ -658,4 +658,4 @@ specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see -. \ No newline at end of file +. diff --git a/README.md b/README.md index bed77394f8..c8076edcff 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,37 @@ -[![Runbot Status](https://runbot.odoo-community.org/runbot/badge/flat/226/9.0.svg)](https://runbot.odoo-community.org/runbot/repo/github-com-oca-edi-226) -[![Build Status](https://travis-ci.org/OCA/edi.svg?branch=9.0)](https://travis-ci.org/OCA/edi) -[![Coverage Status](https://coveralls.io/repos/OCA/edi/badge.svg?branch=9.0&service=github)](https://coveralls.io/github/OCA/edi?branch=9.0) -# EDI +[![Runboat](https://img.shields.io/badge/runboat-Try%20me-875A7B.png)](https://runboat.odoo-community.org/builds?repo=OCA/edi&target_branch=12.0) +[![Pre-commit Status](https://github.com/OCA/edi/actions/workflows/pre-commit.yml/badge.svg?branch=12.0)](https://github.com/OCA/edi/actions/workflows/pre-commit.yml?query=branch%3A12.0) +[![Build Status](https://github.com/OCA/edi/actions/workflows/test.yml/badge.svg?branch=12.0)](https://github.com/OCA/edi/actions/workflows/test.yml?query=branch%3A12.0) +[![codecov](https://codecov.io/gh/OCA/edi/branch/12.0/graph/badge.svg)](https://codecov.io/gh/OCA/edi) +[![Translation Status](https://translation.odoo-community.org/widgets/edi-12-0/-/svg-badge.svg)](https://translation.odoo-community.org/engage/edi-12-0/?utm_source=widget) -EDI Modules + +# Electronic Data Interchange modules +Electronic Data Interchange modules -Translation Status ------------------- -[![Transifex Status](https://www.transifex.com/projects/p/OCA-edi-9-0/chart/image_png)](https://www.transifex.com/projects/p/OCA-edi-9-0) + ----- + + +[//]: # (addons) + +This part will be replaced when running the oca-gen-addons-table script from OCA/maintainer-tools. + +[//]: # (end addons) + + -OCA, or the [Odoo Community Association](http://odoo-community.org/), is a nonprofit organization whose -mission is to support the collaborative development of Odoo features and -promote its widespread use. +## Licenses + +This repository is licensed under [AGPL-3.0](LICENSE). + +However, each module can have a totally different license, as long as they adhere to Odoo Community Association (OCA) +policy. Consult each module's `__manifest__.py` file, which contains a `license` key +that explains its license. + +---- +OCA, or the [Odoo Community Association](http://odoo-community.org/), is a nonprofit +organization whose mission is to support the collaborative development of Odoo features +and promote its widespread use. diff --git a/edi_oca/README.rst b/edi_oca/README.rst new file mode 100644 index 0000000000..9af41f347d --- /dev/null +++ b/edi_oca/README.rst @@ -0,0 +1,193 @@ +=== +EDI +=== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fedi-lightgray.png?logo=github + :target: https://github.com/OCA/edi/tree/14.0/edi_oca + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-14-0/edi-14-0-edi_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/226/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Base EDI backend. + +Provides following models: + +1. EDI Backend, to centralize configuration +2. EDI Backend Type, to classify EDI backends (eg: UBL, GS1, e-invoice, pick-yours) +3. EDI Exchange Type, to define file types of exchange +4. EDI Exchange Record, to define a record exchanged between systems + +Also define a mixin to be inherited by records that will generate EDIs + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +This module aims to provide an infrastructure to simplify interchangability of documents +between systems providing a configuration platform. +It will be inherited by other modules in order to define the proper implementations of +components. + +In order to define a new Exchange Record, we need to configure: + +* Backend Type +* Exchange Type +* Backend +* Components + +Component definition +~~~~~~~~~~~~~~~~~~~~ + +The component usage must be defined like `edi.{direction}.{kind}.{code}` where: + +* direction is `output` or `input` +* kind can be: `generate`, `send`, `check`, `process`, `receive` +* code is the `{backend type code}` or `{backend type code}.{exchange type code}` + +User EDI generation +~~~~~~~~~~~~~~~~~~~ + +On the exchange type, it might be possible to define a set of models, a domain and a +snippet of code. +After defining this fields, we will automatically see buttons on the view to generate +the exchange records. +This configuration is useful to define a way of generation managed by user. + + +Exchange type rules configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Exchange types can be further configured with rules. +You can use rules to: + +1. make buttons automatically appear in forms +2. define your own custom logic + +Go to an exchange type and go to the tab "Model rules". +There you can add one or more rule, one per model. +On each rule you can define a domain or a snippet to activate it. +In case of a "Form button" kind, if the domain and/ the snippet is/are satisfied, +a form btn will appear on the top of the form. +This button can be used by the end user to manually generate an exchange. +If there's more than a backend and the exchange type has not a backend set, +a wizard will appear asking to select a backend to be used for the exchange. + +In case of "Custom" kind, you'll have to define your own logic to do something. + +Usage +===== + +After certain operations or manual execution, Exchange records will be generated. +This Exchange records might be input records or outputs records. + +The change of state can be manually executed by the system or be managed through by +`ir.cron`. + +Output Exchange records +~~~~~~~~~~~~~~~~~~~~~~~ + +An output record is intended to be used for exchange information from Odoo to another +system. + +The flow of an output record should be: + +* Creation +* Generation of data +* Validation of data +* Sending data +* Validation of data processed properly by the other party + +Input Exchange records +~~~~~~~~~~~~~~~~~~~~~~ + +An input record is intended to be used for exchange information another system to odoo. + +The flow of an input record should be: + +* Creation +* Reception of data +* Checking data +* Processing data + +Known issues / Roadmap +====================== + +14.0.1.0.0 +~~~~~~~~~~ + +The module name has been changed from edi to edi_oca. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* ACSONE +* Creu Blanca +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi +* Enric Tobella + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk +.. |maintainer-etobella| image:: https://github.com/etobella.png?size=40px + :target: https://github.com/etobella + :alt: etobella + +Current `maintainers `__: + +|maintainer-simahawk| |maintainer-etobella| + +This module is part of the `OCA/edi `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_oca/__init__.py b/edi_oca/__init__.py new file mode 100644 index 0000000000..79dbb94082 --- /dev/null +++ b/edi_oca/__init__.py @@ -0,0 +1,3 @@ +from . import components +from . import models +from . import wizards diff --git a/edi_oca/__manifest__.py b/edi_oca/__manifest__.py new file mode 100644 index 0000000000..273c03850c --- /dev/null +++ b/edi_oca/__manifest__.py @@ -0,0 +1,46 @@ +# Copyright 2020 ACSONE +# Copyright 2021 Camptocamp +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "EDI", + "summary": """ + Define backends, exchange types, exchange records, + basic automation and views for handling EDI exchanges. + """, + "version": "12.0.1.20.1", + "website": "https://github.com/OCA/edi", + "development_status": "Beta", + "license": "LGPL-3", + "author": "ACSONE,Creu Blanca,Camptocamp,Odoo Community Association (OCA)", + "maintainers": ["simahawk", "etobella"], + "depends": [ + "base_edi", + "component_event", + "mail", + "base_sparse_field", + "queue_job", + ], + "external_dependencies": {"python": ["yaml"]}, + "data": [ + "wizards/edi_exchange_record_create_wiz.xml", + "data/cron.xml", + "data/sequence.xml", + "data/job_channel.xml", + "data/job_function.xml", + "security/res_groups.xml", + "security/ir_model_access.xml", + "views/edi_backend_views.xml", + "views/edi_backend_type_views.xml", + "views/edi_exchange_record_views.xml", + "views/edi_exchange_type_views.xml", + "views/edi_exchange_type_rule_views.xml", + "views/menuitems.xml", + "templates/exchange_chatter_msg.xml", + "templates/exchange_mixin_buttons.xml", + "templates/assets.xml", + ], + "qweb": ["static/src/xml/widget_edi.xml"], + "demo": ["demo/edi_backend_demo.xml"], +} diff --git a/edi_oca/components/__init__.py b/edi_oca/components/__init__.py new file mode 100644 index 0000000000..94e4ac1406 --- /dev/null +++ b/edi_oca/components/__init__.py @@ -0,0 +1,4 @@ +from . import base +from . import base_output +from . import base_input +from . import base_validate diff --git a/edi_oca/components/base.py b/edi_oca/components/base.py new file mode 100644 index 0000000000..1b4c1e6b06 --- /dev/null +++ b/edi_oca/components/base.py @@ -0,0 +1,72 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendComponentMixin(AbstractComponent): + """Generic mixin for all EDI components.""" + + _name = "edi.component.base.mixin" + _collection = "edi.backend" + _usage = None + _backend_type = None + _exchange_type = None + + def __init__(self, work_context): + super().__init__(work_context) + self.backend = work_context.backend + + @staticmethod + def _match_attrs(): + """Attributes to be used for matching this component. + + By default, match by backend and exchange type. + + NOTE: the class attribute must have an underscore, the name here not. + """ + return ("backend_type", "exchange_type") + + @classmethod + def _component_match(cls, work, usage=None, model_name=None, **kw): + """Override to customize match. + + Registry lookup filtered by usage and model_name when landing here. + Now, narrow match to `_match_attrs` attributes. + """ + match_attrs = cls._match_attrs() + if not any([kw.get(k) for k in match_attrs]): + # No attr to check + return True + + backend_type = kw.get("backend_type") + exchange_type = kw.get("exchange_type") + + if cls._backend_type and cls._exchange_type: + # They must match both + return ( + cls._backend_type == backend_type + and cls._exchange_type == exchange_type + ) + + if cls._backend_type not in (None, kw.get("backend_type")): + return False + + if cls._exchange_type not in (None, kw.get("exchange_type")): + return False + + return True + + +class EDIBackendRecordComponentMixin(AbstractComponent): + """Generic mixin for record-bound components.""" + + _name = "edi.component.mixin" + _inherit = "edi.component.base.mixin" + + def __init__(self, work_context): + super().__init__(work_context) + self.exchange_record = work_context.exchange_record + self.record = self.exchange_record.record + self.type_settings = self.exchange_record.type_id.get_settings() diff --git a/edi_oca/components/base_input.py b/edi_oca/components/base_input.py new file mode 100644 index 0000000000..e5436ae4c0 --- /dev/null +++ b/edi_oca/components/base_input.py @@ -0,0 +1,24 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendInputComponentMixin(AbstractComponent): + """Generate input content.""" + + _name = "edi.component.input.mixin" + _inherit = "edi.component.mixin" + + def process(self): + raise NotImplementedError() + + +class EDIBackendReceiveComponentMixin(AbstractComponent): + + _name = "edi.component.receive.mixin" + _inherit = "edi.component.mixin" + + def receive(self): + raise NotImplementedError() diff --git a/edi_oca/components/base_output.py b/edi_oca/components/base_output.py new file mode 100644 index 0000000000..30cfe06cc3 --- /dev/null +++ b/edi_oca/components/base_output.py @@ -0,0 +1,37 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendOutputComponentMixin(AbstractComponent): + """Generate output content.""" + + _name = "edi.component.output.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.output.generate.*" + + def generate(self): + raise NotImplementedError() + + +class EDIBackendSendComponentMixin(AbstractComponent): + """Send output records.""" + + _name = "edi.component.send.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.output.send.*" + + def send(self): + raise NotImplementedError() + + +class EDIBackendCheckComponentMixin(AbstractComponent): + + _name = "edi.component.check.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.output.check.*" + + def check(self): + raise NotImplementedError() diff --git a/edi_oca/components/base_validate.py b/edi_oca/components/base_validate.py new file mode 100644 index 0000000000..324f0401ad --- /dev/null +++ b/edi_oca/components/base_validate.py @@ -0,0 +1,20 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo.addons.component.core import AbstractComponent + + +class EDIBackendValidateComponentMixin(AbstractComponent): + """Validate exchange data.""" + + _name = "edi.component.validate.mixin" + _inherit = "edi.component.mixin" + _usage = "edi.validate.*" + + def validate(self, value=None): + self._validate(value) + + def _validate(self, value=None): + """Return None validated, raise `edi.exceptions.EDIValidationError` if not.""" + raise NotImplementedError() diff --git a/edi_oca/data/cron.xml b/edi_oca/data/cron.xml new file mode 100644 index 0000000000..e182b10acd --- /dev/null +++ b/edi_oca/data/cron.xml @@ -0,0 +1,36 @@ + + + + EDI exchange check output sync + + + 1 + hours + -1 + + + code + model.search([])._cron_check_output_exchange_sync() + + + + EDI exchange check input sync + + + 1 + hours + -1 + + + code + model.search([])._cron_check_input_exchange_sync() + + diff --git a/edi_oca/data/job_channel.xml b/edi_oca/data/job_channel.xml new file mode 100644 index 0000000000..7718ea76f1 --- /dev/null +++ b/edi_oca/data/job_channel.xml @@ -0,0 +1,10 @@ + + + edi + + + + edi_exchange + + + diff --git a/edi_oca/data/job_function.xml b/edi_oca/data/job_function.xml new file mode 100644 index 0000000000..335ed11ff2 --- /dev/null +++ b/edi_oca/data/job_function.xml @@ -0,0 +1,48 @@ + + + + action_exchange_generate + + + + + action_exchange_send + + + + + action_exchange_receive + + + + + action_exchange_process + + + + + exchange_create_ack_record + + + + + + exchange_generate + + + + + exchange_send + + + + + exchange_receive + + + + + exchange_process + + + diff --git a/edi_oca/data/sequence.xml b/edi_oca/data/sequence.xml new file mode 100644 index 0000000000..37eb198932 --- /dev/null +++ b/edi_oca/data/sequence.xml @@ -0,0 +1,11 @@ + + + + + EDI Exchange Record + edi.exchange + EDI/%(year)s/ + 10 + + + diff --git a/edi_oca/demo/edi_backend_demo.xml b/edi_oca/demo/edi_backend_demo.xml new file mode 100644 index 0000000000..4e69bf8111 --- /dev/null +++ b/edi_oca/demo/edi_backend_demo.xml @@ -0,0 +1,11 @@ + + + + Demo EDI backend type + demo_backend + + + Demo EDI backend + + + diff --git a/edi_oca/exceptions.py b/edi_oca/exceptions.py new file mode 100644 index 0000000000..b9a39b3ff9 --- /dev/null +++ b/edi_oca/exceptions.py @@ -0,0 +1,7 @@ +# Copyright 2020 ACSONE +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +class EDIValidationError(Exception): + """Thrown when a document validation fails.""" diff --git a/edi_oca/i18n/edi_oca.pot b/edi_oca/i18n/edi_oca.pot new file mode 100644 index 0000000000..9b787287a1 --- /dev/null +++ b/edi_oca/i18n/edi_oca.pot @@ -0,0 +1,1288 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * edi_oca +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \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: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_exchange_type__advanced_settings_edit +msgid "" +"\n" +" Advanced technical settings as YAML format.\n" +" The YAML structure should reproduce a dictionary.\n" +" The backend might use these settings for automated operations.\n" +"\n" +" Currently supported conf:\n" +"\n" +" components:\n" +" generate:\n" +" usage: $comp_usage\n" +" # set a value for component work context\n" +" work_ctx:\n" +" opt1: True\n" +" validate:\n" +" usage: $comp_usage\n" +" env_ctx:\n" +" # set a value for the whole processing env\n" +" opt2: False\n" +" check:\n" +" usage: $comp_usage\n" +" send:\n" +" usage: $comp_usage\n" +" receive:\n" +" usage: $comp_usage\n" +" process:\n" +" usage: $comp_usage\n" +"\n" +" filename_pattern:\n" +" force_tz: Europe/Rome\n" +" date_pattern: %Y-%m-%d-%H-%M-%S\n" +"\n" +" In any case, you can use these settings\n" +" to provide your own configuration for whatever need you might have.\n" +" " +msgstr "" + +#. module: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_backend__output_sent_processed_auto +msgid "" +"\n" +" Automatically set the record as processed after sending.\n" +" Usecase: the web service you send the file to processes it on the fly.\n" +" " +msgstr "" + +#. module: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_exchange_type_rule__kind +msgid "" +"\n" +"* Form button: show a button on the related model form\n" +" when conditions from domain and snippet are satisfied\n" +"\n" +"* Custom: let devs handle a custom behavior with specific developments\n" +msgstr "" + +#. module: edi_oca +#: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_consumer_mixin_buttons +msgid " EDI actions" +msgstr "" + +#. module: edi_oca +#: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_record_view_form +msgid "" +"\n" +"Language-Team: none\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.3.2\n" + +#. module: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_exchange_type__advanced_settings_edit +msgid "" +"\n" +" Advanced technical settings as YAML format.\n" +" The YAML structure should reproduce a dictionary.\n" +" The backend might use these settings for automated operations.\n" +"\n" +" Currently supported conf:\n" +"\n" +" components:\n" +" generate:\n" +" usage: $comp_usage\n" +" # set a value for component work context\n" +" work_ctx:\n" +" opt1: True\n" +" validate:\n" +" usage: $comp_usage\n" +" env_ctx:\n" +" # set a value for the whole processing env\n" +" opt2: False\n" +" check:\n" +" usage: $comp_usage\n" +" send:\n" +" usage: $comp_usage\n" +" receive:\n" +" usage: $comp_usage\n" +" process:\n" +" usage: $comp_usage\n" +"\n" +" filename_pattern:\n" +" force_tz: Europe/Rome\n" +" date_pattern: %Y-%m-%d-%H-%M-%S\n" +"\n" +" In any case, you can use these settings\n" +" to provide your own configuration for whatever need you might " +"have.\n" +" " +msgstr "" + +#. module: edi_oca +#: model:ir.model.fields,help:edi_oca.field_edi_backend__output_sent_processed_auto +msgid "" +"\n" +" Automatically set the record as processed after sending.\n" +" Usecase: the web service you send the file to processes it on the fly.\n" +" " +msgstr "" + +#. module: edi_oca +#: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_consumer_mixin_buttons +msgid "This record has EDI exchanges to handle" +msgstr "" + +#. module: edi_oca +#: model_terms:ir.ui.view,arch_db:edi_oca.edi_exchange_record_view_form +msgid "" +"The record has some pending EDIs to be " +#~ "generated" +#~ msgstr "" +#~ "L'enregistrement a des EDIs en attente d'être " +#~ "générés" + +#~ msgid "Expected Edi Configuration" +#~ msgstr "Configuration Edi attendue" + +#~ msgid "File %s processed successfully " +#~ msgstr "Échange traité avec succès " + +#~ msgid "Ack Exchange" +#~ msgstr "Ack Exchange" + +#~ msgid "Ack for this exchange" +#~ msgstr "Ack pour cet échange" + +#~ msgid "Configuration" +#~ msgstr "Configuration" diff --git a/edi_oca/migrations/14.0.1.10.0/post-migrate.py b/edi_oca/migrations/14.0.1.10.0/post-migrate.py new file mode 100644 index 0000000000..2fb55e8bff --- /dev/null +++ b/edi_oca/migrations/14.0.1.10.0/post-migrate.py @@ -0,0 +1,19 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + + env = api.Environment(cr, SUPERUSER_ID, {}) + # For backward compat, enable buttons on all exc types that had a model conf. + domain = [("model_ids", "!=", False), ("model_manual_btn", "=", False)] + env["edi.exchange.type"].search(domain).write({"model_manual_btn": True}) + _logger.info("Activate model manual button on existing `edi.exchange.type`") diff --git a/edi_oca/migrations/14.0.1.20.0/post-migrate.py b/edi_oca/migrations/14.0.1.20.0/post-migrate.py new file mode 100644 index 0000000000..8c8b6a8b91 --- /dev/null +++ b/edi_oca/migrations/14.0.1.20.0/post-migrate.py @@ -0,0 +1,35 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import SUPERUSER_ID, api, tools + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + + bkp_table = "exc_type_model_rel_bkp" + if not tools.sql.table_exists(cr, bkp_table): + return + + # Use backup table (created by pre-migrate step) to create type rules + env = api.Environment(cr, SUPERUSER_ID, {}) + query = """ + SELECT * FROM exc_type_model_rel_bkp + """ + cr.execute(query) + res = cr.dictfetchall() + model = env["edi.exchange.type.rule"] + for item in res: + kind = "form_btn" if item.pop("form_btn", False) else "custom" + vals = dict(item, name="Default", kind=kind) + rec = model.create(vals) + rec.type_id.button_wipe_deprecated_rule_fields() + + cr.execute("DROP TABLE exc_type_model_rel_bkp") + _logger.info("edi.exchange.type.rule created") diff --git a/edi_oca/migrations/14.0.1.20.0/pre-migrate.py b/edi_oca/migrations/14.0.1.20.0/pre-migrate.py new file mode 100644 index 0000000000..61419f1d88 --- /dev/null +++ b/edi_oca/migrations/14.0.1.20.0/pre-migrate.py @@ -0,0 +1,42 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging + +from odoo import tools + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + + # Backup old style rules to be used later on post migrate + old_table = "edi_exchange_type_ir_model_rel" + if not tools.sql.table_exists(cr, old_table): + return + bkp_table = "exc_type_model_rel_bkp" + if tools.sql.table_exists(cr, bkp_table): + return + + bkp_query = """ + CREATE TABLE IF NOT EXISTS + exc_type_model_rel_bkp + AS + SELECT + rel.ir_model_id as model_id, + type.id as type_id, + type.enable_domain as enable_domain, + type.enable_snippet as enable_snippet, + type.model_manual_btn as form_btn + FROM + edi_exchange_type type, + edi_exchange_type_ir_model_rel rel + WHERE + rel.edi_exchange_type_id = type.id; + """ + cr.execute(bkp_query) + + _logger.info("edi.exchange.type old style rules backed up") diff --git a/edi_oca/models/__init__.py b/edi_oca/models/__init__.py new file mode 100644 index 0000000000..f40b0abe1e --- /dev/null +++ b/edi_oca/models/__init__.py @@ -0,0 +1,7 @@ +from . import edi_backend +from . import edi_backend_type +from . import edi_exchange_record +from . import edi_exchange_consumer_mixin +from . import edi_exchange_type +from . import edi_exchange_type_rule +from . import edi_id_mixin diff --git a/edi_oca/models/edi_backend.py b/edi_oca/models/edi_backend.py new file mode 100644 index 0000000000..77686353da --- /dev/null +++ b/edi_oca/models/edi_backend.py @@ -0,0 +1,649 @@ +# Copyright 2020 ACSONE SA +# Copyright 2020 Creu Blanca +# Copyright 2021 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +import base64 +import logging +import traceback +from io import StringIO + +from odoo import _, exceptions, fields, models, tools + +from odoo.addons.component.exception import NoComponentError + +from ..exceptions import EDIValidationError + +_logger = logging.getLogger(__name__) + + +def _get_exception_msg(): + buff = StringIO() + traceback.print_exc(file=buff) + traceback_txt = buff.getvalue() + buff.close() + return traceback_txt + + +class EDIBackend(models.Model): + """Generic backend to control EDI exchanges. + + Backends can be organized with types. + + The backend should be responsible for managing records. + For each record it can generate or parse their values + depending on their direction (incoming, outgoing) + and send or receive them automatically depending on their state. + """ + + _name = "edi.backend" + _description = "EDI Backend" + _inherit = ["collection.base"] + + name = fields.Char(required=True) + backend_type_id = fields.Many2one( + string="EDI Backend type", + comodel_name="edi.backend.type", + required=True, + ondelete="restrict", + ) + output_sent_processed_auto = fields.Boolean( + help=""" + Automatically set the record as processed after sending. + Usecase: the web service you send the file to processes it on the fly. + """ + ) + active = fields.Boolean(default=True) + + def _get_component(self, exchange_record, key): + record_conf = self._get_component_conf_for_record(exchange_record, key) + # Load additional ctx keys if any + collection = self + # TODO: document/test this + env_ctx = self._get_component_env_ctx(record_conf, key) + collection = collection.with_context(**env_ctx) + exchange_record = exchange_record.with_context(**env_ctx) + work_ctx = {"exchange_record": exchange_record} + # Inject work context from advanced settings + work_ctx.update(record_conf.get("work_ctx", {})) + # Model is not granted to be there + model = exchange_record.model or self._name + candidates = self._get_component_usage_candidates(exchange_record, key) + match_attrs = self._component_match_attrs(exchange_record, key) + return collection._find_component( + model, + candidates, + work_ctx=work_ctx, + **match_attrs, + ) + + def _get_component_env_ctx(self, record_conf, key): + env_ctx = record_conf.get("env_ctx", {}) + # You can use `edi_session` down in the stack to control logics. + env_ctx.update(dict(edi_framework_action=key)) + return env_ctx + + def _component_match_attrs(self, exchange_record, key): + """Attributes that will be used to lookup components. + + They will be set in the work context and propagated to components. + """ + return { + "backend_type": self.backend_type_id.code, + "exchange_type": exchange_record.type_id.code, + } + + def _component_sort_key(self, component_class): + """Determine the order of matched components. + + The order can be very important if your implementation + allow generic / default components to be registered. + """ + return ( + 1 if component_class._backend_type else 0, + 1 if component_class._exchange_type else 0, + ) + + def _find_component(self, model, usage_candidates, safe=True, work_ctx=None, **kw): + """Retrieve component for current backend. + + :param usage_candidates: + list of usage to try by priority. 1st found, 1st returned + :param safe: boolean, if true does not break if component is not found + :param work_ctx: dictionary with work context params + :param kw: keyword args to lookup for components (eg: usage) + """ + component = None + work_ctx = work_ctx or {} + if "backend" not in work_ctx: + work_ctx["backend"] = self + with self.work_on(model, **work_ctx) as work: + for usage in usage_candidates: + components, c_work_ctx = work._matching_components(usage=usage, **kw) + if not components: + continue + # Sort components and pick the 1st one matching. + # In this way we support generic components registration + # and specific components registrations + components = sorted( + components, key=lambda x: self._component_sort_key(x), reverse=True + ) + component = components[0](c_work_ctx) + _logger.debug("using component", component._name) + break + if not component and not safe: + raise NoComponentError( + "No component found matching any of: {}".format(usage_candidates) + ) + return component or None + + def _get_component_usage_candidates(self, exchange_record, key): + """Retrieve usage candidates for components.""" + # fmt:off + base_usage = ".".join([ + exchange_record.direction, + key, + ]) + # fmt:on + record_conf = self._get_component_conf_for_record(exchange_record, key) + candidates = [record_conf["usage"]] if record_conf else [] + candidates += [ + base_usage, + ] + return candidates + + def _get_component_conf_for_record(self, exchange_record, key): + settings = exchange_record.type_id.get_settings() + return settings.get("components", {}).get(key, {}) + + @property + def exchange_record_model(self): + return self.env["edi.exchange.record"] + + def create_record(self, type_code, values): + """Create an exchange record for current backend. + + :param type_code: edi.exchange.type code + :param values: edi.exchange.record values + :return: edi.exchange.record record + """ + self.ensure_one() + _values = self._create_record_prepare_values(type_code, values) + return self.exchange_record_model.create(_values) + + def _create_record_prepare_values(self, type_code, values): + res = values.copy() # do not pollute original dict + exchange_type = self.env["edi.exchange.type"].search( + self._get_exchange_type_domain(type_code), limit=1 + ) + assert exchange_type, "Exchange type not found: {}".format(type_code) + res["type_id"] = exchange_type.id + res["backend_id"] = self.id + return res + + def _get_exchange_type_domain(self, code): + return [ + ("code", "=", code), + "|", + ("backend_id", "=", self.id), + "&", + ("backend_type_id", "=", self.backend_type_id.id), + ("backend_id", "=", False), + ] + + def _delay_action(self, rec): + # TODO: Remove this on 16.0 + _logger.warning( + "This function has been replaced by rec.with_delay(). " + "It will be removed on 16.0." + ) + return self.with_delay(**rec._job_delay_params()) + + def exchange_generate(self, exchange_record, store=True, force=False, **kw): + """Generate output content for given exchange record. + + :param exchange_record: edi.exchange.record recordset + :param store: store output on the record itself + :param force: allow to re-genetate the content + :param kw: keyword args to be propagated to output generate handler + """ + self.ensure_one() + self._check_exchange_generate(exchange_record, force=force) + output = self._exchange_generate(exchange_record, **kw) + message = None + if output and store: + if not isinstance(output, bytes): + output = output.encode() + exchange_record.update( + { + "exchange_file": base64.b64encode(output), + "edi_exchange_state": "output_pending", + } + ) + try: + # TODO: Remove this on 15.0, we will keep it in order to not break current + # installations + output = tools.pycompat.to_text(output) + except UnicodeDecodeError: + pass + if output: + try: + self._validate_data(exchange_record, output) + except EDIValidationError: + error = _get_exception_msg() + state = "validate_error" + message = exchange_record._exchange_status_message("validate_ko") + exchange_record.update( + {"edi_exchange_state": state, "exchange_error": error} + ) + exchange_record.notify_action_complete("generate", message=message) + return output + + def _check_exchange_generate(self, exchange_record, force=False): + exchange_record.ensure_one() + if ( + exchange_record.edi_exchange_state != "new" + and exchange_record.exchange_file + and not force + ): + raise exceptions.UserError( + _( + "Exchange record ID=%d is not in draft state " + "and has already an output value." + ) + % exchange_record.id + ) + if exchange_record.direction != "output": + raise exceptions.UserError( + _( + "Exchange record ID=%d is not an outgoing record, " + "cannot be generated" + ) + % exchange_record.id + ) + if exchange_record.exchange_file: + raise exceptions.UserError( + _("Exchange record ID=%d already has a file to process!") + % exchange_record.id + ) + + def _exchange_generate(self, exchange_record, **kw): + component = self._get_component(exchange_record, "generate") + if component: + return component.generate() + raise NotImplementedError("No handler for `_exchange_generate`") + + # TODO: add tests + def _validate_data(self, exchange_record, value=None, **kw): + component = self._get_component(exchange_record, "validate") + if component: + return component.validate(value) + + def exchange_send(self, exchange_record): + """Send exchange file.""" + self.ensure_one() + exchange_record.ensure_one() + # In case already sent: skip sending and check the state + check = self._output_check_send(exchange_record) + if not check: + return False + state = exchange_record.edi_exchange_state + error = False + message = None + try: + self._exchange_send(exchange_record) + except self._swallable_exceptions(): + if self.env.context.get("_edi_send_break_on_error"): + raise + error = _get_exception_msg() + state = "output_error_on_send" + message = exchange_record._exchange_status_message("send_ko") + res = False + else: + # TODO: maybe the send handler should return desired message and state + message = exchange_record._exchange_status_message("send_ok") + error = None + state = ( + "output_sent_and_processed" + if self.output_sent_processed_auto + else "output_sent" + ) + res = True + finally: + exchange_record.write( + { + "edi_exchange_state": state, + "exchange_error": error, + # FIXME: this should come from _compute_exchanged_on + # but somehow it's failing in send tests (in record tests it works). + "exchanged_on": fields.Datetime.now(), + } + ) + exchange_record.notify_action_complete("send", message=message) + return res + + def _swallable_exceptions(self): + # TODO: improve this list + return ( + ValueError, + FileNotFoundError, + exceptions.UserError, + exceptions.ValidationError, + ) + + def _output_check_send(self, exchange_record): + if exchange_record.direction != "output": + raise exceptions.UserError( + _("Record ID=%d is not meant to be sent!") % exchange_record.id + ) + if not exchange_record.exchange_file: + raise exceptions.UserError( + _("Record ID=%d has no file to send!") % exchange_record.id + ) + return exchange_record.edi_exchange_state in [ + "output_pending", + "output_error_on_send", + ] + + def _exchange_send(self, exchange_record): + component = self._get_component(exchange_record, "send") + if component: + return component.send() + raise NotImplementedError("No handler for `_exchange_send`") + + def _cron_check_output_exchange_sync(self, **kw): + for backend in self: + backend._check_output_exchange_sync(**kw) + + # TODO: consider splitting cron in 2 (1 for receiving, 1 for processing) + def _check_output_exchange_sync( + self, skip_send=False, skip_sent=True, record_ids=None + ): + """Lookup for pending output records and take care of them. + + First work on records that need output generation. + Then work on records waiting for a state update. + + :param skip_send: only generate missing output. + :param skip_sent: ignore records that were already sent. + """ + # Generate output files + new_records = self.exchange_record_model.search( + self._output_new_records_domain(record_ids=record_ids) + ) + _logger.info( + "EDI Exchange output sync: found %d new records to process.", + len(new_records), + ) + for rec in new_records: + rec.with_delay().action_exchange_generate() + + if skip_send: + return + pending_records = self.exchange_record_model.search( + self._output_pending_records_domain( + skip_sent=skip_sent, record_ids=record_ids + ) + ) + _logger.info( + "EDI Exchange output sync: found %d pending records to process.", + len(pending_records), + ) + for rec in pending_records: + if rec.edi_exchange_state == "output_pending": + rec.with_delay().action_exchange_send() + else: + # TODO: run in job as well? + self._exchange_output_check_state(rec) + + def _output_new_records_domain(self, record_ids=None): + """Domain for output records needing output content generation.""" + domain = [ + ("backend_id", "=", self.id), + ("type_id.exchange_file_auto_generate", "=", True), + ("type_id.direction", "=", "output"), + ("edi_exchange_state", "=", "new"), + ("exchange_file", "=", False), + ] + if record_ids: + domain.append(("id", "in", record_ids)) + return domain + + def _output_pending_records_domain(self, skip_sent=True, record_ids=None): + """Domain for pending output records. + + Records might be waiting to be sent or have errors or have ack to handle.""" + states = ("output_pending", "output_sent_and_error") + if not skip_sent: + # If you want to update sent records + # you'll have to provide a `check` component. + states += ("output_sent",) + domain = [ + ("type_id.direction", "=", "output"), + ("backend_id", "=", self.id), + ("edi_exchange_state", "in", states), + ] + if record_ids: + domain.append(("id", "in", record_ids)) + return domain + + def _exchange_output_check_state(self, exchange_record): + component = self._get_component(exchange_record, "check") + if component: + return component.check() + raise NotImplementedError("No handler for `_exchange_output_check_state`") + + def _exchange_process_check(self, exchange_record): + if not exchange_record.direction == "input": + raise exceptions.UserError( + _("Record ID=%d is not meant to be processed") % exchange_record.id + ) + if not exchange_record.exchange_file: + raise exceptions.UserError( + _("Record ID=%d has no file to process!") % exchange_record.id + ) + return exchange_record.edi_exchange_state in [ + "input_received", + "input_processed_error", + ] + + def exchange_process(self, exchange_record): + """Process an incoming document.""" + self.ensure_one() + exchange_record.ensure_one() + # In case already processed: skip processing and check the state + check = self._exchange_process_check(exchange_record) + if not check: + return False + old_state = state = exchange_record.edi_exchange_state + error = False + message = None + try: + self._exchange_process(exchange_record) + except self._swallable_exceptions(): + if self.env.context.get("_edi_process_break_on_error"): + raise + error = _get_exception_msg() + state = "input_processed_error" + res = False + else: + error = None + state = "input_processed" + res = True + finally: + exchange_record.write( + { + "edi_exchange_state": state, + "exchange_error": error, + } + ) + if state == "input_processed_error" and old_state != "input_processed_error": + exchange_record._notify_error("process_ko") + elif state == "input_processed": + exchange_record._notify_done() + exchange_record.notify_action_complete("process", message=message) + return res + + def _exchange_process(self, exchange_record): + component = self._get_component(exchange_record, "process") + if component: + return component.process() + raise NotImplementedError() + + def exchange_receive(self, exchange_record): + """Retrieve an incoming document.""" + self.ensure_one() + exchange_record.ensure_one() + # In case already processed: skip processing and check the state + check = self._exchange_receive_check(exchange_record) + if not check: + return False + state = exchange_record.edi_exchange_state + error = False + message = None + content = None + try: + content = self._exchange_receive(exchange_record) + if content: + exchange_record._set_file_content(content) + self._validate_data(exchange_record) + except EDIValidationError: + error = _get_exception_msg() + state = "validate_error" + message = exchange_record._exchange_status_message("validate_ko") + res = False + except self._swallable_exceptions(): + if self.env.context.get("_edi_receive_break_on_error"): + raise + error = _get_exception_msg() + state = "input_receive_error" + message = exchange_record._exchange_status_message("receive_ko") + res = False + else: + message = exchange_record._exchange_status_message("receive_ok") + error = None + state = "input_received" + res = True + finally: + exchange_record.write( + { + "edi_exchange_state": state, + "exchange_error": error, + # FIXME: this should come from _compute_exchanged_on + # but somehow it's failing in send tests (in record tests it works). + "exchanged_on": fields.Datetime.now(), + } + ) + exchange_record.notify_action_complete("receive", message=message) + return res + + def _exchange_receive_check(self, exchange_record): + # TODO: use `filtered_domain` + _input_pending_records_domain + # and raise one single error + # do the same for all the other check cases. + if not exchange_record.direction == "input": + raise exceptions.UserError( + _("Record ID=%d is not meant to be processed") % exchange_record.id + ) + return exchange_record.edi_exchange_state in [ + "input_pending", + "input_receive_error", + ] + + def _exchange_receive(self, exchange_record): + component = self._get_component(exchange_record, "receive") + if component: + return component.receive() + raise NotImplementedError() + + def _cron_check_input_exchange_sync(self, **kw): + for backend in self: + backend._check_input_exchange_sync(**kw) + + # TODO: add tests + # TODO: consider splitting cron in 2 (1 for receiving, 1 for processing) + def _check_input_exchange_sync(self, record_ids=None, **kw): + """Lookup for pending input records and take care of them. + + First work on records that need to receive input. + Then work on records waiting to be processed. + """ + pending_records = self.exchange_record_model.search( + self._input_pending_records_domain(record_ids=record_ids) + ) + _logger.info( + "EDI Exchange input sync: found %d pending records to receive.", + len(pending_records), + ) + for rec in pending_records: + rec.with_delay().action_exchange_receive() + + pending_process_records = self.exchange_record_model.search( + self._input_pending_process_records_domain(record_ids=record_ids) + ) + _logger.info( + "EDI Exchange input sync: found %d pending records to process.", + len(pending_process_records), + ) + for rec in pending_process_records: + rec.with_delay().action_exchange_process() + + def _input_pending_records_domain(self, record_ids=None): + domain = [ + ("backend_id", "=", self.id), + ("type_id.direction", "=", "input"), + ("edi_exchange_state", "=", "input_pending"), + ("exchange_file", "=", False), + ] + if record_ids: + domain.append(("id", "in", record_ids)) + return domain + + def _input_pending_process_records_domain(self, record_ids=None): + states = ("input_received", "input_processed_error") + domain = [ + ("backend_id", "=", self.id), + ("type_id.direction", "=", "input"), + ("edi_exchange_state", "in", states), + ] + if record_ids: + domain.append(("id", "in", record_ids)) + return domain + + def _find_existing_exchange_records( + self, exchange_type, extra_domain=None, count_only=False + ): + domain = [ + ("backend_id", "=", self.id), + ("type_id", "=", exchange_type.id), + ] + extra_domain or [] + return self.env["edi.exchange.record"].search(domain, count=count_only) + + def action_view_exchanges(self): + action = self.env.ref("edi_oca.act_open_edi_exchange_record_view") + action["context"] = { + "search_default_backend_id": self.id, + "default_backend_id": self.id, + "default_backend_type_id": self.backend_type_id.id, + } + return action + + def action_view_exchange_types(self): + action = self.env.ref("edi_oca.act_open_edi_exchange_type_view") + action["context"] = { + "search_default_backend_id": self.id, + "default_backend_id": self.id, + "default_backend_type_id": self.backend_type_id.id, + } + return action + + def _is_valid_edi_action(self, action, raise_if_not=False): + try: + assert action in ("generate", "send", "process", "receive", "check") + return True + except AssertionError: + if raise_if_not: + raise + return False diff --git a/edi_oca/models/edi_backend_type.py b/edi_oca/models/edi_backend_type.py new file mode 100644 index 0000000000..fe365f49f4 --- /dev/null +++ b/edi_oca/models/edi_backend_type.py @@ -0,0 +1,36 @@ +# Copyright 2020 ACSONE SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + +from ..utils import normalize_string + + +class EDIBackendType(models.Model): + """Define a kind of backend.""" + + _name = "edi.backend.type" + _description = "EDI Backend Type" + + name = fields.Char(required=True) + code = fields.Char( + required=True, + inverse="_inverse_code", + ) + + _sql_constraints = [ + ("uniq_code", "unique(code)", "Backend type code must be unique!") + ] + + @api.onchange("name", "code") + def _onchange_code(self): + for rec in self: + rec.code = rec.code or rec.name + + def _inverse_code(self): + for rec in self: + # Make sure it's always normalized + code = normalize_string(rec.code) + if code != rec.code: + rec.code = code diff --git a/edi_oca/models/edi_exchange_consumer_mixin.py b/edi_oca/models/edi_exchange_consumer_mixin.py new file mode 100644 index 0000000000..ea7543a3d6 --- /dev/null +++ b/edi_oca/models/edi_exchange_consumer_mixin.py @@ -0,0 +1,290 @@ +# Copyright 2020 ACSONE SA +# Copyright 2020 Creu Blanca +# Copyright 2022 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + + +from lxml import etree + +from odoo import api, fields, models +from odoo.tools import safe_eval + +from odoo.addons.base_sparse_field.models.fields import Serialized + + +class EDIExchangeConsumerMixin(models.AbstractModel): + """Record that might have related EDI Exchange records""" + + _name = "edi.exchange.consumer.mixin" + _description = "Abstract record where exchange records can be assigned" + + origin_exchange_record_id = fields.Many2one( + string="EDI origin record", + comodel_name="edi.exchange.record", + ondelete="set null", + help="EDI record that originated this document.", + ) + origin_exchange_type_id = fields.Many2one( + string="EDI origin exchange type", + comodel_name="edi.exchange.type", + ondelete="set null", + related="origin_exchange_record_id.type_id", + # Store it to ease searching by type + store=True, + ) + exchange_record_ids = fields.One2many( + "edi.exchange.record", + inverse_name="res_id", + prefetch=False, + domain=lambda r: [("model", "=", r._name)], + ) + exchange_record_count = fields.Integer(compute="_compute_exchange_record_count") + edi_config = Serialized( + compute="_compute_edi_config", + default={}, + ) + edi_has_form_config = fields.Boolean(compute="_compute_edi_config") + # TODO: rename to `edi_disable_auto` + disable_edi_auto = fields.Boolean( + string="Disable auto", + help="When marked, EDI automatic processing will be avoided", + # Each extending module should override `states` as/if needed. + ) + + def _compute_edi_config(self): + for record in self: + config = record._edi_get_exchange_type_config() + record.edi_config = config + record.edi_has_form_config = any([x.get("form") for x in config.values()]) + + def _edi_get_exchange_type_config(self): + # TODO: move this machinery to the rule model + rules = ( + self.env["edi.exchange.type.rule"] + .sudo() + .search([("model_id.model", "=", self._name)]) + ) + result = {} + for rule in rules: + exchange_type = rule.type_id + eval_ctx = dict( + self._get_eval_context(), record=self, exchange_type=exchange_type + ) + domain = safe_eval.safe_eval(rule.enable_domain or "[]", eval_ctx) + if not self.filtered_domain(domain): + continue + if rule.enable_snippet: + safe_eval.safe_eval( + rule.enable_snippet, eval_ctx, mode="exec", nocopy=True + ) + if not eval_ctx.get("result", False): + continue + + result[str(rule.id)] = self._edi_get_exchange_type_rule_conf(rule) + return result + + @api.model + def _edi_get_exchange_type_rule_conf(self, rule): + conf = { + "form": {}, + "type": { + "id": rule.type_id.id, + "name": rule.type_id.name, + }, + } + if rule.kind == "form_btn": + label = rule.form_btn_label or rule.type_id.name + conf.update( + {"form": {"btn": {"label": label, "tooltip": rule.form_btn_tooltip}}} + ) + return conf + + def _get_eval_context(self): + """Prepare context to evalue python code snippet. + + :returns: dict -- evaluation context given to safe_eval + """ + return { + "datetime": safe_eval.datetime, + "dateutil": safe_eval.dateutil, + "time": safe_eval.time, + "uid": self.env.uid, + "user": self.env.user, + } + + @api.model + def fields_view_get( + self, view_id=None, view_type="form", toolbar=False, submenu=False + ): + res = super().fields_view_get( + view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu + ) + if view_type == "form": + doc = etree.XML(res["arch"]) + for node in doc.xpath("//sheet"): + # TODO: add a default group + group = False + if hasattr(self, "_edi_generate_group"): + group = self._edi_generate_group + str_element = self.env["ir.qweb"]._render( + "edi_oca.edi_exchange_consumer_mixin_buttons", + {"group": group}, + ) + node.addprevious(etree.fromstring(str_element)) + View = self.env["ir.ui.view"] + + # Override context for postprocessing + if view_id and res.get("base_model", self._name) != self._name: + View = View.with_context(base_model_name=res["base_model"]) + new_arch, new_fields = View.postprocess_and_fields(doc, self._name) + res["arch"] = new_arch + # We don't want to lose previous configuration, so, we only want to add + # the new fields + new_fields.update(res["fields"]) + res["fields"] = new_fields + return res + + def _edi_create_exchange_record_vals(self, exchange_type): + return { + "model": self._name, + "res_id": self.id, + } + + def _edi_create_exchange_record(self, exchange_type, backend=None, vals=None): + backend = exchange_type.backend_id or backend + assert backend + vals = vals or {} + vals.update(self._edi_create_exchange_record_vals(exchange_type)) + return backend.create_record(exchange_type.code, vals) + + def edi_create_exchange_record(self, exchange_type_id): + self.ensure_one() + exchange_type = self.env["edi.exchange.type"].browse(exchange_type_id) + backend = exchange_type.backend_id + if ( + not backend + and self.env["edi.backend"].search_count( + [("backend_type_id", "=", exchange_type.backend_type_id.id)] + ) + == 1 + ): + backend = self.env["edi.backend"].search( + [("backend_type_id", "=", exchange_type.backend_type_id.id)] + ) + # FIXME: here you can still have more than one backend per type. + # We should always get to the wizard w/ pre-populated values. + # Maybe this behavior can be controlled by exc type adv param. + if backend: + exchange_record = self._edi_create_exchange_record(exchange_type, backend) + self._event("on_edi_generate_manual").notify(self, exchange_record) + return exchange_record.get_formview_action() + return self._edi_get_create_record_wiz_action(exchange_type_id) + + def _edi_get_create_record_wiz_action(self, exchange_type_id): + action = self.env.ref("edi_oca.edi_exchange_record_create_act_window") + action["context"] = { + "default_res_id": self.id, + "default_model": self._name, + "default_exchange_type_id": exchange_type_id, + } + return action + + def _has_exchange_record(self, exchange_type, backend=False, extra_domain=False): + """Check presence of related exchange record with a specific exchange type""" + return bool( + self.env["edi.exchange.record"].search_count( + self._has_exchange_record_domain( + exchange_type, backend=backend, extra_domain=extra_domain + ) + ) + ) + + def _has_exchange_record_domain( + self, exchange_type, backend=False, extra_domain=False + ): + if isinstance(exchange_type, str): + # Backward compat: allow passing the code when this method is called directly + type_leaf = [("type_id.code", "=", exchange_type)] + else: + type_leaf = [("type_id", "=", exchange_type.id)] + domain = [ + ("model", "=", self._name), + ("res_id", "=", self.id), + ] + type_leaf + if backend is None: + backend = exchange_type.backend_id + if backend: + domain.append(("backend_id", "=", backend.id)) + if extra_domain: + domain += extra_domain + return domain + + def _get_exchange_record(self, exchange_type, backend=False, extra_domain=False): + """Get all related exchange records matching give exchange type.""" + return self.env["edi.exchange.record"].search( + self._has_exchange_record_domain( + exchange_type, backend=backend, extra_domain=extra_domain + ) + ) + + @api.depends("exchange_record_ids") + def _compute_exchange_record_count(self): + data = self.env["edi.exchange.record"].read_group( + [("res_id", "in", self.ids), ("model", "=", self._name)], + ["res_id"], + ["res_id"], + ) + mapped_data = {x["res_id"]: x["res_id_count"] for x in data} + for rec in self: + rec.exchange_record_count = mapped_data.get(rec.id, 0) + + def action_view_edi_records(self): + self.ensure_one() + action = self.env.ref("edi_oca.act_open_edi_exchange_record_view") + action["domain"] = [("model", "=", self._name), ("res_id", "=", self.id)] + # Purge default search filters from ctx to avoid hiding records + ctx = action.get("context", {}) + if isinstance(ctx, str): + ctx = safe_eval.safe_eval(ctx, self.env.context) + action["context"] = { + k: v for k, v in ctx.items() if not k.startswith("search_default_") + } + # Drop ID otherwise the context will be loaded from the action's record :S + action.pop("id") + return action + + @api.model + def get_edi_access(self, doc_ids, operation, model_name=False): + """Retrieve access policy. + + The behavior is similar to `mail.thread` and `mail.message` + and it relies on the access rules defines on the related record. + The behavior can be customized on the related model + by defining `_edi_exchange_record_access`. + + By default `write`, otherwise the custom permission is returned. + """ + DocModel = self.env[model_name] if model_name else self + create_allow = getattr(DocModel, "_edi_exchange_record_access", "write") + if operation in ["write", "unlink"]: + check_operation = "write" + elif operation == "create" and create_allow in [ + "create", + "read", + "write", + "unlink", + ]: + check_operation = create_allow + elif operation == "create": + check_operation = "write" + else: + check_operation = operation + return check_operation + + def _edi_set_origin(self, exc_record): + self.sudo().update({"origin_exchange_record_id": exc_record.id}) + + def _edi_get_origin(self): + self.ensure_one() + return self.origin_exchange_record_id diff --git a/edi_oca/models/edi_exchange_record.py b/edi_oca/models/edi_exchange_record.py new file mode 100644 index 0000000000..cb2918b6bd --- /dev/null +++ b/edi_oca/models/edi_exchange_record.py @@ -0,0 +1,568 @@ +# Copyright 2020 ACSONE SA +# Copyright 2021 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import base64 +import logging +from collections import defaultdict + +from odoo import _, api, exceptions, fields, models +from odoo import SUPERUSER_ID + +_logger = logging.getLogger(__name__) + + +class EDIExchangeRecord(models.Model): + """ + Define an exchange record. + """ + + _name = "edi.exchange.record" + _inherit = "mail.thread" + _description = "EDI exchange Record" + _order = "exchanged_on desc, id desc" + _rec_name = "identifier" + + identifier = fields.Char(required=True, index=True, readonly=True, copy=False) + external_identifier = fields.Char(index=True, readonly=True, copy=False) + type_id = fields.Many2one( + string="Exchange type", + comodel_name="edi.exchange.type", + required=True, + ondelete="cascade", + auto_join=True, + index=True, + ) + direction = fields.Selection(related="type_id.direction") + backend_id = fields.Many2one(comodel_name="edi.backend", required=True) + model = fields.Char(index=True, required=False, readonly=True) + res_id = fields.Integer( + string="Record ID", + index=True, + required=False, + readonly=True, + ) + related_record_exists = fields.Boolean(compute="_compute_related_record_exists") + related_name = fields.Char(compute="_compute_related_name", compute_sudo=True) + exchange_file = fields.Binary(attachment=True, copy=False) + exchange_filename = fields.Char( + compute="_compute_exchange_filename", readonly=False, store=True + ) + exchanged_on = fields.Datetime( + string="Exchanged on", + help="Sent or received on this date.", + readonly=False, + ) + edi_exchange_state = fields.Selection( + string="Exchange state", + readonly=True, + copy=False, + default="new", + index=True, + selection=[ + # Common states + ("new", "New"), + ("validate_error", "Error on validation"), + # output exchange states + ("output_pending", "Waiting to be sent"), + ("output_error_on_send", "error on send"), + ("output_sent", "Sent"), + ("output_sent_and_processed", "Sent and processed"), + ("output_sent_and_error", "Sent and error"), + # input exchange states + ("input_pending", "Waiting to be received"), + ("input_received", "Received"), + ("input_receive_error", "Error on reception"), + ("input_processed", "Processed"), + ("input_processed_error", "Error on process"), + ], + ) + exchange_error = fields.Text(string="Exchange error", readonly=True, copy=False) + # Relations w/ other records + parent_id = fields.Many2one( + comodel_name="edi.exchange.record", + help="Original exchange which originated this record", + ) + related_exchange_ids = fields.One2many( + string="Related records", + comodel_name="edi.exchange.record", + inverse_name="parent_id", + ) + ack_expected = fields.Boolean(compute="_compute_ack_expected") + # TODO: shall we add a constrain on the direction? + # In theory if the record is outgoing the ack should be incoming and vice versa. + ack_exchange_id = fields.Many2one( + string="ACK exchange", + comodel_name="edi.exchange.record", + help="ACK generated for current exchange.", + compute="_compute_ack_exchange_id", + store=True, + ) + ack_received_on = fields.Datetime( + string="ACK received on", related="ack_exchange_id.exchanged_on" + ) + retryable = fields.Boolean( + compute="_compute_retryable", + help="The record state can be rolled back manually in case of failure.", + ) + + _sql_constraints = [ + ("identifier_uniq", "unique(identifier)", "The identifier must be unique."), + ( + "external_identifier_uniq", + "unique(external_identifier, backend_id, type_id)", + "The external_identifier must be unique for a type and a backend.", + ), + ] + + @api.depends("model", "res_id") + def _compute_related_name(self): + for rec in self: + related_record = rec.record + rec.related_name = related_record.display_name if related_record else "" + + @api.depends("model", "type_id", "res_id") + def _compute_exchange_filename(self): + for rec in self: + if not rec.type_id: + continue + if not rec.exchange_filename: + rec.exchange_filename = rec.type_id._make_exchange_filename(rec) + + @api.constrains("edi_exchange_state") + def _compute_exchanged_on(self): + for rec in self: + if rec.edi_exchange_state in ("input_received", "output_sent"): + rec.exchanged_on = fields.Datetime.now() + + @api.constrains("edi_exchange_state") + def _constrain_edi_exchange_state(self): + for rec in self: + if rec.edi_exchange_state in ("new", "validate_error"): + continue + if not rec.edi_exchange_state.startswith(rec.direction): + raise exceptions.ValidationError( + _("Exchange state must respect direction!") + ) + + @api.depends("related_exchange_ids.type_id") + def _compute_ack_exchange_id(self): + for rec in self: + rec.ack_exchange_id = rec._get_ack_record() + + def _get_ack_record(self): + if not self.type_id.ack_type_id: + return None + return fields.first( + self.related_exchange_ids.filtered( + lambda x: x.type_id == self.type_id.ack_type_id + ).sorted("id", reverse=True) + ) + + def _compute_ack_expected(self): + for rec in self: + rec.ack_expected = bool(self.type_id.ack_type_id) + + @api.depends("res_id", "model") + def _compute_related_record_exists(self): + for rec in self: + rec.related_record_exists = bool(rec.record) + + def needs_ack(self): + return self.type_id.ack_type_id and not self.ack_exchange_id + + _rollback_state_mapping = { + # From: to + "output_error_on_send": "output_pending", + "output_sent_and_error": "output_pending", + "input_receive_error": "input_pending", + "input_processed_error": "input_received", + } + + @api.depends("edi_exchange_state") + def _compute_retryable(self): + for rec in self: + rec.retryable = rec.edi_exchange_state in self._rollback_state_mapping + + @property + def record(self): + # In some case the res_model (and res_id) could be empty so we have to load + # data from parent + if not self.model and not self.parent_id: + return None + if not self.model and self.parent_id: + return self.parent_id.record + return self.env[self.model].browse(self.res_id).exists() + + def _set_file_content( + self, output_string, encoding="utf-8", field_name="exchange_file" + ): + """Handy method to no have to convert b64 back and forth.""" + self.ensure_one() + if not isinstance(output_string, bytes): + output_string = bytes(output_string, encoding) + self[field_name] = base64.b64encode(output_string) + + def _get_file_content( + self, field_name="exchange_file", binary=True, as_bytes=False + ): + """Handy method to not have to convert b64 back and forth.""" + self.ensure_one() + if not self[field_name]: + return "" + if binary: + res = base64.b64decode(self[field_name]) + return res.decode() if not as_bytes else res + return self[field_name] + + def name_get(self): + result = [] + for rec in self: + rec_name = rec.identifier + if rec.res_id and rec.model: + rec_name = rec.record.display_name + name = "[{}] {}".format(rec.type_id.name, rec_name) + result.append((rec.id, name)) + return result + + @api.model + def create(self, vals): + vals["identifier"] = self._get_identifier() + rec = super().create(vals) + if rec._quick_exec_enabled(): + rec._execute_next_action() + return rec + + def _get_identifier(self): + return self.env["ir.sequence"].next_by_code("edi.exchange") + + def _quick_exec_enabled(self): + if self.env.context.get("edi__skip_quick_exec"): + return False + return self.type_id.quick_exec + + def _execute_next_action(self): + # The backend already knows how to handle records + # according to their direction and status. + # Let it decide. + if self.type_id.direction == "output": + self.backend_id._check_output_exchange_sync(record_ids=self.ids) + else: + self.backend_id._check_input_exchange_sync(record_ids=self.ids) + + @api.constrains("backend_id", "type_id") + def _constrain_backend(self): + for rec in self: + if rec.type_id.backend_id: + if rec.type_id.backend_id != rec.backend_id: + raise exceptions.ValidationError( + _("Backend must match with exchange type's backend!") + ) + else: + if rec.type_id.backend_type_id != rec.backend_id.backend_type_id: + raise exceptions.ValidationError( + _("Backend type must match with exchange type's backend type!") + ) + + @property + def _exchange_status_messages(self): + return { + # status: message + "send_ok": _("Exchange sent"), + "send_ko": _( + "An error happened while sending. Please check exchange record info." + ), + "process_ok": _("Exchange processed successfully "), + "process_ko": _("Exchange processed with errors"), + "receive_ok": _("Exchange received successfully "), + "receive_ko": _("Exchange not received"), + "ack_received": _("ACK file received."), + "ack_missing": _("ACK file is required for this exchange but not found."), + "ack_received_error": _("ACK file received but contains errors."), + "validate_ko": _("Exchange not valid"), + } + + def _exchange_status_message(self, key): + return self._exchange_status_messages[key] + + def action_exchange_generate(self, **kw): + self.ensure_one() + return self.backend_id.exchange_generate(self, **kw) + + def action_exchange_send(self): + self.ensure_one() + return self.backend_id.exchange_send(self) + + def action_exchange_process(self): + self.ensure_one() + return self.backend_id.exchange_process(self) + + def action_exchange_receive(self): + self.ensure_one() + return self.backend_id.exchange_receive(self) + + def exchange_create_ack_record(self, **kw): + return self.exchange_create_child_record( + exc_type=self.type_id.ack_type_id, **kw + ) + + def exchange_create_child_record(self, exc_type=None, **kw): + exc_type = exc_type or self.type_id + values = self._exchange_child_record_values() + values.update(**kw) + return self.backend_id.create_record(exc_type.code, values) + + def _exchange_child_record_values(self): + return { + "parent_id": self.id, + "model": self.model, + "res_id": self.res_id, + } + + def action_retry(self): + for rec in self: + rec._retry_exchange_action() + + def _retry_exchange_action(self): + """Move back to precedent state to retry exchange action if failed.""" + if not self.retryable: + return False + new_state = self._rollback_state_mapping[self.edi_exchange_state] + fname = "edi_exchange_state" + self[fname] = new_state + display_state = self._fields[fname].convert_to_export(self[fname], self) + self.message_post( + body=_("Action retry: state moved back to '%s'") % display_state + ) + if self._quick_exec_enabled(): + self._execute_next_action() + return True + + def action_open_related_record(self): + self.ensure_one() + if not self.related_record_exists: + return {} + return self.record.get_formview_action() + + def _set_related_record(self, odoo_record): + self.sudo().update({"model": odoo_record._name, "res_id": odoo_record.id}) + + def action_open_related_exchanges(self): + self.ensure_one() + if not self.related_exchange_ids: + return {} + action = self.env.ref("edi_oca.act_open_edi_exchange_record_view") + action["domain"] = [("id", "in", self.related_exchange_ids.ids)] + return action + + def notify_action_complete(self, action, message=None): + """Notify current record that an edi action has been completed. + + Implementers should take care of calling this method + if they work on records w/o calling edi_backend methods (eg: action_send). + + Implementers can hook to this method to do something after any action ends. + """ + if message: + self._notify_related_record(message) + + # Trigger generic action complete event on exchange record + event_name = "{}_complete".format(action) + self._trigger_edi_event(event_name) + if self.related_record_exists: + # Trigger specific event on related record + self._trigger_edi_event(event_name, target=self.record) + + def _notify_related_record(self, message, level="info"): + """Post notification on the original record.""" + if not self.related_record_exists or not hasattr( + self.record, "message_post_with_view" + ): + return + self.record.message_post_with_view( + "edi_oca.message_edi_exchange_link", + values={ + "backend": self.backend_id, + "exchange_record": self, + "message": message, + "level": level, + }, + subtype_id=self.env.ref("mail.mt_note").id, + ) + + def _trigger_edi_event_make_name(self, name, suffix=None): + return "on_edi_exchange_{name}{suffix}".format( + name=name, + suffix=("_" + suffix) if suffix else "", + ) + + def _trigger_edi_event(self, name, suffix=None, target=None, **kw): + """Trigger a component event linked to this backend and edi exchange.""" + name = self._trigger_edi_event_make_name(name, suffix=suffix) + target = target or self + target._event(name).notify(self, **kw) + + def _notify_done(self): + self._notify_related_record(self._exchange_status_message("process_ok")) + self._trigger_edi_event("done") + + def _notify_error(self, message_key): + self._notify_related_record( + self._exchange_status_message(message_key), + level="error", + ) + self._trigger_edi_event("error") + + def _notify_ack_received(self): + self._notify_related_record(self._exchange_status_message("ack_received")) + self._trigger_edi_event("done", suffix="ack_received") + + def _notify_ack_missing(self): + self._notify_related_record( + self._exchange_status_message("ack_missing"), + level="warning", + ) + self._trigger_edi_event("done", suffix="ack_missing") + + def _notify_ack_received_error(self): + self._notify_related_record( + self._exchange_status_message("ack_received_error"), + ) + self._trigger_edi_event("done", suffix="ack_received_error") + + @api.model + def _search( + self, + args, + offset=0, + limit=None, + order=None, + count=False, + access_rights_uid=None, + ): + ids = super()._search( + args, + offset=offset, + limit=limit, + order=order, + count=False, + access_rights_uid=access_rights_uid, + ) + if self._uid == SUPERUSER_ID: + # rules do not apply for the superuser + return len(ids) if count else ids + + # TODO highlight orphaned EDI records in UI: + # - self.model + self.res_id are set + # - self.record returns empty recordset + # Remark: self.record is @property, not field + + if not ids: + return 0 if count else [] + orig_ids = ids + ids = set(ids) + result = [] + model_data = defaultdict( + lambda: defaultdict(set) + ) # {res_model: {res_id: set(ids)}} + for sub_ids in self._cr.split_for_in_conditions(ids): + self._cr.execute( + """ + SELECT id, res_id, model + FROM "%s" + WHERE id = ANY (%%(ids)s)""" + % self._table, + dict(ids=list(sub_ids)), + ) + for eid, res_id, model in self._cr.fetchall(): + if not model: + result.append(eid) + continue + model_data[model][res_id].add(eid) + + for model, targets in model_data.items(): + if not self.env[model].check_access_rights("read", False): + continue + recs = self.env[model].browse(list(targets)) + missing = recs - recs.exists() + if missing: + for res_id in missing.ids: + _logger.warning( + "Deleted record %s,%s is referenced by edi.exchange.record %s", + model, + res_id, + list(targets[res_id]), + ) + recs = recs - missing + allowed = ( + self.env[model] + .with_context(active_test=False) + ._search([("id", "in", recs.ids)]) + ) + for target_id in allowed: + result += list(targets[target_id]) + if len(orig_ids) == limit and len(result) < len(orig_ids): + result.extend( + self._search( + args, + offset=offset + len(orig_ids), + limit=limit, + order=order, + count=count, + access_rights_uid=access_rights_uid, + )[: limit - len(result)] + ) + # Restore original ordering + result = [x for x in orig_ids if x in result] + return len(result) if count else list(result) + + def read(self, fields=None, load="_classic_read"): + """Override to explicitely call check_access_rule, that is not called + by the ORM. It instead directly fetches ir.rules and apply them.""" + self.check_access_rule("read") + return super().read(fields=fields, load=load) + + def check_access_rule(self, operation): + """In order to check if we can access a record, we are checking if we can access + the related document""" + super(EDIExchangeRecord, self).check_access_rule(operation) + if self._uid == SUPERUSER_ID: + return + default_checker = self.env["edi.exchange.consumer.mixin"].get_edi_access + by_model_rec_ids = defaultdict(set) + by_model_checker = {} + for exc_rec in self.sudo(): + if not exc_rec.related_record_exists: + continue + by_model_rec_ids[exc_rec.model].add(exc_rec.res_id) + if exc_rec.model not in by_model_checker: + by_model_checker[exc_rec.model] = getattr( + self.env[exc_rec.model], "get_edi_access", default_checker + ) + + for model, rec_ids in by_model_rec_ids.items(): + records = self.env[model].browse(rec_ids).sudo(self._uid) + checker = by_model_checker[model] + for record in records: + check_operation = checker( + [record.id], operation, model_name=record._name + ) + record.check_access_rights(check_operation) + record.check_access_rule(check_operation) + + def write(self, vals): + self.check_access_rule("write") + return super().write(vals) + + def _job_delay_params(self): + params = {} + channel = self.type_id.sudo().job_channel_id + if channel: + params["channel"] = channel.complete_name + return params + + def with_delay(self, **kw): + params = self._job_delay_params() + params.update(kw) + return super().with_delay(**params) diff --git a/edi_oca/models/edi_exchange_type.py b/edi_oca/models/edi_exchange_type.py new file mode 100644 index 0000000000..db09a74198 --- /dev/null +++ b/edi_oca/models/edi_exchange_type.py @@ -0,0 +1,362 @@ +# Copyright 2020 ACSONE SA +# Copyright 2021 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +import logging +from datetime import datetime + +from pytz import timezone, utc + +from odoo import _, api, exceptions, fields, models +from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT, groupby + +from odoo.addons.base_sparse_field.models.fields import Serialized +from odoo.addons.http_routing.models.ir_http import slugify + +_logger = logging.getLogger(__name__) + + +try: + import yaml +except ImportError: + _logger.debug("`yaml` lib is missing") + + +class EDIExchangeType(models.Model): + """ + Define a kind of exchange. + """ + + _name = "edi.exchange.type" + _description = "EDI Exchange Type" + + active = fields.Boolean(default=True) + backend_id = fields.Many2one( + string="Backend", + comodel_name="edi.backend", + ondelete="set null", + ) + backend_type_id = fields.Many2one( + string="Backend type", + comodel_name="edi.backend.type", + required=True, + ondelete="restrict", + ) + job_channel_id = fields.Many2one( + comodel_name="queue.job.channel", + ) + name = fields.Char(required=True) + code = fields.Char(required=True, copy=False) + direction = fields.Selection( + selection=[("input", "Input"), ("output", "Output")], required=True + ) + exchange_filename_pattern = fields.Char(default="{record_name}-{type.code}-{dt}") + # TODO make required if exchange_filename_pattern is + exchange_file_ext = fields.Char() + # TODO: this flag should be probably deprecated + # because when an exchange w/o file is pending + # there's no reason not to generate it. + # Also this could be controlled more generally w/ edi auto settings. + exchange_file_auto_generate = fields.Boolean( + help="Auto generate output for records missing their payload. " + "If active, a cron will take care of generating the output when not set yet. " + ) + ack_type_id = fields.Many2one( + string="Ack exchange type", + comodel_name="edi.exchange.type", + ondelete="set null", + help="Identify the type of the ack. " + "If this field is valued it means an hack is expected.", + ) + ack_for_type_ids = fields.Many2many( + string="Ack for exchange type", + comodel_name="edi.exchange.type", + compute="_compute_ack_for_type_ids", + ) + advanced_settings_edit = fields.Text( + string="Advanced YAML settings", + help=""" + Advanced technical settings as YAML format. + The YAML structure should reproduce a dictionary. + The backend might use these settings for automated operations. + + Currently supported conf: + + components: + generate: + usage: $comp_usage + # set a value for component work context + work_ctx: + opt1: True + validate: + usage: $comp_usage + env_ctx: + # set a value for the whole processing env + opt2: False + check: + usage: $comp_usage + send: + usage: $comp_usage + receive: + usage: $comp_usage + process: + usage: $comp_usage + + filename_pattern: + force_tz: Europe/Rome + date_pattern: %Y-%m-%d-%H-%M-%S + + In any case, you can use these settings + to provide your own configuration for whatever need you might have. + """, + ) + advanced_settings = Serialized(default={}, compute="_compute_advanced_settings") + rule_ids = fields.One2many( + comodel_name="edi.exchange.type.rule", + inverse_name="type_id", + help="Rules to handle exchanges and UI automatically", + ) + # Deprecated fields for rules - begin + # These fields have been deprecated in + # https://github.com/OCA/edi/pull/797 + # but are kept for backward compat. + # If you can stop using them now. + # Anyway, annoying warning messages will be logged. + # See inverse methods. + # NOTE: old configurations are migrated automatically on upgrade + # Yet, if you have data files they might be broken + # if we delete these fields. + model_ids = fields.Many2many( + "ir.model", inverse="_inverse_deprecated_rules_model_ids" + ) + enable_domain = fields.Char(inverse="_inverse_deprecated_rules_enable_domain") + enable_snippet = fields.Char(inverse="_inverse_deprecated_rules_enable_snippet") + model_manual_btn = fields.Boolean( + inverse="_inverse_deprecated_rules_model_manual_btn" + ) + deprecated_rule_fields_still_used = fields.Boolean( + compute="_compute_deprecated_rule_fields_still_used" + ) + # Deprecated fields for rules - end + quick_exec = fields.Boolean( + string="Quick execution", + help="When active, records of this type will be processed immediately " + "without waiting for the cron to pass by.", + ) + partner_ids = fields.Many2many( + string="Enabled for partners", + comodel_name="res.partner", + help=( + "You can use this field to limit generating/processing exchanges " + "for specific partners. " + "Use it directly or within models rules (domain or snippet)." + ), + ) + + _sql_constraints = [ + ( + "code_uniq", + "unique(code, backend_id)", + "The code must be unique per backend", + ) + ] + + @api.depends("advanced_settings_edit") + def _compute_advanced_settings(self): + for rec in self: + rec.advanced_settings = rec._load_advanced_settings() + + def _load_advanced_settings(self): + # TODO: validate settings w/ a schema. + # Could be done w/ Cerberus or JSON-schema. + # This would help documenting core and custom keys. + return yaml.safe_load(self.advanced_settings_edit or "") or {} + + def _compute_ack_for_type_ids(self): + ack_for = self.search([("ack_type_id", "in", self.ids)]) + by_type_id = dict(groupby(ack_for, lambda x: x.ack_type_id.id)) + for rec in self: + rec.ack_for_type_ids = [x.id for x in by_type_id.get(rec.id, [])] + + def get_settings(self): + return self.advanced_settings + + def set_settings(self, val): + self.advanced_settings_edit = val + + @api.constrains("backend_id", "backend_type_id") + def _check_backend(self): + for rec in self: + if not rec.backend_id: + continue + if rec.backend_id.backend_type_id != rec.backend_type_id: + raise exceptions.UserError(_("Backend should respect backend type!")) + + def _make_exchange_filename_datetime(self): + """ + Returns current datetime (now) using filename pattern + which can be set using advanced settings. + + Example: + filename_pattern: + force_tz: Europe/Rome + date_pattern: %Y-%m-%d-%H-%M-%S + """ + self.ensure_one() + pattern_settings = self.advanced_settings.get("filename_pattern", {}) + force_tz = pattern_settings.get("force_tz", self.env.user.tz) + date_pattern = pattern_settings.get("date_pattern", DATETIME_FORMAT) + tz = timezone(force_tz) if force_tz else None + now = datetime.now(utc).astimezone(tz) + return slugify(now.strftime(date_pattern)) + + def _make_exchange_filename(self, exchange_record): + """Generate filename.""" + pattern = self.exchange_filename_pattern + ext = self.exchange_file_ext + pattern = pattern + ".{ext}" + dt = self._make_exchange_filename_datetime() + record_name = self._get_record_name(exchange_record) + record = exchange_record + if exchange_record.model and exchange_record.res_id: + record = exchange_record.record + return pattern.format( + exchange_record=exchange_record, + record=record, + record_name=record_name, + type=self, + dt=dt, + ext=ext, + ) + + def _get_record_name(self, exchange_record): + if not exchange_record.res_id or not exchange_record.model: + return slugify(exchange_record.display_name) + if hasattr(exchange_record.record, "_get_edi_exchange_record_name"): + return exchange_record.record._get_edi_exchange_record_name(exchange_record) + return slugify(exchange_record.record.display_name) + + def is_partner_enabled(self, partner): + """Check if given partner record is allowed for the current type. + + You can leverage this in your own logic to trigger or not + certain exchanges for specific partners. + + For instance: a customer might require an ORDRSP while another does not. + """ + exc_type = self.sudo() + if exc_type.partner_ids: + return partner.id in exc_type.partner_ids.ids + return True + + # API to support deprecated model rules fields - begin + def _inverse_deprecated_rules_warning(self): + _fields = ", ".join( + ["model_ids", "enable_domain", "enable_snippet", "model_manual_btn"] + ) + _logger.warning( + "The fields %s are deprecated, " + "please stop using them in favor of edi.exchange.type.rule", + _fields, + ) + + def _inverse_deprecated_rules_model_ids(self): + if self.env.context.get("deprecated_rule_fields_bypass_inverse"): + return + self._inverse_deprecated_rules_warning() + for rec in self: + for model in rec.model_ids: + rule = rec._get_rule_by_model(model) + if not rule: + _logger.warning( + "New rule for %s created from deprecated `model_ids`", + model.model, + ) + rec.rule_ids += rec._inverse_deprecated_rules_create(model) + rules_to_delete = rec.rule_ids.browse() + for rule in rec.rule_ids: + if rule.model_id not in rec.model_ids: + _logger.warning( + "Rule for %s deleted from deprecated `model_ids`", + rule.model_id.model, + ) + rules_to_delete |= rule + rules_to_delete.unlink() + + def _inverse_deprecated_rules_enable_domain(self): + if self.env.context.get("deprecated_rule_fields_bypass_inverse"): + return + self._inverse_deprecated_rules_warning() + for rec in self: + for model in rec.model_ids: + rule = rec._get_rule_by_model(model) + if rule: + _logger.warning( + "Rule for %s domain updated from deprecated `enable_domain`", + model.model, + ) + rule.enable_domain = rec.enable_domain + + def _inverse_deprecated_rules_enable_snippet(self): + if self.env.context.get("deprecated_rule_fields_bypass_inverse"): + return + self._inverse_deprecated_rules_warning() + for rec in self: + for model in rec.model_ids: + rule = rec._get_rule_by_model(model) + if rule: + _logger.warning( + "Rule for %s snippet updated from deprecated `enable_snippet`", + model.model, + ) + rule.enable_snippet = rec.enable_snippet + + def _inverse_deprecated_rules_model_manual_btn(self): + if self.env.context.get("deprecated_rule_fields_bypass_inverse"): + return + self._inverse_deprecated_rules_warning() + for rec in self: + for model in rec.model_ids: + rule = rec._get_rule_by_model(model) + if rule: + _logger.warning( + "Rule for %s btn updated from deprecated `model_manual_btn`", + model.model, + ) + rule.kind = "form_btn" if self.model_manual_btn else "custom" + + def _get_rule_by_model(self, model): + return self.rule_ids.filtered(lambda x: x.model_id == model) + + def _inverse_deprecated_rules_create(self, model): + kind = "form_btn" if self.model_manual_btn else "custom" + vals = { + "type_id": self.id, + "model_id": model.id, + "kind": kind, + "name": "Default", + "enable_snippet": self.enable_snippet, + "enable_domain": self.enable_domain, + } + return self.rule_ids.create(vals) + + @api.depends("model_ids", "enable_domain", "enable_snippet", "model_manual_btn") + def _compute_deprecated_rule_fields_still_used(self): + for rec in self: + rec.deprecated_rule_fields_still_used = ( + rec._deprecated_rule_fields_still_used() + ) + + def _deprecated_rule_fields_still_used(self): + for fname in ("model_ids", "enable_snippet", "enable_domain"): + if self[fname]: + return True + + def button_wipe_deprecated_rule_fields(self): + _fields = ["model_ids", "enable_domain", "enable_snippet", "model_manual_btn"] + deprecated_vals = {}.fromkeys(_fields, None) + self.with_context(deprecated_rule_fields_bypass_inverse=True).write( + deprecated_vals + ) + + # API to support deprecated model rules fields - end diff --git a/edi_oca/models/edi_exchange_type_rule.py b/edi_oca/models/edi_exchange_type_rule.py new file mode 100644 index 0000000000..8098493721 --- /dev/null +++ b/edi_oca/models/edi_exchange_type_rule.py @@ -0,0 +1,62 @@ +# Copyright 2023 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + +KIND_HELP = """ +* Form button: show a button on the related model form + when conditions from domain and snippet are satisfied + +* Custom: let devs handle a custom behavior with specific developments +""" + + +class EDIExchangeTypeRule(models.Model): + """ + Define rules for exchange types. + """ + + _name = "edi.exchange.type.rule" + _description = "EDI Exchange type rule" + + active = fields.Boolean(default=True) + name = fields.Char(required=True) + type_id = fields.Many2one( + comodel_name="edi.exchange.type", + required=True, + ondelete="cascade", + ) + model_id = fields.Many2one( + comodel_name="ir.model", + help="Apply to this model", + ondelete="cascade", + ) + model = fields.Char(related="model_id.model") # Tech field + enable_domain = fields.Char( + string="Enable on domain", help="Filter domain to be checked on Models" + ) + enable_snippet = fields.Char( + string="Enable on snippet", + help="""Snippet of code to be checked on Models, + You can use `record` and `exchange_type` here. + It will be executed if variable result has been defined as True + """, + ) + kind = fields.Selection( + selection=[ + ("form_btn", "Form button"), + ("custom", "Custom"), + ], + required=True, + default="form_btn", + help=KIND_HELP, + ) + form_btn_label = fields.Char( + string="Form button label", translate=True, help="Type name used by default" + ) + form_btn_tooltip = fields.Text( + string="Form button tooltip", + translate=True, + help="Help message visible as tooltip on button h-over", + ) diff --git a/edi_oca/models/edi_id_mixin.py b/edi_oca/models/edi_id_mixin.py new file mode 100644 index 0000000000..33d39d2c14 --- /dev/null +++ b/edi_oca/models/edi_id_mixin.py @@ -0,0 +1,14 @@ +# Copyright 2022 Camptocamp SA +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class EDIIdMixin(models.AbstractModel): + """Mixin to expose identifier's features""" + + _name = "edi.id.mixin" + _description = "EDI ID mixin" + + edi_id = fields.Char(help="Internal or external identifier for records.") diff --git a/edi_oca/readme/CONFIGURE.rst b/edi_oca/readme/CONFIGURE.rst new file mode 100644 index 0000000000..acf6a0d286 --- /dev/null +++ b/edi_oca/readme/CONFIGURE.rst @@ -0,0 +1,50 @@ +This module aims to provide an infrastructure to simplify interchangability of documents +between systems providing a configuration platform. +It will be inherited by other modules in order to define the proper implementations of +components. + +In order to define a new Exchange Record, we need to configure: + +* Backend Type +* Exchange Type +* Backend +* Components + +Component definition +~~~~~~~~~~~~~~~~~~~~ + +The component usage must be defined like `edi.{direction}.{kind}.{code}` where: + +* direction is `output` or `input` +* kind can be: `generate`, `send`, `check`, `process`, `receive` +* code is the `{backend type code}` or `{backend type code}.{exchange type code}` + +User EDI generation +~~~~~~~~~~~~~~~~~~~ + +On the exchange type, it might be possible to define a set of models, a domain and a +snippet of code. +After defining this fields, we will automatically see buttons on the view to generate +the exchange records. +This configuration is useful to define a way of generation managed by user. + + +Exchange type rules configuration +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Exchange types can be further configured with rules. +You can use rules to: + +1. make buttons automatically appear in forms +2. define your own custom logic + +Go to an exchange type and go to the tab "Model rules". +There you can add one or more rule, one per model. +On each rule you can define a domain or a snippet to activate it. +In case of a "Form button" kind, if the domain and/ the snippet is/are satisfied, +a form btn will appear on the top of the form. +This button can be used by the end user to manually generate an exchange. +If there's more than a backend and the exchange type has not a backend set, +a wizard will appear asking to select a backend to be used for the exchange. + +In case of "Custom" kind, you'll have to define your own logic to do something. diff --git a/edi_oca/readme/CONTRIBUTORS.rst b/edi_oca/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..4945a3dc40 --- /dev/null +++ b/edi_oca/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Enric Tobella diff --git a/edi_oca/readme/DESCRIPTION.rst b/edi_oca/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..3ffefe93ad --- /dev/null +++ b/edi_oca/readme/DESCRIPTION.rst @@ -0,0 +1,10 @@ +Base EDI backend. + +Provides following models: + +1. EDI Backend, to centralize configuration +2. EDI Backend Type, to classify EDI backends (eg: UBL, GS1, e-invoice, pick-yours) +3. EDI Exchange Type, to define file types of exchange +4. EDI Exchange Record, to define a record exchanged between systems + +Also define a mixin to be inherited by records that will generate EDIs diff --git a/edi_oca/readme/ROADMAP.rst b/edi_oca/readme/ROADMAP.rst new file mode 100644 index 0000000000..bb632b277c --- /dev/null +++ b/edi_oca/readme/ROADMAP.rst @@ -0,0 +1,4 @@ +14.0.1.0.0 +~~~~~~~~~~ + +The module name has been changed from edi to edi_oca. diff --git a/edi_oca/readme/USAGE.rst b/edi_oca/readme/USAGE.rst new file mode 100644 index 0000000000..cb4a456ed4 --- /dev/null +++ b/edi_oca/readme/USAGE.rst @@ -0,0 +1,31 @@ +After certain operations or manual execution, Exchange records will be generated. +This Exchange records might be input records or outputs records. + +The change of state can be manually executed by the system or be managed through by +`ir.cron`. + +Output Exchange records +~~~~~~~~~~~~~~~~~~~~~~~ + +An output record is intended to be used for exchange information from Odoo to another +system. + +The flow of an output record should be: + +* Creation +* Generation of data +* Validation of data +* Sending data +* Validation of data processed properly by the other party + +Input Exchange records +~~~~~~~~~~~~~~~~~~~~~~ + +An input record is intended to be used for exchange information another system to odoo. + +The flow of an input record should be: + +* Creation +* Reception of data +* Checking data +* Processing data diff --git a/edi_oca/security/ir_model_access.xml b/edi_oca/security/ir_model_access.xml new file mode 100644 index 0000000000..83970fb3c5 --- /dev/null +++ b/edi_oca/security/ir_model_access.xml @@ -0,0 +1,116 @@ + + + + access_edi_backend_type manager + + + + + + + + + access_edi_backend manager + + + + + + + + + access_edi_exchange_type manager + + + + + + + + + access_edi_exchange_type_rule manager + + + + + + + + + access_edi_exchange_record manager + + + + + + + + + access_edi_backend_type user + + + + + + + + + access_edi_backend user + + + + + + + + + access_edi_exchange_type user + + + + + + + + + access_edi_exchange_type_rule user + + + + + + + + + access_edi_exchange_record user + + + + + + + + + access_edi_exchange_record user + + + + + + + + + Assigned EDI exchange records + + ['|', ('model','!=', False), ('res_id', '=', False)] + + + + Manager EDI exchange records + + [(1, '=', 1)] + + + diff --git a/edi_oca/security/res_groups.xml b/edi_oca/security/res_groups.xml new file mode 100644 index 0000000000..f01b1eb2a7 --- /dev/null +++ b/edi_oca/security/res_groups.xml @@ -0,0 +1,11 @@ + + + + EDI Advanced Settings Manager + + + + diff --git a/edi_oca/static/description/icon.png b/edi_oca/static/description/icon.png new file mode 100644 index 0000000000..a79752645c Binary files /dev/null and b/edi_oca/static/description/icon.png differ diff --git a/edi_oca/static/description/icon.svg b/edi_oca/static/description/icon.svg new file mode 100644 index 0000000000..3060d8aa58 --- /dev/null +++ b/edi_oca/static/description/icon.svg @@ -0,0 +1,142 @@ + + + + + + image/svg+xml + + icon + + + + + + + + + + + + + + + icon + + + + + + + + + diff --git a/edi_oca/static/description/index.html b/edi_oca/static/description/index.html new file mode 100644 index 0000000000..a38c3cc818 --- /dev/null +++ b/edi_oca/static/description/index.html @@ -0,0 +1,534 @@ + + + + + + +EDI + + + +
+

EDI

+ + +

Beta License: LGPL-3 OCA/edi Translate me on Weblate Try me on Runbot

+

Base EDI backend.

+

Provides following models:

+
    +
  1. EDI Backend, to centralize configuration
  2. +
  3. EDI Backend Type, to classify EDI backends (eg: UBL, GS1, e-invoice, pick-yours)
  4. +
  5. EDI Exchange Type, to define file types of exchange
  6. +
  7. EDI Exchange Record, to define a record exchanged between systems
  8. +
+

Also define a mixin to be inherited by records that will generate EDIs

+

Table of contents

+ +
+

Configuration

+

This module aims to provide an infrastructure to simplify interchangability of documents +between systems providing a configuration platform. +It will be inherited by other modules in order to define the proper implementations of +components.

+

In order to define a new Exchange Record, we need to configure:

+
    +
  • Backend Type
  • +
  • Exchange Type
  • +
  • Backend
  • +
  • Components
  • +
+
+

Component definition

+

The component usage must be defined like edi.{direction}.{kind}.{code} where:

+
    +
  • direction is output or input
  • +
  • kind can be: generate, send, check, process, receive
  • +
  • code is the {backend type code} or {backend type code}.{exchange type code}
  • +
+
+
+

User EDI generation

+

On the exchange type, it might be possible to define a set of models, a domain and a +snippet of code. +After defining this fields, we will automatically see buttons on the view to generate +the exchange records. +This configuration is useful to define a way of generation managed by user.

+
+
+

Exchange type rules configuration

+

Exchange types can be further configured with rules. +You can use rules to:

+
    +
  1. make buttons automatically appear in forms
  2. +
  3. define your own custom logic
  4. +
+

Go to an exchange type and go to the tab “Model rules”. +There you can add one or more rule, one per model. +On each rule you can define a domain or a snippet to activate it. +In case of a “Form button” kind, if the domain and/ the snippet is/are satisfied, +a form btn will appear on the top of the form. +This button can be used by the end user to manually generate an exchange. +If there’s more than a backend and the exchange type has not a backend set, +a wizard will appear asking to select a backend to be used for the exchange.

+

In case of “Custom” kind, you’ll have to define your own logic to do something.

+
+
+
+

Usage

+

After certain operations or manual execution, Exchange records will be generated. +This Exchange records might be input records or outputs records.

+

The change of state can be manually executed by the system or be managed through by +ir.cron.

+
+

Output Exchange records

+

An output record is intended to be used for exchange information from Odoo to another +system.

+

The flow of an output record should be:

+
    +
  • Creation
  • +
  • Generation of data
  • +
  • Validation of data
  • +
  • Sending data
  • +
  • Validation of data processed properly by the other party
  • +
+
+
+

Input Exchange records

+

An input record is intended to be used for exchange information another system to odoo.

+

The flow of an input record should be:

+
    +
  • Creation
  • +
  • Reception of data
  • +
  • Checking data
  • +
  • Processing data
  • +
+
+
+
+

Known issues / Roadmap

+
+

14.0.1.0.0

+

The module name has been changed from edi to edi_oca.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE
  • +
  • Creu Blanca
  • +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

simahawk etobella

+

This module is part of the OCA/edi project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/edi_oca/static/src/js/widget_edi.js b/edi_oca/static/src/js/widget_edi.js new file mode 100644 index 0000000000..16a129ce34 --- /dev/null +++ b/edi_oca/static/src/js/widget_edi.js @@ -0,0 +1,38 @@ +/* Copyright 2019 Tecnativa - David Vidal + * Copyright 2022 Camptocamp - Simone Orsi + * License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). */ + +odoo.define("edi_oca.FieldEdiConfiguration", function (require) { + "use strict"; + + var AbstractField = require("web.AbstractField"); + var field_registry = require("web.field_registry"); + + var FieldEdiConfiguration = AbstractField.extend({ + description: "Field for EDI Missing configurations", + // We want to maintain it black in order to show nothing on the header + template: "edi_oca.FieldEdiConfiguration", + supportedFieldTypes: ["serialized"], + events: _.extend({}, AbstractField.prototype.events, { + "click button": "_onClickCreateEDIRecord", + }), + _onClickCreateEDIRecord: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var button = ev.target.closest("button"); + var typeId = parseInt(button.dataset.typeId, 10); + var self = this; + this._rpc({ + model: this.model, + method: "edi_create_exchange_record", + args: [[this.res_id], typeId], + context: this.record.getContext({}), + }).then(function (action) { + self.trigger_up("do_action", {action: action}); + }); + }, + }); + + field_registry.add("edi_configuration", FieldEdiConfiguration); + return FieldEdiConfiguration; +}); diff --git a/edi_oca/static/src/xml/widget_edi.xml b/edi_oca/static/src/xml/widget_edi.xml new file mode 100644 index 0000000000..278610eca4 --- /dev/null +++ b/edi_oca/static/src/xml/widget_edi.xml @@ -0,0 +1,22 @@ + + + +
+ + + + + + + +
+
+
diff --git a/edi_oca/templates/assets.xml b/edi_oca/templates/assets.xml new file mode 100644 index 0000000000..d15b32a539 --- /dev/null +++ b/edi_oca/templates/assets.xml @@ -0,0 +1,8 @@ + + +