├── .copier-answers.yml ├── .editorconfig ├── .github └── workflows │ ├── pre-commit.yml │ ├── stale.yml │ └── test.yml ├── .gitignore ├── .oca └── oca-port │ └── blacklist │ └── webservice.json ├── .pre-commit-config.yaml ├── .pylintrc ├── .pylintrc-mandatory ├── .ruff.toml ├── LICENSE ├── README.md ├── checklog-odoo.cfg ├── endpoint ├── README.rst ├── __init__.py ├── __manifest__.py ├── controllers │ ├── __init__.py │ └── main.py ├── data │ └── server_action.xml ├── demo │ └── endpoint_demo.xml ├── i18n │ ├── endpoint.pot │ ├── fr.po │ ├── it.po │ └── zh_CN.po ├── models │ ├── __init__.py │ ├── endpoint_endpoint.py │ └── endpoint_mixin.py ├── pyproject.toml ├── readme │ ├── CONFIGURE.md │ ├── CONTRIBUTORS.md │ ├── DESCRIPTION.md │ └── ROADMAP.md ├── security │ ├── ir.model.access.csv │ └── ir_rule.xml ├── static │ └── description │ │ ├── icon.png │ │ └── index.html ├── tests │ ├── __init__.py │ ├── common.py │ ├── test_endpoint.py │ └── test_endpoint_controller.py └── views │ └── endpoint_view.xml ├── endpoint_route_handler ├── README.rst ├── __init__.py ├── __manifest__.py ├── controllers │ ├── __init__.py │ └── main.py ├── exceptions.py ├── i18n │ ├── endpoint_route_handler.pot │ ├── it.po │ └── zh_CN.po ├── models │ ├── __init__.py │ ├── endpoint_route_handler.py │ ├── endpoint_route_handler_tool.py │ ├── endpoint_route_sync_mixin.py │ └── ir_http.py ├── post_init_hook.py ├── pyproject.toml ├── readme │ ├── CONTRIBUTORS.md │ ├── DESCRIPTION.md │ ├── ROADMAP.md │ └── USAGE.md ├── registry.py ├── security │ └── ir.model.access.csv ├── static │ └── description │ │ ├── icon.png │ │ └── index.html └── tests │ ├── __init__.py │ ├── common.py │ ├── fake_controllers.py │ ├── test_endpoint.py │ ├── test_endpoint_controller.py │ └── test_registry.py ├── eslint.config.cjs ├── prettier.config.cjs ├── requirements.txt ├── setup └── _metapackage │ └── pyproject.toml └── webservice ├── README.rst ├── __init__.py ├── __manifest__.py ├── components ├── __init__.py ├── base_adapter.py └── request_adapter.py ├── controllers ├── __init__.py └── oauth2.py ├── i18n ├── fr.po ├── it.po └── webservice.pot ├── models ├── __init__.py └── webservice_backend.py ├── pyproject.toml ├── readme ├── CONTRIBUTORS.md └── DESCRIPTION.md ├── security ├── ir.model.access.csv └── ir_rule.xml ├── static └── description │ ├── icon.png │ └── index.html ├── tests ├── __init__.py ├── common.py ├── test_oauth2.py ├── test_utils.py └── test_webservice.py ├── utils.py └── views └── webservice_backend.xml /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Do NOT update manually; changes here will be overwritten by Copier 2 | _commit: v1.29 3 | _src_path: git+https://github.com/OCA/oca-addons-repo-template 4 | additional_ruff_rules: [] 5 | ci: GitHub 6 | convert_readme_fragments_to_markdown: true 7 | enable_checklog_odoo: true 8 | generate_requirements_txt: true 9 | github_check_license: true 10 | github_ci_extra_env: {} 11 | github_enable_codecov: true 12 | github_enable_makepot: true 13 | github_enable_stale_action: true 14 | github_enforce_dev_status_compatibility: true 15 | include_wkhtmltopdf: false 16 | odoo_test_flavor: Both 17 | odoo_version: 18.0 18 | org_name: Odoo Community Association (OCA) 19 | org_slug: OCA 20 | rebel_module_groups: [] 21 | repo_description: web-api 22 | repo_name: web-api 23 | repo_slug: web-api 24 | repo_website: https://github.com/OCA/web-api 25 | use_pyproject_toml: true 26 | use_ruff: true 27 | 28 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Configuration for known file extensions 2 | [*.{css,js,json,less,md,py,rst,sass,scss,xml,yaml,yml}] 3 | charset = utf-8 4 | end_of_line = lf 5 | indent_size = 4 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.{json,yml,yaml,rst,md}] 11 | indent_size = 2 12 | 13 | # Do not configure editor for libs and autogenerated content 14 | [{*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst}] 15 | charset = unset 16 | end_of_line = unset 17 | indent_size = unset 18 | indent_style = unset 19 | insert_final_newline = false 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "18.0*" 7 | push: 8 | branches: 9 | - "18.0" 10 | - "18.0-ocabot-*" 11 | 12 | jobs: 13 | pre-commit: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | - name: Get python version 21 | run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV 22 | - uses: actions/cache@v4 23 | with: 24 | path: ~/.cache/pre-commit 25 | key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} 26 | - name: Install pre-commit 27 | run: pip install pre-commit 28 | - name: Run pre-commit 29 | run: pre-commit run --all-files --show-diff-on-failure --color=always 30 | env: 31 | # Consider valid a PR that changes README fragments but doesn't 32 | # change the README.rst file itself. It's not really a problem 33 | # because the bot will update it anyway after merge. This way, we 34 | # lower the barrier for functional contributors that want to fix the 35 | # readme fragments, while still letting developers get README 36 | # auto-generated (which also helps functionals when using runboat). 37 | # DOCS https://pre-commit.com/#temporarily-disabling-hooks 38 | SKIP: oca-gen-addon-readme 39 | - name: Check that all files generated by pre-commit are in git 40 | run: | 41 | newfiles="$(git ls-files --others --exclude-from=.gitignore)" 42 | if [ "$newfiles" != "" ] ; then 43 | echo "Please check-in the following files:" 44 | echo "$newfiles" 45 | exit 1 46 | fi 47 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 12 * * 0" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Stale PRs and issues policy 12 | uses: actions/stale@v9 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | # General settings. 16 | ascending: true 17 | remove-stale-when-updated: true 18 | # Pull Requests settings. 19 | # 120+30 day stale policy for PRs 20 | # * Except PRs marked as "no stale" 21 | days-before-pr-stale: 120 22 | days-before-pr-close: 30 23 | exempt-pr-labels: "no stale" 24 | stale-pr-label: "stale" 25 | stale-pr-message: > 26 | There hasn't been any activity on this pull request in the past 4 months, so 27 | it has been marked as stale and it will be closed automatically if no 28 | further activity occurs in the next 30 days. 29 | 30 | If you want this PR to never become stale, please ask a PSC member to apply 31 | the "no stale" label. 32 | # Issues settings. 33 | # 180+30 day stale policy for open issues 34 | # * Except Issues marked as "no stale" 35 | days-before-issue-stale: 180 36 | days-before-issue-close: 30 37 | exempt-issue-labels: "no stale,needs more information" 38 | stale-issue-label: "stale" 39 | stale-issue-message: > 40 | There hasn't been any activity on this issue in the past 6 months, so it has 41 | been marked as stale and it will be closed automatically if no further 42 | activity occurs in the next 30 days. 43 | 44 | If you want this issue to never become stale, please ask a PSC member to 45 | apply the "no stale" label. 46 | 47 | # 15+30 day stale policy for issues pending more information 48 | # * Issues that are pending more information 49 | # * Except Issues marked as "no stale" 50 | - name: Needs more information stale issues policy 51 | uses: actions/stale@v9 52 | with: 53 | repo-token: ${{ secrets.GITHUB_TOKEN }} 54 | ascending: true 55 | only-labels: "needs more information" 56 | exempt-issue-labels: "no stale" 57 | days-before-stale: 15 58 | days-before-close: 30 59 | days-before-pr-stale: -1 60 | days-before-pr-close: -1 61 | remove-stale-when-updated: true 62 | stale-issue-label: "stale" 63 | stale-issue-message: > 64 | This issue needs more information and there hasn't been any activity 65 | recently, so it has been marked as stale and it will be closed automatically 66 | if no further activity occurs in the next 30 days. 67 | 68 | If you think this is a mistake, please ask a PSC member to remove the "needs 69 | more information" label. 70 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "18.0*" 7 | push: 8 | branches: 9 | - "18.0" 10 | - "18.0-ocabot-*" 11 | 12 | jobs: 13 | unreleased-deps: 14 | runs-on: ubuntu-latest 15 | name: Detect unreleased dependencies 16 | steps: 17 | - uses: actions/checkout@v4 18 | - run: | 19 | for reqfile in requirements.txt test-requirements.txt ; do 20 | if [ -f ${reqfile} ] ; then 21 | result=0 22 | # reject non-comment lines that contain a / (i.e. URLs, relative paths) 23 | grep "^[^#].*/" ${reqfile} || result=$? 24 | if [ $result -eq 0 ] ; then 25 | echo "Unreleased dependencies found in ${reqfile}." 26 | exit 1 27 | fi 28 | fi 29 | done 30 | test: 31 | runs-on: ubuntu-22.04 32 | container: ${{ matrix.container }} 33 | name: ${{ matrix.name }} 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | include: 38 | - container: ghcr.io/oca/oca-ci/py3.10-odoo18.0:latest 39 | name: test with Odoo 40 | - container: ghcr.io/oca/oca-ci/py3.10-ocb18.0:latest 41 | name: test with OCB 42 | makepot: "true" 43 | services: 44 | postgres: 45 | image: postgres:12.0 46 | env: 47 | POSTGRES_USER: odoo 48 | POSTGRES_PASSWORD: odoo 49 | POSTGRES_DB: odoo 50 | ports: 51 | - 5432:5432 52 | env: 53 | OCA_ENABLE_CHECKLOG_ODOO: "1" 54 | steps: 55 | - uses: actions/checkout@v4 56 | with: 57 | persist-credentials: false 58 | - name: Install addons and dependencies 59 | run: oca_install_addons 60 | - name: Check licenses 61 | run: manifestoo -d . check-licenses 62 | - name: Check development status 63 | run: manifestoo -d . check-dev-status --default-dev-status=Beta 64 | - name: Initialize test db 65 | run: oca_init_test_database 66 | - name: Run tests 67 | run: oca_run_tests 68 | - uses: codecov/codecov-action@v4 69 | with: 70 | token: ${{ secrets.CODECOV_TOKEN }} 71 | - name: Update .pot files 72 | run: oca_export_and_push_pot https://x-access-token:${{ secrets.GIT_PUSH_TOKEN }}@github.com/${{ github.repository }} 73 | if: ${{ matrix.makepot == 'true' && github.event_name == 'push' && github.repository_owner == 'OCA' }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | /.venv 5 | /.pytest_cache 6 | /.ruff_cache 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | bin/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | eggs/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | *.eggs 27 | 28 | # Windows installers 29 | *.msi 30 | 31 | # Debian packages 32 | *.deb 33 | 34 | # Redhat packages 35 | *.rpm 36 | 37 | # MacOS packages 38 | *.dmg 39 | *.pkg 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .coverage 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | 53 | # Translations 54 | *.mo 55 | 56 | # Pycharm 57 | .idea 58 | 59 | # Eclipse 60 | .settings 61 | 62 | # Visual Studio cache/options directory 63 | .vs/ 64 | .vscode 65 | 66 | # OSX Files 67 | .DS_Store 68 | 69 | # Django stuff: 70 | *.log 71 | 72 | # Mr Developer 73 | .mr.developer.cfg 74 | .project 75 | .pydevproject 76 | 77 | # Rope 78 | .ropeproject 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # Backup files 84 | *~ 85 | *.swp 86 | 87 | # OCA rules 88 | !static/lib/ 89 | -------------------------------------------------------------------------------- /.oca/oca-port/blacklist/webservice.json: -------------------------------------------------------------------------------- 1 | { 2 | "pull_requests": { 3 | "OCA/web-api#48": "(auto) Nothing to port from PR #48" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: | 2 | (?x) 3 | # NOT INSTALLABLE ADDONS 4 | # END NOT INSTALLABLE ADDONS 5 | # Files and folders generated by bots, to avoid loops 6 | ^setup/|/static/description/index\.html$| 7 | # We don't want to mess with tool-generated files 8 | .svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|^eslint.config.cjs|^prettier.config.cjs| 9 | # Maybe reactivate this when all README files include prettier ignore tags? 10 | ^README\.md$| 11 | # Library files can have extraneous formatting (even minimized) 12 | /static/(src/)?lib/| 13 | # Repos using Sphinx to generate docs don't need prettying 14 | ^docs/_templates/.*\.html$| 15 | # Don't bother non-technical authors with formatting issues in docs 16 | readme/.*\.(rst|md)$| 17 | # Ignore build and dist directories in addons 18 | /build/|/dist/| 19 | # Ignore test files in addons 20 | /tests/samples/.*| 21 | # You don't usually want a bot to modify your legal texts 22 | (LICENSE.*|COPYING.*) 23 | default_language_version: 24 | python: python3 25 | node: "22.9.0" 26 | repos: 27 | - repo: local 28 | hooks: 29 | # These files are most likely copier diff rejection junks; if found, 30 | # review them manually, fix the problem (if needed) and remove them 31 | - id: forbidden-files 32 | name: forbidden files 33 | entry: found forbidden files; remove them 34 | language: fail 35 | files: "\\.rej$" 36 | - id: en-po-files 37 | name: en.po files cannot exist 38 | entry: found a en.po file 39 | language: fail 40 | files: '[a-zA-Z0-9_]*/i18n/en\.po$' 41 | - repo: https://github.com/sbidoul/whool 42 | rev: v1.2 43 | hooks: 44 | - id: whool-init 45 | - repo: https://github.com/oca/maintainer-tools 46 | rev: bf9ecb9938b6a5deca0ff3d870fbd3f33341fded 47 | hooks: 48 | # update the NOT INSTALLABLE ADDONS section above 49 | - id: oca-update-pre-commit-excluded-addons 50 | - id: oca-fix-manifest-website 51 | args: ["https://github.com/OCA/web-api"] 52 | - id: oca-gen-addon-readme 53 | args: 54 | - --addons-dir=. 55 | - --branch=18.0 56 | - --org-name=OCA 57 | - --repo-name=web-api 58 | - --if-source-changed 59 | - --keep-source-digest 60 | - --convert-fragments-to-markdown 61 | - id: oca-gen-external-dependencies 62 | - repo: https://github.com/OCA/odoo-pre-commit-hooks 63 | rev: v0.0.33 64 | hooks: 65 | - id: oca-checks-odoo-module 66 | - id: oca-checks-po 67 | args: 68 | - --disable=po-pretty-format 69 | - repo: local 70 | hooks: 71 | - id: prettier 72 | name: prettier (with plugin-xml) 73 | entry: prettier 74 | args: 75 | - --write 76 | - --list-different 77 | - --ignore-unknown 78 | types: [text] 79 | files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$ 80 | language: node 81 | additional_dependencies: 82 | - "prettier@3.3.3" 83 | - "@prettier/plugin-xml@3.4.1" 84 | - repo: local 85 | hooks: 86 | - id: eslint 87 | name: eslint 88 | entry: eslint 89 | args: 90 | - --color 91 | - --fix 92 | verbose: true 93 | types: [javascript] 94 | language: node 95 | additional_dependencies: 96 | - "eslint@9.12.0" 97 | - "eslint-plugin-jsdoc@50.3.1" 98 | - repo: https://github.com/pre-commit/pre-commit-hooks 99 | rev: v4.6.0 100 | hooks: 101 | - id: trailing-whitespace 102 | # exclude autogenerated files 103 | exclude: /README\.rst$|\.pot?$ 104 | - id: end-of-file-fixer 105 | # exclude autogenerated files 106 | exclude: /README\.rst$|\.pot?$ 107 | - id: debug-statements 108 | - id: fix-encoding-pragma 109 | args: ["--remove"] 110 | - id: check-case-conflict 111 | - id: check-docstring-first 112 | - id: check-executables-have-shebangs 113 | - id: check-merge-conflict 114 | # exclude files where underlines are not distinguishable from merge conflicts 115 | exclude: /README\.rst$|^docs/.*\.rst$ 116 | - id: check-symlinks 117 | - id: check-xml 118 | - id: mixed-line-ending 119 | args: ["--fix=lf"] 120 | - repo: https://github.com/astral-sh/ruff-pre-commit 121 | rev: v0.6.8 122 | hooks: 123 | - id: ruff 124 | args: [--fix, --exit-non-zero-on-fix] 125 | - id: ruff-format 126 | - repo: https://github.com/OCA/pylint-odoo 127 | rev: v9.1.3 128 | hooks: 129 | - id: pylint_odoo 130 | name: pylint with optional checks 131 | args: 132 | - --rcfile=.pylintrc 133 | - --exit-zero 134 | verbose: true 135 | - id: pylint_odoo 136 | args: 137 | - --rcfile=.pylintrc-mandatory 138 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | [MASTER] 4 | load-plugins=pylint_odoo 5 | score=n 6 | 7 | [ODOOLINT] 8 | readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" 9 | manifest-required-authors=Odoo Community Association (OCA) 10 | manifest-required-keys=license 11 | manifest-deprecated-keys=description,active 12 | license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 13 | valid-odoo-versions=18.0 14 | 15 | [MESSAGES CONTROL] 16 | disable=all 17 | 18 | # This .pylintrc contains optional AND mandatory checks and is meant to be 19 | # loaded in an IDE to have it check everything, in the hope this will make 20 | # optional checks more visible to contributors who otherwise never look at a 21 | # green travis to see optional checks that failed. 22 | # .pylintrc-mandatory containing only mandatory checks is used the pre-commit 23 | # config as a blocking check. 24 | 25 | enable=anomalous-backslash-in-string, 26 | api-one-deprecated, 27 | api-one-multi-together, 28 | assignment-from-none, 29 | attribute-deprecated, 30 | class-camelcase, 31 | dangerous-default-value, 32 | dangerous-view-replace-wo-priority, 33 | development-status-allowed, 34 | duplicate-id-csv, 35 | duplicate-key, 36 | duplicate-xml-fields, 37 | duplicate-xml-record-id, 38 | eval-referenced, 39 | eval-used, 40 | incoherent-interpreter-exec-perm, 41 | license-allowed, 42 | manifest-author-string, 43 | manifest-deprecated-key, 44 | manifest-required-author, 45 | manifest-required-key, 46 | manifest-version-format, 47 | method-compute, 48 | method-inverse, 49 | method-required-super, 50 | method-search, 51 | openerp-exception-warning, 52 | pointless-statement, 53 | pointless-string-statement, 54 | print-used, 55 | redundant-keyword-arg, 56 | redundant-modulename-xml, 57 | reimported, 58 | relative-import, 59 | return-in-init, 60 | rst-syntax-error, 61 | sql-injection, 62 | too-few-format-args, 63 | translation-field, 64 | translation-required, 65 | unreachable, 66 | use-vim-comment, 67 | wrong-tabs-instead-of-spaces, 68 | xml-syntax-error, 69 | attribute-string-redundant, 70 | character-not-valid-in-resource-link, 71 | consider-merging-classes-inherited, 72 | context-overridden, 73 | create-user-wo-reset-password, 74 | dangerous-filter-wo-user, 75 | dangerous-qweb-replace-wo-priority, 76 | deprecated-data-xml-node, 77 | deprecated-openerp-xml-node, 78 | duplicate-po-message-definition, 79 | except-pass, 80 | file-not-used, 81 | invalid-commit, 82 | manifest-maintainers-list, 83 | missing-newline-extrafiles, 84 | missing-readme, 85 | missing-return, 86 | odoo-addons-relative-import, 87 | old-api7-method-defined, 88 | po-msgstr-variables, 89 | po-syntax-error, 90 | renamed-field-parameter, 91 | resource-not-exist, 92 | str-format-used, 93 | test-folder-imported, 94 | translation-contains-variable, 95 | translation-positional-used, 96 | unnecessary-utf8-coding-comment, 97 | website-manifest-key-not-valid-uri, 98 | xml-attribute-translatable, 99 | xml-deprecated-qweb-directive, 100 | xml-deprecated-tree-attribute, 101 | external-request-timeout, 102 | # messages that do not cause the lint step to fail 103 | consider-merging-classes-inherited, 104 | create-user-wo-reset-password, 105 | dangerous-filter-wo-user, 106 | deprecated-module, 107 | file-not-used, 108 | invalid-commit, 109 | missing-manifest-dependency, 110 | missing-newline-extrafiles, 111 | missing-readme, 112 | no-utf8-coding-comment, 113 | odoo-addons-relative-import, 114 | old-api7-method-defined, 115 | redefined-builtin, 116 | too-complex, 117 | unnecessary-utf8-coding-comment 118 | 119 | 120 | [REPORTS] 121 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 122 | output-format=colorized 123 | reports=no 124 | -------------------------------------------------------------------------------- /.pylintrc-mandatory: -------------------------------------------------------------------------------- 1 | 2 | [MASTER] 3 | load-plugins=pylint_odoo 4 | score=n 5 | 6 | [ODOOLINT] 7 | readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" 8 | manifest-required-authors=Odoo Community Association (OCA) 9 | manifest-required-keys=license 10 | manifest-deprecated-keys=description,active 11 | license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 12 | valid-odoo-versions=18.0 13 | 14 | [MESSAGES CONTROL] 15 | disable=all 16 | 17 | enable=anomalous-backslash-in-string, 18 | api-one-deprecated, 19 | api-one-multi-together, 20 | assignment-from-none, 21 | attribute-deprecated, 22 | class-camelcase, 23 | dangerous-default-value, 24 | dangerous-view-replace-wo-priority, 25 | development-status-allowed, 26 | duplicate-id-csv, 27 | duplicate-key, 28 | duplicate-xml-fields, 29 | duplicate-xml-record-id, 30 | eval-referenced, 31 | eval-used, 32 | incoherent-interpreter-exec-perm, 33 | license-allowed, 34 | manifest-author-string, 35 | manifest-deprecated-key, 36 | manifest-required-author, 37 | manifest-required-key, 38 | manifest-version-format, 39 | method-compute, 40 | method-inverse, 41 | method-required-super, 42 | method-search, 43 | openerp-exception-warning, 44 | pointless-statement, 45 | pointless-string-statement, 46 | print-used, 47 | redundant-keyword-arg, 48 | redundant-modulename-xml, 49 | reimported, 50 | relative-import, 51 | return-in-init, 52 | rst-syntax-error, 53 | sql-injection, 54 | too-few-format-args, 55 | translation-field, 56 | translation-required, 57 | unreachable, 58 | use-vim-comment, 59 | wrong-tabs-instead-of-spaces, 60 | xml-syntax-error, 61 | attribute-string-redundant, 62 | character-not-valid-in-resource-link, 63 | consider-merging-classes-inherited, 64 | context-overridden, 65 | create-user-wo-reset-password, 66 | dangerous-filter-wo-user, 67 | dangerous-qweb-replace-wo-priority, 68 | deprecated-data-xml-node, 69 | deprecated-openerp-xml-node, 70 | duplicate-po-message-definition, 71 | except-pass, 72 | file-not-used, 73 | invalid-commit, 74 | manifest-maintainers-list, 75 | missing-newline-extrafiles, 76 | missing-readme, 77 | missing-return, 78 | odoo-addons-relative-import, 79 | old-api7-method-defined, 80 | po-msgstr-variables, 81 | po-syntax-error, 82 | renamed-field-parameter, 83 | resource-not-exist, 84 | str-format-used, 85 | test-folder-imported, 86 | translation-contains-variable, 87 | translation-positional-used, 88 | unnecessary-utf8-coding-comment, 89 | website-manifest-key-not-valid-uri, 90 | xml-attribute-translatable, 91 | xml-deprecated-qweb-directive, 92 | xml-deprecated-tree-attribute, 93 | external-request-timeout 94 | 95 | [REPORTS] 96 | msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} 97 | output-format=colorized 98 | reports=no 99 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | 2 | target-version = "py310" 3 | fix = true 4 | 5 | [lint] 6 | extend-select = [ 7 | "B", 8 | "C90", 9 | "E501", # line too long (default 88) 10 | "I", # isort 11 | "UP", # pyupgrade 12 | ] 13 | extend-safe-fixes = ["UP008"] 14 | exclude = ["setup/*"] 15 | 16 | [format] 17 | exclude = ["setup/*"] 18 | 19 | [lint.per-file-ignores] 20 | "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py 21 | "__manifest__.py" = ["B018"] # useless expression 22 | 23 | [lint.isort] 24 | section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"] 25 | 26 | [lint.isort.sections] 27 | "odoo" = ["odoo"] 28 | "odoo-addons" = ["odoo.addons"] 29 | 30 | [lint.mccabe] 31 | max-complexity = 16 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Runboat](https://img.shields.io/badge/runboat-Try%20me-875A7B.png)](https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=18.0) 3 | [![Pre-commit Status](https://github.com/OCA/web-api/actions/workflows/pre-commit.yml/badge.svg?branch=18.0)](https://github.com/OCA/web-api/actions/workflows/pre-commit.yml?query=branch%3A18.0) 4 | [![Build Status](https://github.com/OCA/web-api/actions/workflows/test.yml/badge.svg?branch=18.0)](https://github.com/OCA/web-api/actions/workflows/test.yml?query=branch%3A18.0) 5 | [![codecov](https://codecov.io/gh/OCA/web-api/branch/18.0/graph/badge.svg)](https://codecov.io/gh/OCA/web-api) 6 | [![Translation Status](https://translation.odoo-community.org/widgets/web-api-18-0/-/svg-badge.svg)](https://translation.odoo-community.org/engage/web-api-18-0/?utm_source=widget) 7 | 8 | 9 | 10 | # web-api 11 | 12 | web-api 13 | 14 | 15 | 16 | 17 | 18 | [//]: # (addons) 19 | 20 | Available addons 21 | ---------------- 22 | addon | version | maintainers | summary 23 | --- | --- | --- | --- 24 | [endpoint](endpoint/) | 18.0.1.1.0 | simahawk | Provide custom endpoint machinery. 25 | [endpoint_route_handler](endpoint_route_handler/) | 18.0.1.0.0 | simahawk | Provide mixin and tool to generate custom endpoints on the fly. 26 | [webservice](webservice/) | 18.0.1.1.0 | etobella | Defines webservice abstract definition to be used generally 27 | 28 | [//]: # (end addons) 29 | 30 | 31 | 32 | ## Licenses 33 | 34 | This repository is licensed under [AGPL-3.0](LICENSE). 35 | 36 | However, each module can have a totally different license, as long as they adhere to Odoo Community Association (OCA) 37 | policy. Consult each module's `__manifest__.py` file, which contains a `license` key 38 | that explains its license. 39 | 40 | ---- 41 | OCA, or the [Odoo Community Association](http://odoo-community.org/), is a nonprofit 42 | organization whose mission is to support the collaborative development of Odoo features 43 | and promote its widespread use. 44 | -------------------------------------------------------------------------------- /checklog-odoo.cfg: -------------------------------------------------------------------------------- 1 | [checklog-odoo] 2 | ignore= 3 | WARNING.* 0 failed, 0 error\(s\).* 4 | -------------------------------------------------------------------------------- /endpoint/README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Endpoint 3 | ======== 4 | 5 | .. 6 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 7 | !! This file is generated by oca-gen-addon-readme !! 8 | !! changes will be overwritten. !! 9 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 10 | !! source digest: sha256:c9d4f75711d0c6f4ac7116e0847b603fcca5723ba984a2645778bc0bd655c6b2 11 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 12 | 13 | .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png 14 | :target: https://odoo-community.org/page/development-status 15 | :alt: Beta 16 | .. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png 17 | :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html 18 | :alt: License: LGPL-3 19 | .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github 20 | :target: https://github.com/OCA/web-api/tree/18.0/endpoint 21 | :alt: OCA/web-api 22 | .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png 23 | :target: https://translation.odoo-community.org/projects/web-api-18-0/web-api-18-0-endpoint 24 | :alt: Translate me on Weblate 25 | .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png 26 | :target: https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=18.0 27 | :alt: Try me on Runboat 28 | 29 | |badge1| |badge2| |badge3| |badge4| |badge5| 30 | 31 | Provide an endpoint framework allowing users to define their own custom 32 | endpoint. 33 | 34 | Thanks to endpoint mixin the endpoint records are automatically 35 | registered as real Odoo routes. 36 | 37 | You can easily code what you want in the code snippet. 38 | 39 | NOTE: for security reasons any kind of RPC call is blocked on endpoint 40 | records. 41 | 42 | **Table of contents** 43 | 44 | .. contents:: 45 | :local: 46 | 47 | Configuration 48 | ============= 49 | 50 | Go to "Technical -> Endpoints" and create a new endpoint. 51 | 52 | Known issues / Roadmap 53 | ====================== 54 | 55 | - add validation of request data 56 | - add api docs generation 57 | - handle multiple routes per endpoint 58 | 59 | Bug Tracker 60 | =========== 61 | 62 | Bugs are tracked on `GitHub Issues `_. 63 | In case of trouble, please check there if your issue has already been reported. 64 | If you spotted it first, help us to smash it by providing a detailed and welcomed 65 | `feedback `_. 66 | 67 | Do not contact contributors directly about support or help with technical issues. 68 | 69 | Credits 70 | ======= 71 | 72 | Authors 73 | ------- 74 | 75 | * Camptocamp 76 | 77 | Contributors 78 | ------------ 79 | 80 | - Simone Orsi 81 | 82 | Maintainers 83 | ----------- 84 | 85 | This module is maintained by the OCA. 86 | 87 | .. image:: https://odoo-community.org/logo.png 88 | :alt: Odoo Community Association 89 | :target: https://odoo-community.org 90 | 91 | OCA, or the Odoo Community Association, is a nonprofit organization whose 92 | mission is to support the collaborative development of Odoo features and 93 | promote its widespread use. 94 | 95 | .. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px 96 | :target: https://github.com/simahawk 97 | :alt: simahawk 98 | 99 | Current `maintainer `__: 100 | 101 | |maintainer-simahawk| 102 | 103 | This module is part of the `OCA/web-api `_ project on GitHub. 104 | 105 | You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. 106 | -------------------------------------------------------------------------------- /endpoint/__init__.py: -------------------------------------------------------------------------------- 1 | from . import controllers 2 | from . import models 3 | -------------------------------------------------------------------------------- /endpoint/__manifest__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 3 | 4 | { 5 | "name": "Endpoint", 6 | "summary": """Provide custom endpoint machinery.""", 7 | "version": "18.0.1.1.0", 8 | "license": "LGPL-3", 9 | "development_status": "Beta", 10 | "author": "Camptocamp,Odoo Community Association (OCA)", 11 | "maintainers": ["simahawk"], 12 | "website": "https://github.com/OCA/web-api", 13 | "depends": ["endpoint_route_handler", "rpc_helper"], 14 | "data": [ 15 | "data/server_action.xml", 16 | "security/ir.model.access.csv", 17 | "security/ir_rule.xml", 18 | "views/endpoint_view.xml", 19 | ], 20 | "demo": [ 21 | "demo/endpoint_demo.xml", 22 | ], 23 | } 24 | -------------------------------------------------------------------------------- /endpoint/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | -------------------------------------------------------------------------------- /endpoint/controllers/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | 6 | import json 7 | 8 | from werkzeug.exceptions import NotFound 9 | 10 | from odoo import http 11 | from odoo.http import Response, request 12 | 13 | 14 | class EndpointControllerMixin: 15 | def _handle_endpoint(self, env, model, endpoint_route, **params): 16 | endpoint = self._find_endpoint(env, model, endpoint_route) 17 | if not endpoint: 18 | raise NotFound() 19 | endpoint._validate_request(request) 20 | result = endpoint._handle_request(request) 21 | return self._handle_result(result) 22 | 23 | def _handle_result(self, result): 24 | response = result.get("response") 25 | if isinstance(response, Response): 26 | # Full response already provided 27 | return response 28 | payload = result.get("payload", "") 29 | status = result.get("status_code", 200) 30 | headers = result.get("headers", {}) 31 | return self._make_json_response(payload, headers=headers, status=status) 32 | 33 | # TODO: probably not needed anymore as controllers are automatically registered 34 | def _make_json_response(self, payload, headers=None, status=200, **kw): 35 | # TODO: guess out type? 36 | data = json.dumps(payload) 37 | if headers is None: 38 | headers = {} 39 | headers["Content-Type"] = "application/json" 40 | resp = request.make_response(data, headers=headers) 41 | resp.status = str(status) 42 | return resp 43 | 44 | def _find_endpoint(self, env, model, endpoint_route): 45 | return env[model]._find_endpoint(endpoint_route) 46 | 47 | def auto_endpoint(self, model, endpoint_route, **params): 48 | """Default method to handle auto-generated endpoints""" 49 | env = request.env 50 | return self._handle_endpoint(env, model, endpoint_route, **params) 51 | 52 | 53 | class EndpointController(http.Controller, EndpointControllerMixin): 54 | pass 55 | -------------------------------------------------------------------------------- /endpoint/data/server_action.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sync registry 5 | ir.actions.server 6 | 7 | 8 | action 9 | code 10 | records.write({"registry_sync": True}) 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /endpoint/demo/endpoint_demo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Demo Endpoint 1 5 | /demo/one 6 | GET 7 | code 8 | 9 | result = {"response": Response("ok")} 10 | 11 | 12 | 13 | 14 | Demo Endpoint 2 15 | /demo/as_demo_user 16 | GET 17 | public 18 | 19 | code 20 | 21 | result = {"response": Response("My name is: " + user.name)} 22 | 23 | 24 | 25 | 26 | Demo Endpoint 3 27 | /demo/json_data 28 | GET 29 | public 30 | 31 | code 32 | 33 | result = {"payload": {"a": 1, "b": 2}} 34 | 35 | 36 | 37 | 38 | Demo Endpoint 4 39 | /demo/raise_not_found 40 | GET 41 | public 42 | 43 | code 44 | 45 | raise werkzeug.exceptions.NotFound() 46 | 47 | 48 | 49 | 50 | Demo Endpoint 5 51 | /demo/raise_validation_error 52 | GET 53 | public 54 | 55 | code 56 | 57 | raise exceptions.ValidationError("Sorry, you cannot do this!") 58 | 59 | 60 | 61 | 62 | Demo Endpoint 6 63 | /demo/value_from_request 64 | GET 65 | public 66 | 67 | code 68 | 69 | result = {"response": Response(request.params.get("your_name", ""))} 70 | 71 | 72 | 73 | 74 | Demo Endpoint 7 75 | /demo/bad_method 76 | GET 77 | code 78 | public 79 | 80 | 81 | result = {"payload": "Method used:" + request.httprequest.method} 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /endpoint/i18n/endpoint.pot: -------------------------------------------------------------------------------- 1 | # Translation of Odoo Server. 2 | # This file contains the translation of the following modules: 3 | # * endpoint 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Odoo Server 18.0\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "Last-Translator: \n" 10 | "Language-Team: \n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Type: text/plain; charset=UTF-8\n" 13 | "Content-Transfer-Encoding: \n" 14 | "Plural-Forms: \n" 15 | 16 | #. module: endpoint 17 | #. odoo-python 18 | #: code:addons/endpoint/models/endpoint_mixin.py:0 19 | msgid "'Exec as user' is mandatory for public endpoints." 20 | msgstr "" 21 | 22 | #. module: endpoint 23 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active 24 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active 25 | msgid "Active" 26 | msgstr "" 27 | 28 | #. module: endpoint 29 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 30 | msgid "All" 31 | msgstr "" 32 | 33 | #. module: endpoint 34 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 35 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 36 | msgid "Archived" 37 | msgstr "" 38 | 39 | #. module: endpoint 40 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 41 | msgid "Auth" 42 | msgstr "" 43 | 44 | #. module: endpoint 45 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type 46 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type 47 | msgid "Auth Type" 48 | msgstr "" 49 | 50 | #. module: endpoint 51 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 52 | msgid "Code" 53 | msgstr "" 54 | 55 | #. module: endpoint 56 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 57 | msgid "Code Help" 58 | msgstr "" 59 | 60 | #. module: endpoint 61 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet 62 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet 63 | msgid "Code Snippet" 64 | msgstr "" 65 | 66 | #. module: endpoint 67 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs 68 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs 69 | msgid "Code Snippet Docs" 70 | msgstr "" 71 | 72 | #. module: endpoint 73 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id 74 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id 75 | msgid "Company" 76 | msgstr "" 77 | 78 | #. module: endpoint 79 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid 80 | msgid "Created by" 81 | msgstr "" 82 | 83 | #. module: endpoint 84 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date 85 | msgid "Created on" 86 | msgstr "" 87 | 88 | #. module: endpoint 89 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf 90 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf 91 | msgid "Csrf" 92 | msgstr "" 93 | 94 | #. module: endpoint 95 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name 96 | msgid "Display Name" 97 | msgstr "" 98 | 99 | #. module: endpoint 100 | #: model:ir.model,name:endpoint.model_endpoint_endpoint 101 | msgid "Endpoint" 102 | msgstr "" 103 | 104 | #. module: endpoint 105 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash 106 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash 107 | msgid "Endpoint Hash" 108 | msgstr "" 109 | 110 | #. module: endpoint 111 | #: model:ir.model,name:endpoint.model_endpoint_mixin 112 | msgid "Endpoint mixin" 113 | msgstr "" 114 | 115 | #. module: endpoint 116 | #: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window 117 | #: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu 118 | msgid "Endpoints" 119 | msgstr "" 120 | 121 | #. module: endpoint 122 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id 123 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id 124 | msgid "Exec As User" 125 | msgstr "" 126 | 127 | #. module: endpoint 128 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode 129 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode 130 | msgid "Exec Mode" 131 | msgstr "" 132 | 133 | #. module: endpoint 134 | #. odoo-python 135 | #: code:addons/endpoint/models/endpoint_mixin.py:0 136 | msgid "Exec mode is set to `Code`: you must provide a piece of code" 137 | msgstr "" 138 | 139 | #. module: endpoint 140 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id 141 | msgid "ID" 142 | msgstr "" 143 | 144 | #. module: endpoint 145 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash 146 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash 147 | msgid "Identify the route with its main params" 148 | msgstr "" 149 | 150 | #. module: endpoint 151 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid 152 | msgid "Last Updated by" 153 | msgstr "" 154 | 155 | #. module: endpoint 156 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date 157 | msgid "Last Updated on" 158 | msgstr "" 159 | 160 | #. module: endpoint 161 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 162 | msgid "Main" 163 | msgstr "" 164 | 165 | #. module: endpoint 166 | #. odoo-python 167 | #: code:addons/endpoint/models/endpoint_mixin.py:0 168 | msgid "Missing handler for exec mode %s" 169 | msgstr "" 170 | 171 | #. module: endpoint 172 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name 173 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name 174 | msgid "Name" 175 | msgstr "" 176 | 177 | #. module: endpoint 178 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync 179 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync 180 | msgid "" 181 | "ON: the record has been modified and registry was not notified.\n" 182 | "No change will be active until this flag is set to false via proper action.\n" 183 | "\n" 184 | "OFF: record in line with the registry, nothing to do." 185 | msgstr "" 186 | 187 | #. module: endpoint 188 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__readonly 189 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__readonly 190 | msgid "Readonly" 191 | msgstr "" 192 | 193 | #. module: endpoint 194 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync 195 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync 196 | msgid "Registry Sync" 197 | msgstr "" 198 | 199 | #. module: endpoint 200 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 201 | msgid "" 202 | "Registry out of sync. Likely the record has been modified but not sync'ed " 203 | "with the routing registry." 204 | msgstr "" 205 | 206 | #. module: endpoint 207 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 208 | msgid "Request" 209 | msgstr "" 210 | 211 | #. module: endpoint 212 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type 213 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type 214 | msgid "Request Content Type" 215 | msgstr "" 216 | 217 | #. module: endpoint 218 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method 219 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method 220 | msgid "Request Method" 221 | msgstr "" 222 | 223 | #. module: endpoint 224 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route 225 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route 226 | msgid "Route" 227 | msgstr "" 228 | 229 | #. module: endpoint 230 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group 231 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group 232 | msgid "Route Group" 233 | msgstr "" 234 | 235 | #. module: endpoint 236 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type 237 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type 238 | msgid "Route Type" 239 | msgstr "" 240 | 241 | #. module: endpoint 242 | #: model:ir.actions.server,name:endpoint.server_action_registry_sync 243 | msgid "Sync registry" 244 | msgstr "" 245 | 246 | #. module: endpoint 247 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 248 | msgid "To sync" 249 | msgstr "" 250 | 251 | #. module: endpoint 252 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 253 | msgid "" 254 | "Use the action \"Sync registry\" to make changes effective once you are done" 255 | " with edits and creates." 256 | msgstr "" 257 | 258 | #. module: endpoint 259 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group 260 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group 261 | msgid "Use this to classify routes together" 262 | msgstr "" 263 | 264 | #. module: endpoint 265 | #. odoo-python 266 | #: code:addons/endpoint/models/endpoint_mixin.py:0 267 | msgid "code_snippet should return a dict into `result` variable." 268 | msgstr "" 269 | -------------------------------------------------------------------------------- /endpoint/i18n/fr.po: -------------------------------------------------------------------------------- 1 | # Translation of Odoo Server. 2 | # This file contains the translation of the following modules: 3 | # * endpoint 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Odoo Server 14.0\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "Last-Translator: Automatically generated\n" 10 | "Language-Team: none\n" 11 | "Language: fr\n" 12 | "MIME-Version: 1.0\n" 13 | "Content-Type: text/plain; charset=UTF-8\n" 14 | "Content-Transfer-Encoding: \n" 15 | "Plural-Forms: nplurals=2; plural=n > 1;\n" 16 | 17 | #. module: endpoint 18 | #. odoo-python 19 | #: code:addons/endpoint/models/endpoint_mixin.py:0 20 | msgid "'Exec as user' is mandatory for public endpoints." 21 | msgstr "" 22 | 23 | #. module: endpoint 24 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active 25 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active 26 | msgid "Active" 27 | msgstr "" 28 | 29 | #. module: endpoint 30 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 31 | msgid "All" 32 | msgstr "" 33 | 34 | #. module: endpoint 35 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 36 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 37 | msgid "Archived" 38 | msgstr "" 39 | 40 | #. module: endpoint 41 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 42 | msgid "Auth" 43 | msgstr "" 44 | 45 | #. module: endpoint 46 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type 47 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type 48 | msgid "Auth Type" 49 | msgstr "" 50 | 51 | #. module: endpoint 52 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 53 | msgid "Code" 54 | msgstr "" 55 | 56 | #. module: endpoint 57 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 58 | msgid "Code Help" 59 | msgstr "" 60 | 61 | #. module: endpoint 62 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet 63 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet 64 | msgid "Code Snippet" 65 | msgstr "" 66 | 67 | #. module: endpoint 68 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs 69 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs 70 | msgid "Code Snippet Docs" 71 | msgstr "" 72 | 73 | #. module: endpoint 74 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id 75 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id 76 | msgid "Company" 77 | msgstr "" 78 | 79 | #. module: endpoint 80 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid 81 | msgid "Created by" 82 | msgstr "" 83 | 84 | #. module: endpoint 85 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date 86 | msgid "Created on" 87 | msgstr "" 88 | 89 | #. module: endpoint 90 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf 91 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf 92 | msgid "Csrf" 93 | msgstr "" 94 | 95 | #. module: endpoint 96 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name 97 | msgid "Display Name" 98 | msgstr "" 99 | 100 | #. module: endpoint 101 | #: model:ir.model,name:endpoint.model_endpoint_endpoint 102 | msgid "Endpoint" 103 | msgstr "" 104 | 105 | #. module: endpoint 106 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash 107 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash 108 | msgid "Endpoint Hash" 109 | msgstr "" 110 | 111 | #. module: endpoint 112 | #: model:ir.model,name:endpoint.model_endpoint_mixin 113 | msgid "Endpoint mixin" 114 | msgstr "" 115 | 116 | #. module: endpoint 117 | #: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window 118 | #: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu 119 | msgid "Endpoints" 120 | msgstr "" 121 | 122 | #. module: endpoint 123 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id 124 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id 125 | msgid "Exec As User" 126 | msgstr "" 127 | 128 | #. module: endpoint 129 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode 130 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode 131 | msgid "Exec Mode" 132 | msgstr "" 133 | 134 | #. module: endpoint 135 | #. odoo-python 136 | #: code:addons/endpoint/models/endpoint_mixin.py:0 137 | msgid "Exec mode is set to `Code`: you must provide a piece of code" 138 | msgstr "" 139 | 140 | #. module: endpoint 141 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id 142 | msgid "ID" 143 | msgstr "" 144 | 145 | #. module: endpoint 146 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash 147 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash 148 | msgid "Identify the route with its main params" 149 | msgstr "" 150 | 151 | #. module: endpoint 152 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid 153 | msgid "Last Updated by" 154 | msgstr "" 155 | 156 | #. module: endpoint 157 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date 158 | msgid "Last Updated on" 159 | msgstr "" 160 | 161 | #. module: endpoint 162 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 163 | msgid "Main" 164 | msgstr "" 165 | 166 | #. module: endpoint 167 | #. odoo-python 168 | #: code:addons/endpoint/models/endpoint_mixin.py:0 169 | msgid "Missing handler for exec mode %s" 170 | msgstr "" 171 | 172 | #. module: endpoint 173 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name 174 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name 175 | msgid "Name" 176 | msgstr "" 177 | 178 | #. module: endpoint 179 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync 180 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync 181 | msgid "" 182 | "ON: the record has been modified and registry was not notified.\n" 183 | "No change will be active until this flag is set to false via proper action.\n" 184 | "\n" 185 | "OFF: record in line with the registry, nothing to do." 186 | msgstr "" 187 | 188 | #. module: endpoint 189 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__readonly 190 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__readonly 191 | msgid "Readonly" 192 | msgstr "" 193 | 194 | #. module: endpoint 195 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync 196 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync 197 | msgid "Registry Sync" 198 | msgstr "" 199 | 200 | #. module: endpoint 201 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 202 | msgid "" 203 | "Registry out of sync. Likely the record has been modified but not sync'ed " 204 | "with the routing registry." 205 | msgstr "" 206 | 207 | #. module: endpoint 208 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 209 | msgid "Request" 210 | msgstr "" 211 | 212 | #. module: endpoint 213 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type 214 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type 215 | msgid "Request Content Type" 216 | msgstr "" 217 | 218 | #. module: endpoint 219 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method 220 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method 221 | msgid "Request Method" 222 | msgstr "" 223 | 224 | #. module: endpoint 225 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route 226 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route 227 | msgid "Route" 228 | msgstr "" 229 | 230 | #. module: endpoint 231 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group 232 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group 233 | msgid "Route Group" 234 | msgstr "" 235 | 236 | #. module: endpoint 237 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type 238 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type 239 | msgid "Route Type" 240 | msgstr "" 241 | 242 | #. module: endpoint 243 | #: model:ir.actions.server,name:endpoint.server_action_registry_sync 244 | msgid "Sync registry" 245 | msgstr "" 246 | 247 | #. module: endpoint 248 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 249 | msgid "To sync" 250 | msgstr "" 251 | 252 | #. module: endpoint 253 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 254 | msgid "" 255 | "Use the action \"Sync registry\" to make changes effective once you are done " 256 | "with edits and creates." 257 | msgstr "" 258 | 259 | #. module: endpoint 260 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group 261 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group 262 | msgid "Use this to classify routes together" 263 | msgstr "" 264 | 265 | #. module: endpoint 266 | #. odoo-python 267 | #: code:addons/endpoint/models/endpoint_mixin.py:0 268 | msgid "code_snippet should return a dict into `result` variable." 269 | msgstr "" 270 | -------------------------------------------------------------------------------- /endpoint/i18n/zh_CN.po: -------------------------------------------------------------------------------- 1 | # Translation of Odoo Server. 2 | # This file contains the translation of the following modules: 3 | # * endpoint 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Odoo Server 18.0\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "PO-Revision-Date: 2025-04-13 13:36+0000\n" 10 | "Last-Translator: xtanuiha \n" 11 | "Language-Team: none\n" 12 | "Language: zh_CN\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: \n" 16 | "Plural-Forms: nplurals=1; plural=0;\n" 17 | "X-Generator: Weblate 5.10.4\n" 18 | 19 | #. module: endpoint 20 | #. odoo-python 21 | #: code:addons/endpoint/models/endpoint_mixin.py:0 22 | msgid "'Exec as user' is mandatory for public endpoints." 23 | msgstr "对于公共端点,'以用户身份执行'是强制性的。" 24 | 25 | #. module: endpoint 26 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__active 27 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__active 28 | msgid "Active" 29 | msgstr "活跃" 30 | 31 | #. module: endpoint 32 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 33 | msgid "All" 34 | msgstr "全部" 35 | 36 | #. module: endpoint 37 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 38 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 39 | msgid "Archived" 40 | msgstr "已归档" 41 | 42 | #. module: endpoint 43 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 44 | msgid "Auth" 45 | msgstr "认证" 46 | 47 | #. module: endpoint 48 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__auth_type 49 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__auth_type 50 | msgid "Auth Type" 51 | msgstr "认证类型" 52 | 53 | #. module: endpoint 54 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 55 | msgid "Code" 56 | msgstr "代码" 57 | 58 | #. module: endpoint 59 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 60 | msgid "Code Help" 61 | msgstr "代码帮助" 62 | 63 | #. module: endpoint 64 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet 65 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet 66 | msgid "Code Snippet" 67 | msgstr "代码片段" 68 | 69 | #. module: endpoint 70 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__code_snippet_docs 71 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__code_snippet_docs 72 | msgid "Code Snippet Docs" 73 | msgstr "代码片段文档" 74 | 75 | #. module: endpoint 76 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__company_id 77 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__company_id 78 | msgid "Company" 79 | msgstr "公司" 80 | 81 | #. module: endpoint 82 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_uid 83 | msgid "Created by" 84 | msgstr "创建人" 85 | 86 | #. module: endpoint 87 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__create_date 88 | msgid "Created on" 89 | msgstr "创建于" 90 | 91 | #. module: endpoint 92 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__csrf 93 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__csrf 94 | msgid "Csrf" 95 | msgstr "跨站请求伪造" 96 | 97 | #. module: endpoint 98 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__display_name 99 | msgid "Display Name" 100 | msgstr "显示名称" 101 | 102 | #. module: endpoint 103 | #: model:ir.model,name:endpoint.model_endpoint_endpoint 104 | msgid "Endpoint" 105 | msgstr "端点" 106 | 107 | #. module: endpoint 108 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__endpoint_hash 109 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__endpoint_hash 110 | msgid "Endpoint Hash" 111 | msgstr "端点哈希" 112 | 113 | #. module: endpoint 114 | #: model:ir.model,name:endpoint.model_endpoint_mixin 115 | msgid "Endpoint mixin" 116 | msgstr "端点混合" 117 | 118 | #. module: endpoint 119 | #: model:ir.actions.act_window,name:endpoint.endpoint_endpoint_act_window 120 | #: model:ir.ui.menu,name:endpoint.endpoint_endpoint_menu 121 | msgid "Endpoints" 122 | msgstr "端点" 123 | 124 | #. module: endpoint 125 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_as_user_id 126 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_as_user_id 127 | msgid "Exec As User" 128 | msgstr "以用户身份执行" 129 | 130 | #. module: endpoint 131 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__exec_mode 132 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__exec_mode 133 | msgid "Exec Mode" 134 | msgstr "执行模式" 135 | 136 | #. module: endpoint 137 | #. odoo-python 138 | #: code:addons/endpoint/models/endpoint_mixin.py:0 139 | msgid "Exec mode is set to `Code`: you must provide a piece of code" 140 | msgstr "执行模式设置为`代码`:你必须提供一段代码" 141 | 142 | #. module: endpoint 143 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__id 144 | msgid "ID" 145 | msgstr "ID" 146 | 147 | #. module: endpoint 148 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__endpoint_hash 149 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__endpoint_hash 150 | msgid "Identify the route with its main params" 151 | msgstr "用其主要参数识别路由" 152 | 153 | #. module: endpoint 154 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_uid 155 | msgid "Last Updated by" 156 | msgstr "最后更新人" 157 | 158 | #. module: endpoint 159 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__write_date 160 | msgid "Last Updated on" 161 | msgstr "最后更新于" 162 | 163 | #. module: endpoint 164 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 165 | msgid "Main" 166 | msgstr "主要" 167 | 168 | #. module: endpoint 169 | #. odoo-python 170 | #: code:addons/endpoint/models/endpoint_mixin.py:0 171 | msgid "Missing handler for exec mode %s" 172 | msgstr "缺少执行模式%s的处理程序" 173 | 174 | #. module: endpoint 175 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__name 176 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__name 177 | msgid "Name" 178 | msgstr "名称" 179 | 180 | #. module: endpoint 181 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__registry_sync 182 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__registry_sync 183 | msgid "" 184 | "ON: the record has been modified and registry was not notified.\n" 185 | "No change will be active until this flag is set to false via proper action.\n" 186 | "\n" 187 | "OFF: record in line with the registry, nothing to do." 188 | msgstr "" 189 | "开:记录已被修改且注册表未收到通知。\n" 190 | "在通过适当操作将此标志设置为假之前,任何更改都不会生效。\n" 191 | "\n" 192 | "关:记录与注册表一致,无需操作。" 193 | 194 | #. module: endpoint 195 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__readonly 196 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__readonly 197 | msgid "Readonly" 198 | msgstr "只读" 199 | 200 | #. module: endpoint 201 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__registry_sync 202 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__registry_sync 203 | msgid "Registry Sync" 204 | msgstr "注册表同步" 205 | 206 | #. module: endpoint 207 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 208 | msgid "" 209 | "Registry out of sync. Likely the record has been modified but not sync'ed " 210 | "with the routing registry." 211 | msgstr "注册表不同步。可能记录已被修改但未与路由注册表同步。" 212 | 213 | #. module: endpoint 214 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 215 | msgid "Request" 216 | msgstr "请求" 217 | 218 | #. module: endpoint 219 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_content_type 220 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_content_type 221 | msgid "Request Content Type" 222 | msgstr "请求内容类型" 223 | 224 | #. module: endpoint 225 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__request_method 226 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__request_method 227 | msgid "Request Method" 228 | msgstr "请求方法" 229 | 230 | #. module: endpoint 231 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route 232 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route 233 | msgid "Route" 234 | msgstr "路由" 235 | 236 | #. module: endpoint 237 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_group 238 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_group 239 | msgid "Route Group" 240 | msgstr "路由组" 241 | 242 | #. module: endpoint 243 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_endpoint__route_type 244 | #: model:ir.model.fields,field_description:endpoint.field_endpoint_mixin__route_type 245 | msgid "Route Type" 246 | msgstr "路由类型" 247 | 248 | #. module: endpoint 249 | #: model:ir.actions.server,name:endpoint.server_action_registry_sync 250 | msgid "Sync registry" 251 | msgstr "同步注册表" 252 | 253 | #. module: endpoint 254 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_search_view 255 | msgid "To sync" 256 | msgstr "待同步" 257 | 258 | #. module: endpoint 259 | #: model_terms:ir.ui.view,arch_db:endpoint.endpoint_mixin_form_view 260 | msgid "" 261 | "Use the action \"Sync registry\" to make changes effective once you are done" 262 | " with edits and creates." 263 | msgstr "完成编辑和创建后,使用“同步注册表”操作使更改生效。" 264 | 265 | #. module: endpoint 266 | #: model:ir.model.fields,help:endpoint.field_endpoint_endpoint__route_group 267 | #: model:ir.model.fields,help:endpoint.field_endpoint_mixin__route_group 268 | msgid "Use this to classify routes together" 269 | msgstr "使用此功能将路由分类在一起" 270 | 271 | #. module: endpoint 272 | #. odoo-python 273 | #: code:addons/endpoint/models/endpoint_mixin.py:0 274 | msgid "code_snippet should return a dict into `result` variable." 275 | msgstr "code_snippet应返回一个字典到`result`变量中。" 276 | -------------------------------------------------------------------------------- /endpoint/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import endpoint_mixin 2 | from . import endpoint_endpoint 3 | -------------------------------------------------------------------------------- /endpoint/models/endpoint_endpoint.py: -------------------------------------------------------------------------------- 1 | from odoo import models 2 | 3 | 4 | class EndpointEndpoint(models.Model): 5 | """Define a custom endpoint.""" 6 | 7 | _name = "endpoint.endpoint" 8 | _inherit = "endpoint.mixin" 9 | _description = "Endpoint" 10 | -------------------------------------------------------------------------------- /endpoint/models/endpoint_mixin.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import textwrap 6 | 7 | import werkzeug 8 | 9 | from odoo import _, api, exceptions, fields, http, models 10 | from odoo.exceptions import UserError 11 | from odoo.tools import safe_eval 12 | 13 | from odoo.addons.rpc_helper.decorator import disable_rpc 14 | 15 | hashlib = safe_eval.wrap_module( 16 | __import__("hashlib"), 17 | [ 18 | "sha1", 19 | "sha224", 20 | "sha256", 21 | "sha384", 22 | "sha512", 23 | "sha3_224", 24 | "sha3_256", 25 | "sha3_384", 26 | "sha3_512", 27 | "shake_128", 28 | "shake_256", 29 | "blake2b", 30 | "blake2s", 31 | "md5", 32 | "new", 33 | ], 34 | ) 35 | hmac = safe_eval.wrap_module( 36 | __import__("hmac"), 37 | ["new", "compare_digest"], 38 | ) 39 | 40 | 41 | @disable_rpc() # Block ALL RPC calls 42 | class EndpointMixin(models.AbstractModel): 43 | _name = "endpoint.mixin" 44 | _inherit = "endpoint.route.handler" 45 | _description = "Endpoint mixin" 46 | 47 | exec_mode = fields.Selection( 48 | selection="_selection_exec_mode", 49 | required=True, 50 | ) 51 | code_snippet = fields.Text() 52 | code_snippet_docs = fields.Text( 53 | compute="_compute_code_snippet_docs", 54 | default=lambda self: self._default_code_snippet_docs(), 55 | ) 56 | exec_as_user_id = fields.Many2one(comodel_name="res.users") 57 | company_id = fields.Many2one("res.company", string="Company") 58 | 59 | def _selection_exec_mode(self): 60 | return [("code", "Execute code")] 61 | 62 | def _compute_code_snippet_docs(self): 63 | for rec in self: 64 | rec.code_snippet_docs = textwrap.dedent(rec._default_code_snippet_docs()) 65 | 66 | @api.constrains("exec_mode") 67 | def _check_exec_mode(self): 68 | for rec in self: 69 | rec._validate_exec_mode() 70 | 71 | def _validate_exec_mode(self): 72 | validator = getattr(self, "_validate_exec__" + self.exec_mode, lambda x: True) 73 | validator() 74 | 75 | def _validate_exec__code(self): 76 | if not self._code_snippet_valued(): 77 | raise UserError( 78 | _("Exec mode is set to `Code`: you must provide a piece of code") 79 | ) 80 | 81 | @api.constrains("auth_type") 82 | def _check_auth(self): 83 | for rec in self: 84 | if rec.auth_type == "public" and not rec.exec_as_user_id: 85 | raise UserError(_("'Exec as user' is mandatory for public endpoints.")) 86 | 87 | def _default_code_snippet_docs(self): 88 | return """ 89 | Available vars: 90 | 91 | * env 92 | * endpoint 93 | * request 94 | * datetime 95 | * dateutil 96 | * time 97 | * user 98 | * json 99 | * Response 100 | * werkzeug 101 | * exceptions 102 | * hashlib: Python 'hashlib' library. Available methods: 103 | 'sha1', 'sha224', 'sha256', 104 | 'sha384', 'sha512', 'sha3_224', 'sha3_256', 'sha3_384', 105 | 'sha3_512', 'shake_128', 'shake_256', 'blake2b', 106 | 'blake2s', 'md5', 'new' 107 | * hmac: Python 'hmac' library. Use 'new' to create HMAC objects. 108 | 109 | Must generate either an instance of ``Response`` into ``response`` var or: 110 | 111 | * payload 112 | * headers 113 | * status_code 114 | 115 | which are all optional. 116 | 117 | Use ``log`` function to log messages into ir.logging table. 118 | """ 119 | 120 | def _get_code_snippet_eval_context(self, request): 121 | """Prepare the context used when evaluating python code 122 | 123 | :returns: dict -- evaluation context given to safe_eval 124 | """ 125 | return { 126 | "env": self.env, 127 | "user": self.env.user, 128 | "endpoint": self, 129 | "request": request, 130 | "datetime": safe_eval.datetime, 131 | "dateutil": safe_eval.dateutil, 132 | "time": safe_eval.time, 133 | "json": safe_eval.json, 134 | "Response": http.Response, 135 | "werkzeug": safe_eval.wrap_module( 136 | werkzeug, {"exceptions": ["NotFound", "BadRequest", "Unauthorized"]} 137 | ), 138 | "exceptions": safe_eval.wrap_module( 139 | exceptions, ["UserError", "ValidationError"] 140 | ), 141 | "log": self._code_snippet_log_func, 142 | "hmac": hmac, 143 | "hashlib": hashlib, 144 | } 145 | 146 | def _code_snippet_log_func(self, message, level="info"): 147 | # Almost barely copied from ir.actions.server 148 | with self.pool.cursor() as cr: 149 | cr.execute( 150 | """ 151 | INSERT INTO ir_logging 152 | ( 153 | create_date, 154 | create_uid, 155 | type, 156 | dbname, 157 | name, 158 | level, 159 | message, 160 | path, 161 | line, 162 | func 163 | ) 164 | VALUES ( 165 | NOW() at time zone 'UTC', 166 | %s, 167 | %s, 168 | %s, 169 | %s, 170 | %s, 171 | %s, 172 | %s, 173 | %s, 174 | %s 175 | ) 176 | """, 177 | ( 178 | self.env.uid, 179 | "server", 180 | self._cr.dbname, 181 | __name__, 182 | level, 183 | message, 184 | "endpoint", 185 | self.id, 186 | self.name, 187 | ), 188 | ) 189 | 190 | def _handle_exec__code(self, request): 191 | if not self._code_snippet_valued(): 192 | return {} 193 | eval_ctx = self._get_code_snippet_eval_context(request) 194 | snippet = self.code_snippet 195 | safe_eval.safe_eval(snippet, eval_ctx, mode="exec", nocopy=True) 196 | result = eval_ctx.get("result") 197 | if not isinstance(result, dict): 198 | raise exceptions.UserError( 199 | _("code_snippet should return a dict into `result` variable.") 200 | ) 201 | return result 202 | 203 | def _code_snippet_valued(self): 204 | snippet = self.code_snippet or "" 205 | return bool( 206 | [ 207 | not line.startswith("#") 208 | for line in (snippet.splitlines()) 209 | if line.strip("") 210 | ] 211 | ) 212 | 213 | def _default_endpoint_options_handler(self): 214 | kdp = "odoo.addons.endpoint.controllers.main.EndpointController" 215 | return { 216 | "klass_dotted_path": kdp, 217 | "method_name": "auto_endpoint", 218 | "default_pargs": (self._name, self.route), 219 | } 220 | 221 | def _validate_request(self, request): 222 | http_req = request.httprequest 223 | if self.request_method and self.request_method != http_req.method: 224 | self._logger.error("_validate_request: MethodNotAllowed") 225 | raise werkzeug.exceptions.MethodNotAllowed() 226 | if ( 227 | self.request_content_type 228 | and self.request_content_type != http_req.content_type 229 | ): 230 | self._logger.error("_validate_request: UnsupportedMediaType") 231 | raise werkzeug.exceptions.UnsupportedMediaType() 232 | 233 | def _get_handler(self): 234 | try: 235 | return getattr(self, "_handle_exec__" + self.exec_mode) 236 | except AttributeError as e: 237 | raise UserError( 238 | _("Missing handler for exec mode %s") % self.exec_mode 239 | ) from e 240 | 241 | def _handle_request(self, request): 242 | # Switch user for the whole process 243 | self_with_user = self 244 | if self.exec_as_user_id: 245 | self_with_user = self.with_user(user=self.exec_as_user_id) 246 | handler = self_with_user._get_handler() 247 | try: 248 | res = handler(request) 249 | except self._bad_request_exceptions() as orig_exec: 250 | self._logger.error("_validate_request: BadRequest") 251 | raise werkzeug.exceptions.BadRequest() from orig_exec 252 | return res 253 | 254 | def _bad_request_exceptions(self): 255 | return (exceptions.UserError, exceptions.ValidationError) 256 | 257 | @api.model 258 | def _find_endpoint(self, endpoint_route): 259 | return self.sudo().search(self._find_endpoint_domain(endpoint_route), limit=1) 260 | 261 | def _find_endpoint_domain(self, endpoint_route): 262 | return [("route", "=", endpoint_route)] 263 | 264 | def copy_data(self, default=None): 265 | # OVERRIDE: ``route`` cannot be copied as it must me unique. 266 | # Yet, we want to be able to duplicate a record from the UI. 267 | self.ensure_one() 268 | default = dict(default or {}) 269 | default.setdefault("route", f"{self.route}/COPY_FIXME") 270 | return super().copy_data(default=default) 271 | -------------------------------------------------------------------------------- /endpoint/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["whool"] 3 | build-backend = "whool.buildapi" 4 | -------------------------------------------------------------------------------- /endpoint/readme/CONFIGURE.md: -------------------------------------------------------------------------------- 1 | Go to "Technical -\> Endpoints" and create a new endpoint. 2 | -------------------------------------------------------------------------------- /endpoint/readme/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | - Simone Orsi \<\> 2 | -------------------------------------------------------------------------------- /endpoint/readme/DESCRIPTION.md: -------------------------------------------------------------------------------- 1 | Provide an endpoint framework allowing users to define their own custom 2 | endpoint. 3 | 4 | Thanks to endpoint mixin the endpoint records are automatically 5 | registered as real Odoo routes. 6 | 7 | You can easily code what you want in the code snippet. 8 | 9 | NOTE: for security reasons any kind of RPC call is blocked on endpoint 10 | records. 11 | -------------------------------------------------------------------------------- /endpoint/readme/ROADMAP.md: -------------------------------------------------------------------------------- 1 | - add validation of request data 2 | - add api docs generation 3 | - handle multiple routes per endpoint 4 | -------------------------------------------------------------------------------- /endpoint/security/ir.model.access.csv: -------------------------------------------------------------------------------- 1 | id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 2 | access_endpoint_endpoint_edit,endpoint_endpoint edit,model_endpoint_endpoint,base.group_system,1,1,1,1 3 | -------------------------------------------------------------------------------- /endpoint/security/ir_rule.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Endpoint Multi-company 5 | 6 | 7 | ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] 10 | 11 | 12 | -------------------------------------------------------------------------------- /endpoint/static/description/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OCA/web-api/12959dc92a6cf22c5878b8daa49acc73e0629279/endpoint/static/description/icon.png -------------------------------------------------------------------------------- /endpoint/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import test_endpoint 2 | from . import test_endpoint_controller 3 | -------------------------------------------------------------------------------- /endpoint/tests/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import contextlib 6 | 7 | from odoo.tests.common import TransactionCase, tagged 8 | from odoo.tools import DotDict 9 | 10 | from odoo.addons.website.tools import MockRequest 11 | 12 | 13 | @tagged("-at_install", "post_install") 14 | class CommonEndpoint(TransactionCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | super().setUpClass() 18 | cls._setup_env() 19 | cls._setup_records() 20 | 21 | @classmethod 22 | def _setup_env(cls): 23 | cls.env = cls.env(context=cls._setup_context()) 24 | 25 | @classmethod 26 | def _setup_context(cls): 27 | return dict( 28 | cls.env.context, 29 | tracking_disable=True, 30 | ) 31 | 32 | @classmethod 33 | def _setup_records(cls): 34 | pass 35 | 36 | @contextlib.contextmanager 37 | def _get_mocked_request( 38 | self, httprequest=None, extra_headers=None, request_attrs=None 39 | ): 40 | with MockRequest(self.env) as mocked_request: 41 | mocked_request.httprequest = ( 42 | DotDict(httprequest) if httprequest else mocked_request.httprequest 43 | ) 44 | headers = {} 45 | headers.update(extra_headers or {}) 46 | mocked_request.httprequest.headers = headers 47 | request_attrs = request_attrs or {} 48 | for k, v in request_attrs.items(): 49 | setattr(mocked_request, k, v) 50 | mocked_request.make_response = lambda data, **kw: data 51 | yield mocked_request 52 | -------------------------------------------------------------------------------- /endpoint/tests/test_endpoint.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import json 6 | import textwrap 7 | from unittest import mock 8 | 9 | import psycopg2 10 | import werkzeug 11 | 12 | from odoo import exceptions 13 | from odoo.tools.misc import mute_logger 14 | 15 | from .common import CommonEndpoint 16 | 17 | 18 | class TestEndpoint(CommonEndpoint): 19 | @classmethod 20 | def _setup_records(cls): 21 | res = super()._setup_records() 22 | cls.endpoint = cls.env.ref("endpoint.endpoint_demo_1") 23 | return res 24 | 25 | @mute_logger("odoo.sql_db") 26 | def test_endpoint_unique(self): 27 | with self.assertRaises(psycopg2.IntegrityError): 28 | self.env["endpoint.endpoint"].create( 29 | { 30 | "name": "Endpoint", 31 | "route": "/demo/one", 32 | "exec_mode": "code", 33 | } 34 | ) 35 | 36 | def test_endpoint_validation(self): 37 | with self.assertRaisesRegex( 38 | exceptions.UserError, r"you must provide a piece of code" 39 | ): 40 | self.env["endpoint.endpoint"].create( 41 | { 42 | "name": "Endpoint 2", 43 | "route": "/demo/2", 44 | "exec_mode": "code", 45 | "request_method": "GET", 46 | "auth_type": "user_endpoint", 47 | } 48 | ) 49 | with self.assertRaisesRegex( 50 | exceptions.UserError, r"Request content type is required for POST and PUT." 51 | ): 52 | self.env["endpoint.endpoint"].create( 53 | { 54 | "name": "Endpoint 3", 55 | "route": "/demo/3", 56 | "exec_mode": "code", 57 | "code_snippet": "foo = 1", 58 | "request_method": "POST", 59 | "auth_type": "user_endpoint", 60 | } 61 | ) 62 | with self.assertRaisesRegex( 63 | exceptions.UserError, r"Request content type is required for POST and PUT." 64 | ): 65 | self.endpoint.request_method = "POST" 66 | 67 | def test_endpoint_find(self): 68 | self.assertEqual( 69 | self.env["endpoint.endpoint"]._find_endpoint("/demo/one"), self.endpoint 70 | ) 71 | 72 | def test_endpoint_code_eval_full_response(self): 73 | with self._get_mocked_request() as req: 74 | result = self.endpoint._handle_request(req) 75 | resp = result["response"] 76 | self.assertEqual(resp.status, "200 OK") 77 | self.assertEqual(resp.data, b"ok") 78 | 79 | def test_endpoint_code_eval_free_vals(self): 80 | self.endpoint.write( 81 | { 82 | "code_snippet": textwrap.dedent( 83 | """ 84 | result = { 85 | "payload": json.dumps({"a": 1, "b": 2}), 86 | "headers": [("content-type", "application/json")] 87 | } 88 | """ 89 | ) 90 | } 91 | ) 92 | with self._get_mocked_request() as req: 93 | result = self.endpoint._handle_request(req) 94 | payload = result["payload"] 95 | self.assertEqual(json.loads(payload), {"a": 1, "b": 2}) 96 | 97 | def test_endpoint_log(self): 98 | self.endpoint.write( 99 | { 100 | "code_snippet": textwrap.dedent( 101 | """ 102 | log("ciao") 103 | result = {"ok": True} 104 | """ 105 | ) 106 | } 107 | ) 108 | with self._get_mocked_request() as req: 109 | # just test that logging does not break 110 | # as it creates a record directly via sql 111 | # and we cannot easily check the result 112 | self.endpoint._handle_request(req) 113 | self.env.cr.execute("DELETE FROM ir_logging") 114 | 115 | @mute_logger("endpoint.endpoint", "odoo.modules.registry") 116 | def test_endpoint_validate_request(self): 117 | endpoint = self.endpoint.copy( 118 | { 119 | "route": "/wrong", 120 | "request_method": "POST", 121 | "request_content_type": "text/plain", 122 | } 123 | ) 124 | with self.assertRaises(werkzeug.exceptions.UnsupportedMediaType): 125 | with self._get_mocked_request(httprequest={"method": "POST"}) as req: 126 | endpoint._validate_request(req) 127 | with self.assertRaises(werkzeug.exceptions.MethodNotAllowed): 128 | with self._get_mocked_request( 129 | httprequest={"method": "GET"}, 130 | extra_headers=[("Content-type", "text/plain")], 131 | ) as req: 132 | endpoint._validate_request(req) 133 | 134 | @mute_logger("odoo.modules.registry") 135 | def test_routing(self): 136 | route, info, __ = self.endpoint._get_routing_info() 137 | self.assertEqual(route, "/demo/one") 138 | self.assertEqual( 139 | info, 140 | { 141 | "auth": "user_endpoint", 142 | "methods": ["GET"], 143 | "routes": ["/demo/one"], 144 | "type": "http", 145 | "csrf": False, 146 | "readonly": False, 147 | }, 148 | ) 149 | endpoint = self.endpoint.copy( 150 | { 151 | "route": "/new/one", 152 | "request_method": "POST", 153 | "request_content_type": "text/plain", 154 | "auth_type": "public", 155 | "exec_as_user_id": self.env.user.id, 156 | } 157 | ) 158 | __, info, __ = endpoint._get_routing_info() 159 | self.assertEqual( 160 | info, 161 | { 162 | "auth": "public", 163 | "methods": ["POST"], 164 | "routes": ["/new/one"], 165 | "type": "http", 166 | "csrf": False, 167 | "readonly": False, 168 | }, 169 | ) 170 | # check prefix 171 | type(endpoint)._endpoint_route_prefix = "/foo" 172 | endpoint._compute_route() 173 | __, info, __ = endpoint._get_routing_info() 174 | self.assertEqual( 175 | info, 176 | { 177 | "auth": "public", 178 | "methods": ["POST"], 179 | "routes": ["/foo/new/one"], 180 | "type": "http", 181 | "csrf": False, 182 | "readonly": False, 183 | }, 184 | ) 185 | type(endpoint)._endpoint_route_prefix = "" 186 | 187 | @mute_logger("odoo.modules.registry") 188 | def test_unlink(self): 189 | endpoint = self.endpoint.copy( 190 | { 191 | "route": "/delete/this", 192 | "request_method": "POST", 193 | "request_content_type": "text/plain", 194 | "auth_type": "public", 195 | "exec_as_user_id": self.env.user.id, 196 | } 197 | ) 198 | endpoint._handle_registry_sync() 199 | key = endpoint._endpoint_registry_unique_key() 200 | reg = endpoint._endpoint_registry 201 | self.assertEqual(reg._get_rule(key).route, "/delete/this") 202 | endpoint.unlink() 203 | self.assertEqual(reg._get_rule(key), None) 204 | 205 | @mute_logger("odoo.modules.registry") 206 | def test_archive(self): 207 | endpoint = self.endpoint.copy( 208 | { 209 | "route": "/enable-disable/this", 210 | "request_method": "POST", 211 | "request_content_type": "text/plain", 212 | "auth_type": "public", 213 | "exec_as_user_id": self.env.user.id, 214 | } 215 | ) 216 | endpoint._handle_registry_sync() 217 | self.assertTrue(endpoint.active) 218 | key = endpoint._endpoint_registry_unique_key() 219 | reg = endpoint._endpoint_registry 220 | self.assertEqual(reg._get_rule(key).route, "/enable-disable/this") 221 | endpoint.active = False 222 | endpoint._handle_registry_sync() 223 | self.assertEqual(reg._get_rule(key), None) 224 | 225 | def test_registry_sync(self): 226 | endpoint = self.env["endpoint.endpoint"].create( 227 | { 228 | "name": "New", 229 | "route": "/not/active/yet", 230 | "exec_mode": "code", 231 | "code_snippet": "foo = 1", 232 | "request_method": "GET", 233 | "auth_type": "user_endpoint", 234 | } 235 | ) 236 | self.assertFalse(endpoint.registry_sync) 237 | key = endpoint._endpoint_registry_unique_key() 238 | reg = endpoint._endpoint_registry 239 | self.assertEqual(reg._get_rule(key), None) 240 | with mock.patch.object(type(self.env.cr.postcommit), "add") as mocked: 241 | endpoint.registry_sync = True 242 | partial_func = mocked.call_args[0][0] 243 | self.assertEqual(partial_func.args, ([endpoint.id],)) 244 | self.assertEqual( 245 | partial_func.func.__name__, "_handle_registry_sync_post_commit" 246 | ) 247 | 248 | def test_duplicate(self): 249 | endpoint = self.endpoint.copy() 250 | self.assertTrue(endpoint.route.endswith("/COPY_FIXME")) 251 | -------------------------------------------------------------------------------- /endpoint/tests/test_endpoint_controller.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import json 6 | import os 7 | from unittest import skipIf 8 | 9 | from odoo.tests.common import HttpCase 10 | from odoo.tools.misc import mute_logger 11 | 12 | 13 | @skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") 14 | class EndpointHttpCase(HttpCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | super().setUpClass() 18 | # force sync for demo records 19 | cls.env["endpoint.endpoint"].search([])._handle_registry_sync() 20 | 21 | def tearDown(self): 22 | # Clear cache for method ``ir.http.routing_map()`` 23 | self.env.registry.clear_cache("routing") 24 | super().tearDown() 25 | 26 | def test_call1(self): 27 | response = self.url_open("/demo/one") 28 | self.assertEqual(response.status_code, 401) 29 | # Let's login now 30 | self.authenticate("admin", "admin") 31 | response = self.url_open("/demo/one") 32 | self.assertEqual(response.status_code, 200) 33 | self.assertEqual(response.content, b"ok") 34 | 35 | def test_call_route_update(self): 36 | # Ensure that a route that gets updated is not available anymore 37 | self.authenticate("admin", "admin") 38 | endpoint = self.env.ref("endpoint.endpoint_demo_1") 39 | endpoint.route += "/new" 40 | # force sync 41 | endpoint._handle_registry_sync() 42 | response = self.url_open("/demo/one") 43 | self.assertEqual(response.status_code, 404) 44 | response = self.url_open("/demo/one/new") 45 | self.assertEqual(response.status_code, 200) 46 | self.assertEqual(response.content, b"ok") 47 | # Archive it 48 | endpoint.active = False 49 | response = self.url_open("/demo/one/new") 50 | self.assertEqual(response.status_code, 404) 51 | endpoint.active = True 52 | response = self.url_open("/demo/one/new") 53 | self.assertEqual(response.status_code, 200) 54 | 55 | def test_call2(self): 56 | response = self.url_open("/demo/as_demo_user") 57 | self.assertEqual(response.content, b"My name is: Marc Demo") 58 | 59 | def test_call3(self): 60 | response = self.url_open("/demo/json_data") 61 | data = json.loads(response.content.decode()) 62 | self.assertEqual(data, {"a": 1, "b": 2}) 63 | 64 | @mute_logger("endpoint.endpoint") 65 | def test_call4(self): 66 | response = self.url_open("/demo/raise_validation_error") 67 | self.assertEqual(response.status_code, 400) 68 | 69 | def test_call5(self): 70 | response = self.url_open("/demo/none") 71 | self.assertEqual(response.status_code, 404) 72 | 73 | def test_call6(self): 74 | response = self.url_open("/demo/value_from_request?your_name=JonnyTest") 75 | self.assertEqual(response.content, b"JonnyTest") 76 | 77 | def test_call7(self): 78 | response = self.url_open("/demo/bad_method", data="ok") 79 | self.assertEqual(response.status_code, 405) 80 | -------------------------------------------------------------------------------- /endpoint/views/endpoint_view.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | endpoint.mixin.form 8 | endpoint.mixin 9 | 10 |
11 |
12 | 13 | 14 | 20 | 21 | 33 |
34 |
43 | 44 | 45 | 46 | 47 | 51 | 52 | 53 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 81 | 82 | 83 | 84 |
85 | 86 |
87 | 88 | 89 | 90 | endpoint.endpoint.form 91 | endpoint.endpoint 92 | 93 | primary 94 | 95 |
96 | 97 | real 98 |
99 |
100 |
101 | 102 | 103 | endpoint.mixin.search 104 | endpoint.mixin 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 117 | 122 | 123 | 128 | 129 | 130 | 131 | 132 | 133 | endpoint.endpoint.search 134 | endpoint.endpoint 135 | 136 | primary 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | endpoint.mixin.list 147 | endpoint.mixin 148 | 149 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | endpoint.endpoint.list 164 | endpoint.endpoint 165 | 166 | primary 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | Endpoints 177 | endpoint.endpoint 178 | list,form 179 | [] 180 | {'search_default_all': 1} 181 | 182 | 183 | 184 | Endpoints 185 | 186 | 187 | 188 | 189 | 190 | -------------------------------------------------------------------------------- /endpoint_route_handler/README.rst: -------------------------------------------------------------------------------- 1 | ====================== 2 | Endpoint route handler 3 | ====================== 4 | 5 | .. 6 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 7 | !! This file is generated by oca-gen-addon-readme !! 8 | !! changes will be overwritten. !! 9 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 10 | !! source digest: sha256:81e0be13d84cbe05be2e0500590d0d8fb7fcb5a5f9486768ed65f3970c3589fc 11 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 12 | 13 | .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png 14 | :target: https://odoo-community.org/page/development-status 15 | :alt: Beta 16 | .. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png 17 | :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html 18 | :alt: License: LGPL-3 19 | .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github 20 | :target: https://github.com/OCA/web-api/tree/18.0/endpoint_route_handler 21 | :alt: OCA/web-api 22 | .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png 23 | :target: https://translation.odoo-community.org/projects/web-api-18-0/web-api-18-0-endpoint_route_handler 24 | :alt: Translate me on Weblate 25 | .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png 26 | :target: https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=18.0 27 | :alt: Try me on Runboat 28 | 29 | |badge1| |badge2| |badge3| |badge4| |badge5| 30 | 31 | Technical module that provides a base handler for adding and removing 32 | controller routes on the fly. 33 | 34 | Can be used as a mixin or as a tool. 35 | 36 | **Table of contents** 37 | 38 | .. contents:: 39 | :local: 40 | 41 | Usage 42 | ===== 43 | 44 | As a mixin 45 | ---------- 46 | 47 | Use standard Odoo inheritance: 48 | 49 | :: 50 | 51 | class MyModel(models.Model): 52 | _name = "my.model" 53 | _inherit = "endpoint.route.handler" 54 | 55 | Once you have this, each my.model record will generate a route. You can 56 | have a look at the endpoint module to see a real life example. 57 | 58 | The options of the routing rules are defined by the method 59 | \_default_endpoint_options. Here's an example from the endpoint module: 60 | 61 | :: 62 | 63 | def _default_endpoint_options_handler(self): 64 | return { 65 | "klass_dotted_path": "odoo.addons.endpoint.controllers.main.EndpointController", 66 | "method_name": "auto_endpoint", 67 | "default_pargs": (self.route,), 68 | } 69 | 70 | As you can see, you have to pass the references to the controller class 71 | and the method to use when the endpoint is called. And you can prepare 72 | some default arguments to pass. In this case, the route of the current 73 | record. 74 | 75 | As a tool 76 | --------- 77 | 78 | Initialize non stored route handlers and generate routes from them. For 79 | instance: 80 | 81 | :: 82 | 83 | route_handler = self.env["endpoint.route.handler.tool"] 84 | endpoint_handler = MyController()._my_handler 85 | vals = { 86 | "name": "My custom route", 87 | "route": "/my/custom/route", 88 | "request_method": "GET", 89 | "auth_type": "public", 90 | } 91 | new_route = route_handler.new(vals) 92 | new_route._register_controller() 93 | 94 | You can override options and define - for instance - a different 95 | controller method: 96 | 97 | :: 98 | 99 | options = { 100 | "handler": { 101 | "klass_dotted_path": "odoo.addons.my_module.controllers.SpecialController", 102 | "method_name": "my_special_handler", 103 | } 104 | } 105 | new_route._register_controller(options=options) 106 | 107 | Of course, what happens when the endpoint gets called depends on the 108 | logic defined on the controller method. 109 | 110 | In both cases (mixin and tool) when a new route is generated or an 111 | existing one is updated, the ir.http.routing_map (which holds all Odoo 112 | controllers) will be updated. 113 | 114 | You can see a real life example on shopfloor.app model. 115 | 116 | Known issues / Roadmap 117 | ====================== 118 | 119 | - add api docs helpers 120 | 121 | - allow multiple HTTP methods on the same endpoint 122 | 123 | - multiple values for route and methods 124 | 125 | keep the same in the ui for now, later own we can imagine a 126 | multi-value selection or just add text field w/ proper validation 127 | and cleanup 128 | 129 | remove the route field in the table of endpoint_route 130 | 131 | support a comma separated list of routes maybe support comma 132 | separated list of methods use only routing.routes for generating 133 | the rule sort and freeze its values to update the endpoint hash 134 | 135 | catch dup route exception on the sync to detect duplicated routes 136 | and use the endpoint_hash to retrieve the real record (note: we 137 | could store more info in the routing information which will stay in 138 | the map) 139 | 140 | for customizing the rule behavior the endpoint the hook is to 141 | override the registry lookup 142 | 143 | make EndpointRule class overridable on the registry 144 | 145 | NOTE in v16 we won't care anymore about odoo controller so the lookup of 146 | the controller can be simplified to a basic py obj that holds the 147 | routing info. 148 | 149 | Bug Tracker 150 | =========== 151 | 152 | Bugs are tracked on `GitHub Issues `_. 153 | In case of trouble, please check there if your issue has already been reported. 154 | If you spotted it first, help us to smash it by providing a detailed and welcomed 155 | `feedback `_. 156 | 157 | Do not contact contributors directly about support or help with technical issues. 158 | 159 | Credits 160 | ======= 161 | 162 | Authors 163 | ------- 164 | 165 | * Camptocamp 166 | 167 | Contributors 168 | ------------ 169 | 170 | - Simone Orsi 171 | - Nguyen Minh Chien 172 | 173 | Maintainers 174 | ----------- 175 | 176 | This module is maintained by the OCA. 177 | 178 | .. image:: https://odoo-community.org/logo.png 179 | :alt: Odoo Community Association 180 | :target: https://odoo-community.org 181 | 182 | OCA, or the Odoo Community Association, is a nonprofit organization whose 183 | mission is to support the collaborative development of Odoo features and 184 | promote its widespread use. 185 | 186 | .. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px 187 | :target: https://github.com/simahawk 188 | :alt: simahawk 189 | 190 | Current `maintainer `__: 191 | 192 | |maintainer-simahawk| 193 | 194 | This module is part of the `OCA/web-api `_ project on GitHub. 195 | 196 | You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. 197 | -------------------------------------------------------------------------------- /endpoint_route_handler/__init__.py: -------------------------------------------------------------------------------- 1 | from . import models 2 | from .post_init_hook import post_init_hook 3 | -------------------------------------------------------------------------------- /endpoint_route_handler/__manifest__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 3 | 4 | { 5 | "name": "Endpoint route handler", 6 | "summary": """Provide mixin and tool to generate custom endpoints on the fly.""", 7 | "version": "18.0.1.0.0", 8 | "license": "LGPL-3", 9 | "development_status": "Beta", 10 | "author": "Camptocamp,Odoo Community Association (OCA)", 11 | "maintainers": ["simahawk"], 12 | "website": "https://github.com/OCA/web-api", 13 | "data": [ 14 | "security/ir.model.access.csv", 15 | ], 16 | "post_init_hook": "post_init_hook", 17 | } 18 | -------------------------------------------------------------------------------- /endpoint_route_handler/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | -------------------------------------------------------------------------------- /endpoint_route_handler/controllers/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | 6 | import logging 7 | 8 | from werkzeug.exceptions import NotFound 9 | 10 | from odoo import http 11 | 12 | _logger = logging.getLogger(__file__) 13 | 14 | 15 | class EndpointNotFoundController(http.Controller): 16 | def auto_not_found(self, endpoint_route, **params): 17 | _logger.error("Non registered endpoint for %s", endpoint_route) 18 | raise NotFound() 19 | -------------------------------------------------------------------------------- /endpoint_route_handler/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | 6 | class EndpointHandlerNotFound(Exception): 7 | """Raise when an endpoint handler is not found.""" 8 | -------------------------------------------------------------------------------- /endpoint_route_handler/i18n/endpoint_route_handler.pot: -------------------------------------------------------------------------------- 1 | # Translation of Odoo Server. 2 | # This file contains the translation of the following modules: 3 | # * endpoint_route_handler 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Odoo Server 18.0\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "Last-Translator: \n" 10 | "Language-Team: \n" 11 | "MIME-Version: 1.0\n" 12 | "Content-Type: text/plain; charset=UTF-8\n" 13 | "Content-Transfer-Encoding: \n" 14 | "Plural-Forms: \n" 15 | 16 | #. module: endpoint_route_handler 17 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__active 18 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__active 19 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__active 20 | msgid "Active" 21 | msgstr "" 22 | 23 | #. module: endpoint_route_handler 24 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__auth_type 25 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__auth_type 26 | msgid "Auth Type" 27 | msgstr "" 28 | 29 | #. module: endpoint_route_handler 30 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_uid 31 | msgid "Created by" 32 | msgstr "" 33 | 34 | #. module: endpoint_route_handler 35 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_date 36 | msgid "Created on" 37 | msgstr "" 38 | 39 | #. module: endpoint_route_handler 40 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__csrf 41 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__csrf 42 | msgid "Csrf" 43 | msgstr "" 44 | 45 | #. module: endpoint_route_handler 46 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__display_name 47 | msgid "Display Name" 48 | msgstr "" 49 | 50 | #. module: endpoint_route_handler 51 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash 52 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash 53 | msgid "Endpoint Hash" 54 | msgstr "" 55 | 56 | #. module: endpoint_route_handler 57 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler 58 | msgid "Endpoint Route handler" 59 | msgstr "" 60 | 61 | #. module: endpoint_route_handler 62 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler_tool 63 | msgid "Endpoint Route handler tool" 64 | msgstr "" 65 | 66 | #. module: endpoint_route_handler 67 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_sync_mixin 68 | msgid "Endpoint Route sync mixin" 69 | msgstr "" 70 | 71 | #. module: endpoint_route_handler 72 | #: model:ir.model,name:endpoint_route_handler.model_ir_http 73 | msgid "HTTP Routing" 74 | msgstr "" 75 | 76 | #. module: endpoint_route_handler 77 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__id 78 | msgid "ID" 79 | msgstr "" 80 | 81 | #. module: endpoint_route_handler 82 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash 83 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash 84 | msgid "Identify the route with its main params" 85 | msgstr "" 86 | 87 | #. module: endpoint_route_handler 88 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_uid 89 | msgid "Last Updated by" 90 | msgstr "" 91 | 92 | #. module: endpoint_route_handler 93 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_date 94 | msgid "Last Updated on" 95 | msgstr "" 96 | 97 | #. module: endpoint_route_handler 98 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__name 99 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__name 100 | msgid "Name" 101 | msgstr "" 102 | 103 | #. module: endpoint_route_handler 104 | #. odoo-python 105 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 106 | msgid "" 107 | "Non unique route(s): %(routes)s.\n" 108 | "Found in model(s): %(models)s.\n" 109 | msgstr "" 110 | 111 | #. module: endpoint_route_handler 112 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__registry_sync 113 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync 114 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync 115 | msgid "" 116 | "ON: the record has been modified and registry was not notified.\n" 117 | "No change will be active until this flag is set to false via proper action.\n" 118 | "\n" 119 | "OFF: record in line with the registry, nothing to do." 120 | msgstr "" 121 | 122 | #. module: endpoint_route_handler 123 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__readonly 124 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__readonly 125 | msgid "Readonly" 126 | msgstr "" 127 | 128 | #. module: endpoint_route_handler 129 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__registry_sync 130 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync 131 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync 132 | msgid "Registry Sync" 133 | msgstr "" 134 | 135 | #. module: endpoint_route_handler 136 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_content_type 137 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_content_type 138 | msgid "Request Content Type" 139 | msgstr "" 140 | 141 | #. module: endpoint_route_handler 142 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_method 143 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_method 144 | msgid "Request Method" 145 | msgstr "" 146 | 147 | #. module: endpoint_route_handler 148 | #. odoo-python 149 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 150 | msgid "Request content type is required for POST and PUT." 151 | msgstr "" 152 | 153 | #. module: endpoint_route_handler 154 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route 155 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route 156 | msgid "Route" 157 | msgstr "" 158 | 159 | #. module: endpoint_route_handler 160 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_group 161 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_group 162 | msgid "Route Group" 163 | msgstr "" 164 | 165 | #. module: endpoint_route_handler 166 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_type 167 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_type 168 | msgid "Route Type" 169 | msgstr "" 170 | 171 | #. module: endpoint_route_handler 172 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__route_group 173 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__route_group 174 | msgid "Use this to classify routes together" 175 | msgstr "" 176 | 177 | #. module: endpoint_route_handler 178 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_endpoint_endpoint_route_unique 179 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_mixin_endpoint_route_unique 180 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_endpoint_route_unique 181 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_tool_endpoint_route_unique 182 | msgid "You can register an endpoint route only once." 183 | msgstr "" 184 | 185 | #. module: endpoint_route_handler 186 | #. odoo-python 187 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 188 | msgid "`%(name)s` uses a blacklisted routed = `%(route)s`" 189 | msgstr "" 190 | -------------------------------------------------------------------------------- /endpoint_route_handler/i18n/it.po: -------------------------------------------------------------------------------- 1 | # Translation of Odoo Server. 2 | # This file contains the translation of the following modules: 3 | # * endpoint_route_handler 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Odoo Server 16.0\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "PO-Revision-Date: 2025-01-31 14:06+0000\n" 10 | "Last-Translator: mymage \n" 11 | "Language-Team: none\n" 12 | "Language: it\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: \n" 16 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 17 | "X-Generator: Weblate 5.6.2\n" 18 | 19 | #. module: endpoint_route_handler 20 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__active 21 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__active 22 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__active 23 | msgid "Active" 24 | msgstr "Attiva" 25 | 26 | #. module: endpoint_route_handler 27 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__auth_type 28 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__auth_type 29 | msgid "Auth Type" 30 | msgstr "Tipo autorizzazione" 31 | 32 | #. module: endpoint_route_handler 33 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_uid 34 | msgid "Created by" 35 | msgstr "Creato da" 36 | 37 | #. module: endpoint_route_handler 38 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_date 39 | msgid "Created on" 40 | msgstr "Creato il" 41 | 42 | #. module: endpoint_route_handler 43 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__csrf 44 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__csrf 45 | msgid "Csrf" 46 | msgstr "CSRF" 47 | 48 | #. module: endpoint_route_handler 49 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__display_name 50 | msgid "Display Name" 51 | msgstr "Nome visualizzato" 52 | 53 | #. module: endpoint_route_handler 54 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash 55 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash 56 | msgid "Endpoint Hash" 57 | msgstr "Hash endpoint" 58 | 59 | #. module: endpoint_route_handler 60 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler 61 | msgid "Endpoint Route handler" 62 | msgstr "Gestore percorso endpoint" 63 | 64 | #. module: endpoint_route_handler 65 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler_tool 66 | msgid "Endpoint Route handler tool" 67 | msgstr "Strumento gestore percorso endpoint" 68 | 69 | #. module: endpoint_route_handler 70 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_sync_mixin 71 | msgid "Endpoint Route sync mixin" 72 | msgstr "Mixin sincro percorso endpoint" 73 | 74 | #. module: endpoint_route_handler 75 | #: model:ir.model,name:endpoint_route_handler.model_ir_http 76 | msgid "HTTP Routing" 77 | msgstr "Instradamento HTTP" 78 | 79 | #. module: endpoint_route_handler 80 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__id 81 | msgid "ID" 82 | msgstr "ID" 83 | 84 | #. module: endpoint_route_handler 85 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash 86 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash 87 | msgid "Identify the route with its main params" 88 | msgstr "Identifica la rotta con questi parametri principali" 89 | 90 | #. module: endpoint_route_handler 91 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_uid 92 | msgid "Last Updated by" 93 | msgstr "Ultimo aggiornamento di" 94 | 95 | #. module: endpoint_route_handler 96 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_date 97 | msgid "Last Updated on" 98 | msgstr "Ultimo aggiornamento il" 99 | 100 | #. module: endpoint_route_handler 101 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__name 102 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__name 103 | msgid "Name" 104 | msgstr "Nome" 105 | 106 | #. module: endpoint_route_handler 107 | #. odoo-python 108 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 109 | msgid "" 110 | "Non unique route(s): %(routes)s.\n" 111 | "Found in model(s): %(models)s.\n" 112 | msgstr "" 113 | "Percorso non univoco: %(routes)s.\n" 114 | "Trovato nel modello: %(models)s.\n" 115 | 116 | #. module: endpoint_route_handler 117 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__registry_sync 118 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync 119 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync 120 | msgid "" 121 | "ON: the record has been modified and registry was not notified.\n" 122 | "No change will be active until this flag is set to false via proper action.\n" 123 | "\n" 124 | "OFF: record in line with the registry, nothing to do." 125 | msgstr "" 126 | "Acceso: il record è stato modificato e il registro non è stato notificato.\n" 127 | "Nessuna modifica sarà attiva finchè questa opzione è impostata a falso " 128 | "attraverso un'azione opportuna.\n" 129 | "\n" 130 | "Spento: record allineato con il registro, non c'è niente da fare." 131 | 132 | #. module: endpoint_route_handler 133 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__readonly 134 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__readonly 135 | msgid "Readonly" 136 | msgstr "Solo lettura" 137 | 138 | #. module: endpoint_route_handler 139 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__registry_sync 140 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync 141 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync 142 | msgid "Registry Sync" 143 | msgstr "Sincro registro" 144 | 145 | #. module: endpoint_route_handler 146 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_content_type 147 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_content_type 148 | msgid "Request Content Type" 149 | msgstr "Tipo contenuto richiesta" 150 | 151 | #. module: endpoint_route_handler 152 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_method 153 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_method 154 | msgid "Request Method" 155 | msgstr "Metodo richiesta" 156 | 157 | #. module: endpoint_route_handler 158 | #. odoo-python 159 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 160 | msgid "Request content type is required for POST and PUT." 161 | msgstr "Il tipo contenuto richiesta è obbligatorio per POST e PUT." 162 | 163 | #. module: endpoint_route_handler 164 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route 165 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route 166 | msgid "Route" 167 | msgstr "Percorso" 168 | 169 | #. module: endpoint_route_handler 170 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_group 171 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_group 172 | msgid "Route Group" 173 | msgstr "Gruppo percorso" 174 | 175 | #. module: endpoint_route_handler 176 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_type 177 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_type 178 | msgid "Route Type" 179 | msgstr "Tipo percorso" 180 | 181 | #. module: endpoint_route_handler 182 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__route_group 183 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__route_group 184 | msgid "Use this to classify routes together" 185 | msgstr "Utilizzarlo per classificare i percorsi insieme" 186 | 187 | #. module: endpoint_route_handler 188 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_endpoint_endpoint_route_unique 189 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_mixin_endpoint_route_unique 190 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_endpoint_route_unique 191 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_tool_endpoint_route_unique 192 | msgid "You can register an endpoint route only once." 193 | msgstr "Si può regiStrare un percorso endpoint solo una volta." 194 | 195 | #. module: endpoint_route_handler 196 | #. odoo-python 197 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 198 | msgid "`%(name)s` uses a blacklisted routed = `%(route)s`" 199 | msgstr "`%(name)s` utilizza un percorso bloccato = `%(route)s`" 200 | 201 | #~ msgid "Last Modified on" 202 | #~ msgstr "Ultima modifica il" 203 | -------------------------------------------------------------------------------- /endpoint_route_handler/i18n/zh_CN.po: -------------------------------------------------------------------------------- 1 | # Translation of Odoo Server. 2 | # This file contains the translation of the following modules: 3 | # * endpoint_route_handler 4 | # 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: Odoo Server 18.0\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "PO-Revision-Date: 2025-04-13 16:24+0000\n" 10 | "Last-Translator: xtanuiha \n" 11 | "Language-Team: none\n" 12 | "Language: zh_CN\n" 13 | "MIME-Version: 1.0\n" 14 | "Content-Type: text/plain; charset=UTF-8\n" 15 | "Content-Transfer-Encoding: \n" 16 | "Plural-Forms: nplurals=1; plural=0;\n" 17 | "X-Generator: Weblate 5.10.4\n" 18 | 19 | #. module: endpoint_route_handler 20 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__active 21 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__active 22 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__active 23 | msgid "Active" 24 | msgstr "激活" 25 | 26 | #. module: endpoint_route_handler 27 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__auth_type 28 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__auth_type 29 | msgid "Auth Type" 30 | msgstr "认证类型" 31 | 32 | #. module: endpoint_route_handler 33 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_uid 34 | msgid "Created by" 35 | msgstr "创建人" 36 | 37 | #. module: endpoint_route_handler 38 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__create_date 39 | msgid "Created on" 40 | msgstr "创建于" 41 | 42 | #. module: endpoint_route_handler 43 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__csrf 44 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__csrf 45 | msgid "Csrf" 46 | msgstr "跨站请求伪造" 47 | 48 | #. module: endpoint_route_handler 49 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__display_name 50 | msgid "Display Name" 51 | msgstr "显示名称" 52 | 53 | #. module: endpoint_route_handler 54 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash 55 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash 56 | msgid "Endpoint Hash" 57 | msgstr "端点哈希" 58 | 59 | #. module: endpoint_route_handler 60 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler 61 | msgid "Endpoint Route handler" 62 | msgstr "端点路由处理器" 63 | 64 | #. module: endpoint_route_handler 65 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_handler_tool 66 | msgid "Endpoint Route handler tool" 67 | msgstr "端点路由处理器工具" 68 | 69 | #. module: endpoint_route_handler 70 | #: model:ir.model,name:endpoint_route_handler.model_endpoint_route_sync_mixin 71 | msgid "Endpoint Route sync mixin" 72 | msgstr "端点路由同步混合" 73 | 74 | #. module: endpoint_route_handler 75 | #: model:ir.model,name:endpoint_route_handler.model_ir_http 76 | msgid "HTTP Routing" 77 | msgstr "HTTP路由" 78 | 79 | #. module: endpoint_route_handler 80 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__id 81 | msgid "ID" 82 | msgstr "ID" 83 | 84 | #. module: endpoint_route_handler 85 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__endpoint_hash 86 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__endpoint_hash 87 | msgid "Identify the route with its main params" 88 | msgstr "用主要参数识别路由" 89 | 90 | #. module: endpoint_route_handler 91 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_uid 92 | msgid "Last Updated by" 93 | msgstr "最后更新人" 94 | 95 | #. module: endpoint_route_handler 96 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__write_date 97 | msgid "Last Updated on" 98 | msgstr "最后更新于" 99 | 100 | #. module: endpoint_route_handler 101 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__name 102 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__name 103 | msgid "Name" 104 | msgstr "名称" 105 | 106 | #. module: endpoint_route_handler 107 | #. odoo-python 108 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 109 | msgid "" 110 | "Non unique route(s): %(routes)s.\n" 111 | "Found in model(s): %(models)s.\n" 112 | msgstr "" 113 | "非唯一路由:%(routes)s。\n" 114 | "在模型中发现:%(models)s。\n" 115 | 116 | #. module: endpoint_route_handler 117 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__registry_sync 118 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync 119 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync 120 | msgid "" 121 | "ON: the record has been modified and registry was not notified.\n" 122 | "No change will be active until this flag is set to false via proper action.\n" 123 | "\n" 124 | "OFF: record in line with the registry, nothing to do." 125 | msgstr "" 126 | "开:记录已被修改且注册表未通知。\n" 127 | "在通过适当操作将此标志设置为false之前,任何更改都不会生效。\n" 128 | "\n" 129 | "关:记录与注册表一致,无需操作。" 130 | 131 | #. module: endpoint_route_handler 132 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__readonly 133 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__readonly 134 | msgid "Readonly" 135 | msgstr "只读" 136 | 137 | #. module: endpoint_route_handler 138 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__registry_sync 139 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__registry_sync 140 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_sync_mixin__registry_sync 141 | msgid "Registry Sync" 142 | msgstr "注册表同步" 143 | 144 | #. module: endpoint_route_handler 145 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_content_type 146 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_content_type 147 | msgid "Request Content Type" 148 | msgstr "请求内容类型" 149 | 150 | #. module: endpoint_route_handler 151 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__request_method 152 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__request_method 153 | msgid "Request Method" 154 | msgstr "请求方法" 155 | 156 | #. module: endpoint_route_handler 157 | #. odoo-python 158 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 159 | msgid "Request content type is required for POST and PUT." 160 | msgstr "POST和PUT需要请求内容类型。" 161 | 162 | #. module: endpoint_route_handler 163 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route 164 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route 165 | msgid "Route" 166 | msgstr "路由" 167 | 168 | #. module: endpoint_route_handler 169 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_group 170 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_group 171 | msgid "Route Group" 172 | msgstr "路由组" 173 | 174 | #. module: endpoint_route_handler 175 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler__route_type 176 | #: model:ir.model.fields,field_description:endpoint_route_handler.field_endpoint_route_handler_tool__route_type 177 | msgid "Route Type" 178 | msgstr "路由类型" 179 | 180 | #. module: endpoint_route_handler 181 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler__route_group 182 | #: model:ir.model.fields,help:endpoint_route_handler.field_endpoint_route_handler_tool__route_group 183 | msgid "Use this to classify routes together" 184 | msgstr "使用此选项将路由分类在一起" 185 | 186 | #. module: endpoint_route_handler 187 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_endpoint_endpoint_route_unique 188 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_mixin_endpoint_route_unique 189 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_endpoint_route_unique 190 | #: model:ir.model.constraint,message:endpoint_route_handler.constraint_endpoint_route_handler_tool_endpoint_route_unique 191 | msgid "You can register an endpoint route only once." 192 | msgstr "您只能注册一次端点路由。" 193 | 194 | #. module: endpoint_route_handler 195 | #. odoo-python 196 | #: code:addons/endpoint_route_handler/models/endpoint_route_handler.py:0 197 | msgid "`%(name)s` uses a blacklisted routed = `%(route)s`" 198 | msgstr "`%(name)s` 使用了黑名单路由 = `%(route)s`" 199 | -------------------------------------------------------------------------------- /endpoint_route_handler/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import endpoint_route_sync_mixin 2 | from . import endpoint_route_handler 3 | from . import endpoint_route_handler_tool 4 | from . import ir_http 5 | -------------------------------------------------------------------------------- /endpoint_route_handler/models/endpoint_route_handler_tool.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | 6 | from odoo import api, models 7 | 8 | 9 | class EndpointRouteHandlerTool(models.TransientModel): 10 | """Model meant to be used as a tool. 11 | 12 | From v15 on we cannot initialize AbstractModel using `new()` anymore. 13 | Here we proxy the abstract model with a transient model so that we can initialize it 14 | but we don't care at all about storing it in the DB. 15 | """ 16 | 17 | # TODO: try using `_auto = False` 18 | 19 | _name = "endpoint.route.handler.tool" 20 | _inherit = "endpoint.route.handler" 21 | _description = "Endpoint Route handler tool" 22 | 23 | def _refresh_endpoint_data(self): 24 | """Enforce refresh of route computed fields. 25 | 26 | Required for NewId records when using this model as a tool. 27 | """ 28 | self._compute_endpoint_hash() 29 | self._compute_route() 30 | 31 | def _register_controllers(self, init=False, options=None): 32 | if self: 33 | self._refresh_endpoint_data() 34 | return super()._register_controllers(init=init, options=options) 35 | 36 | def _unregister_controllers(self): 37 | if self: 38 | self._refresh_endpoint_data() 39 | return super()._unregister_controllers() 40 | 41 | @api.model 42 | def new(self, values=None, origin=None, ref=None): 43 | values = values or {} # note: in core odoo they use `{}` as defaul arg :/ 44 | res = super().new(values=values, origin=origin, ref=ref) 45 | res._refresh_endpoint_data() 46 | return res 47 | -------------------------------------------------------------------------------- /endpoint_route_handler/models/endpoint_route_sync_mixin.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import logging 6 | from functools import partial 7 | 8 | from odoo import api, fields, models 9 | 10 | from ..registry import EndpointRegistry 11 | 12 | _logger = logging.getLogger(__file__) 13 | 14 | 15 | class EndpointRouteSyncMixin(models.AbstractModel): 16 | """Mixin to handle synchronization of custom routes to the registry. 17 | 18 | Consumers of this mixin gain: 19 | 20 | * handling of sync state 21 | * sync helpers 22 | * automatic registration of routes on boot 23 | 24 | Consumers of this mixin must implement: 25 | 26 | * `_prepare_endpoint_rules` to retrieve all the `EndpointRule` to register 27 | * `_registered_endpoint_rule_keys` to retrieve all keys of registered rules 28 | """ 29 | 30 | _name = "endpoint.route.sync.mixin" 31 | _description = "Endpoint Route sync mixin" 32 | 33 | active = fields.Boolean(default=True) 34 | registry_sync = fields.Boolean( 35 | help="ON: the record has been modified and registry was not notified." 36 | "\nNo change will be active until this flag is set to false via proper action." 37 | "\n\nOFF: record in line with the registry, nothing to do.", 38 | default=False, 39 | copy=False, 40 | ) 41 | 42 | def write(self, vals): 43 | if any([x in vals for x in self._routing_impacting_fields() + ("active",)]): 44 | # Mark as out of sync 45 | vals["registry_sync"] = False 46 | res = super().write(vals) 47 | if vals.get("registry_sync"): 48 | # NOTE: this is not done on create to allow bulk reload of the envs 49 | # and avoid multiple env restarts in case of multiple edits 50 | # on one or more records in a row. 51 | self._add_after_commit_hook(self.ids) 52 | return res 53 | 54 | @api.model 55 | def _add_after_commit_hook(self, record_ids): 56 | self.env.cr.postcommit.add( 57 | partial(self._handle_registry_sync_post_commit, record_ids), 58 | ) 59 | 60 | def _handle_registry_sync(self, record_ids=None): 61 | """Register and un-register controllers for given records.""" 62 | record_ids = record_ids or self.ids 63 | _logger.info("%s sync registry for %s", self._name, str(record_ids)) 64 | records = self.browse(record_ids).exists() 65 | records.filtered(lambda x: x.active)._register_controllers() 66 | records.filtered(lambda x: not x.active)._unregister_controllers() 67 | 68 | def _handle_registry_sync_post_commit(self, record_ids=None): 69 | """Handle registry sync after commit. 70 | 71 | When the sync is triggered as a post-commit hook 72 | the env has been flushed already and the cursor committed, of course. 73 | Hence, we must commit explicitly. 74 | """ 75 | self._handle_registry_sync(record_ids=record_ids) 76 | self.env.cr.commit() # pylint: disable=invalid-commit 77 | 78 | @property 79 | def _endpoint_registry(self): 80 | return EndpointRegistry.registry_for(self.env.cr) 81 | 82 | def unlink(self): 83 | if not self._abstract: 84 | self._unregister_controllers() 85 | return super().unlink() 86 | 87 | def _register_controllers(self, init=False, options=None): 88 | if not self: 89 | return 90 | rules = self._prepare_endpoint_rules(options=options) 91 | self._endpoint_registry.update_rules(rules, init=init) 92 | self.env.registry.clear_cache("routing") 93 | _logger.debug( 94 | "%s registered controllers: %s", 95 | self._name, 96 | ", ".join([r.route for r in rules]), 97 | ) 98 | 99 | def _unregister_controllers(self): 100 | if not self: 101 | return 102 | self._endpoint_registry.drop_rules(self._registered_endpoint_rule_keys()) 103 | self.env.registry.clear_cache("routing") 104 | 105 | def _routing_impacting_fields(self, options=None): 106 | """Return list of fields that have impact on routing for current record.""" 107 | raise NotImplementedError() 108 | 109 | def _prepare_endpoint_rules(self, options=None): 110 | """Return list of `EndpointRule` instances for current record.""" 111 | raise NotImplementedError() 112 | 113 | def _registered_endpoint_rule_keys(self): 114 | """Return list of registered `EndpointRule` unique keys for current record.""" 115 | raise NotImplementedError() 116 | -------------------------------------------------------------------------------- /endpoint_route_handler/models/ir_http.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import logging 6 | from itertools import chain 7 | 8 | import werkzeug 9 | 10 | from odoo import http, models, tools 11 | 12 | from ..registry import EndpointRegistry 13 | 14 | _logger = logging.getLogger(__name__) 15 | 16 | 17 | class IrHttp(models.AbstractModel): 18 | _inherit = "ir.http" 19 | 20 | @classmethod 21 | def _endpoint_route_registry(cls, env): 22 | return EndpointRegistry.registry_for(env.cr) 23 | 24 | def _generate_routing_rules(self, modules, converters): 25 | # Override to inject custom endpoint rules. 26 | return chain( 27 | super()._generate_routing_rules(modules, converters), 28 | self._endpoint_routing_rules(), 29 | ) 30 | 31 | @classmethod 32 | def _endpoint_routing_rules(cls): 33 | """Yield custom endpoint rules""" 34 | e_registry = cls._endpoint_route_registry(http.request.env) 35 | for endpoint_rule in e_registry.get_rules(): 36 | _logger.debug("LOADING %s", endpoint_rule) 37 | endpoint = endpoint_rule.endpoint 38 | for url in endpoint_rule.routing["routes"]: 39 | yield (url, endpoint) 40 | 41 | @tools.ormcache("key", "cls._endpoint_route_last_version()", cache="routing") 42 | def routing_map(cls, key=None): 43 | res = super().routing_map(key=key) 44 | return res 45 | 46 | @classmethod 47 | def _endpoint_route_last_version(cls): 48 | res = cls._get_routing_map_last_version(http.request.env) 49 | return res 50 | 51 | @classmethod 52 | def _get_routing_map_last_version(cls, env): 53 | return cls._endpoint_route_registry(env).last_version() 54 | 55 | @classmethod 56 | def _auth_method_user_endpoint(cls): 57 | """Special method for user auth which raises Unauthorized when needed. 58 | 59 | If you get an HTTP request (instead of a JSON one), 60 | the standard `user` method raises `SessionExpiredException` 61 | when there's no user session. 62 | This leads to a redirect to `/web/login` 63 | which is not desiderable for technical endpoints. 64 | 65 | This method makes sure that no matter the type of request we get, 66 | a proper exception is raised. 67 | """ 68 | try: 69 | cls._auth_method_user() 70 | except http.SessionExpiredException as err: 71 | raise werkzeug.exceptions.Unauthorized() from err 72 | -------------------------------------------------------------------------------- /endpoint_route_handler/post_init_hook.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Camptocamp SA 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 3 | 4 | import logging 5 | 6 | from .registry import EndpointRegistry 7 | 8 | _logger = logging.getLogger(__name__) 9 | 10 | 11 | def post_init_hook(env): 12 | # this is the trigger that sends notifications when jobs change 13 | _logger.info("Create table") 14 | EndpointRegistry._setup_db(env.cr) 15 | -------------------------------------------------------------------------------- /endpoint_route_handler/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["whool"] 3 | build-backend = "whool.buildapi" 4 | -------------------------------------------------------------------------------- /endpoint_route_handler/readme/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | - Simone Orsi \<\> 2 | - Nguyen Minh Chien \<\> 3 | -------------------------------------------------------------------------------- /endpoint_route_handler/readme/DESCRIPTION.md: -------------------------------------------------------------------------------- 1 | Technical module that provides a base handler for adding and removing 2 | controller routes on the fly. 3 | 4 | Can be used as a mixin or as a tool. 5 | -------------------------------------------------------------------------------- /endpoint_route_handler/readme/ROADMAP.md: -------------------------------------------------------------------------------- 1 | - add api docs helpers 2 | 3 | - allow multiple HTTP methods on the same endpoint 4 | 5 | - multiple values for route and methods 6 | 7 | > keep the same in the ui for now, later own we can imagine a 8 | > multi-value selection or just add text field w/ proper validation 9 | > and cleanup 10 | > 11 | > remove the route field in the table of endpoint_route 12 | > 13 | > support a comma separated list of routes maybe support comma 14 | > separated list of methods use only routing.routes for generating the 15 | > rule sort and freeze its values to update the endpoint hash 16 | > 17 | > catch dup route exception on the sync to detect duplicated routes 18 | > and use the endpoint_hash to retrieve the real record (note: we 19 | > could store more info in the routing information which will stay in 20 | > the map) 21 | > 22 | > for customizing the rule behavior the endpoint the hook is to 23 | > override the registry lookup 24 | > 25 | > make EndpointRule class overridable on the registry 26 | 27 | NOTE in v16 we won't care anymore about odoo controller so the lookup of 28 | the controller can be simplified to a basic py obj that holds the 29 | routing info. 30 | -------------------------------------------------------------------------------- /endpoint_route_handler/readme/USAGE.md: -------------------------------------------------------------------------------- 1 | ## As a mixin 2 | 3 | Use standard Odoo inheritance: 4 | 5 | class MyModel(models.Model): 6 | _name = "my.model" 7 | _inherit = "endpoint.route.handler" 8 | 9 | Once you have this, each my.model record will generate a route. You can 10 | have a look at the endpoint module to see a real life example. 11 | 12 | The options of the routing rules are defined by the method 13 | \_default_endpoint_options. Here's an example from the endpoint module: 14 | 15 | def _default_endpoint_options_handler(self): 16 | return { 17 | "klass_dotted_path": "odoo.addons.endpoint.controllers.main.EndpointController", 18 | "method_name": "auto_endpoint", 19 | "default_pargs": (self.route,), 20 | } 21 | 22 | As you can see, you have to pass the references to the controller class 23 | and the method to use when the endpoint is called. And you can prepare 24 | some default arguments to pass. In this case, the route of the current 25 | record. 26 | 27 | ## As a tool 28 | 29 | Initialize non stored route handlers and generate routes from them. For 30 | instance: 31 | 32 | route_handler = self.env["endpoint.route.handler.tool"] 33 | endpoint_handler = MyController()._my_handler 34 | vals = { 35 | "name": "My custom route", 36 | "route": "/my/custom/route", 37 | "request_method": "GET", 38 | "auth_type": "public", 39 | } 40 | new_route = route_handler.new(vals) 41 | new_route._register_controller() 42 | 43 | You can override options and define - for instance - a different 44 | controller method: 45 | 46 | options = { 47 | "handler": { 48 | "klass_dotted_path": "odoo.addons.my_module.controllers.SpecialController", 49 | "method_name": "my_special_handler", 50 | } 51 | } 52 | new_route._register_controller(options=options) 53 | 54 | Of course, what happens when the endpoint gets called depends on the 55 | logic defined on the controller method. 56 | 57 | In both cases (mixin and tool) when a new route is generated or an 58 | existing one is updated, the ir.http.routing_map (which holds all Odoo 59 | controllers) will be updated. 60 | 61 | You can see a real life example on shopfloor.app model. 62 | -------------------------------------------------------------------------------- /endpoint_route_handler/security/ir.model.access.csv: -------------------------------------------------------------------------------- 1 | id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 2 | access_endpoint_route_handler_tool_mngr_edit,endpoint_route_handler mngr edit,model_endpoint_route_handler,base.group_system,1,1,1,1 3 | access_endpoint_route_handler_tool_edit_public,endpoint_route_handler edit,model_endpoint_route_handler,base.group_public,1,0,0,0 4 | access_endpoint_route_handler_tool_edit_portal,endpoint_route_handler edit,model_endpoint_route_handler,base.group_portal,1,0,0,0 5 | access_endpoint_route_handler_tool_edit_employee,endpoint_route_handler edit,model_endpoint_route_handler,base.group_user,1,0,0,0 6 | access_endpoint_route_handler_mngr_edit,endpoint_route_handler_tool mngr edit,model_endpoint_route_handler_tool,base.group_system,1,1,1,1 7 | access_endpoint_route_handler_edit_public,endpoint_route_handler_tool edit,model_endpoint_route_handler_tool,base.group_public,1,0,0,0 8 | access_endpoint_route_handler_edit_portal,endpoint_route_handler_tool edit,model_endpoint_route_handler_tool,base.group_portal,1,0,0,0 9 | access_endpoint_route_handler_edit_employee,endpoint_route_handler_tool edit,model_endpoint_route_handler_tool,base.group_user,1,0,0,0 10 | -------------------------------------------------------------------------------- /endpoint_route_handler/static/description/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OCA/web-api/12959dc92a6cf22c5878b8daa49acc73e0629279/endpoint_route_handler/static/description/icon.png -------------------------------------------------------------------------------- /endpoint_route_handler/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import test_registry 2 | from . import test_endpoint 3 | from . import test_endpoint_controller 4 | -------------------------------------------------------------------------------- /endpoint_route_handler/tests/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import contextlib 6 | 7 | from odoo.tests.common import TransactionCase, tagged 8 | from odoo.tools import DotDict 9 | 10 | from odoo.addons.website.tools import MockRequest 11 | 12 | 13 | @tagged("-at_install", "post_install") 14 | class CommonEndpoint(TransactionCase): 15 | @classmethod 16 | def setUpClass(cls): 17 | super().setUpClass() 18 | cls._setup_env() 19 | cls._setup_records() 20 | cls.route_handler = cls.env["endpoint.route.handler"] 21 | 22 | @classmethod 23 | def _setup_env(cls): 24 | cls.env = cls.env(context=cls._setup_context()) 25 | 26 | @classmethod 27 | def _setup_context(cls): 28 | return dict( 29 | cls.env.context, 30 | tracking_disable=True, 31 | ) 32 | 33 | @classmethod 34 | def _setup_records(cls): 35 | pass 36 | 37 | @contextlib.contextmanager 38 | def _get_mocked_request( 39 | self, env=None, httprequest=None, extra_headers=None, request_attrs=None 40 | ): 41 | with MockRequest(env or self.env) as mocked_request: 42 | mocked_request.httprequest = ( 43 | DotDict(httprequest) if httprequest else mocked_request.httprequest 44 | ) 45 | headers = {} 46 | headers.update(extra_headers or {}) 47 | mocked_request.httprequest.headers = headers 48 | request_attrs = request_attrs or {} 49 | for k, v in request_attrs.items(): 50 | setattr(mocked_request, k, v) 51 | mocked_request.make_response = lambda data, **kw: data 52 | mocked_request.registry._init_modules = set() 53 | yield mocked_request 54 | -------------------------------------------------------------------------------- /endpoint_route_handler/tests/fake_controllers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | from odoo import http 6 | 7 | 8 | class CTRLFake(http.Controller): 9 | # Shortcut for dotted path 10 | _path = "odoo.addons.endpoint_route_handler.tests.fake_controllers.CTRLFake" 11 | 12 | def handler1(self, arg1, arg2=2): 13 | return arg1, arg2 14 | 15 | def handler2(self, arg1, arg2=2): 16 | return arg1, arg2 17 | 18 | def custom_handler(self, custom=None): 19 | return f"Got: {custom}" 20 | 21 | 22 | class TestController(http.Controller): 23 | _path = "odoo.addons.endpoint_route_handler.tests.fake_controllers.TestController" 24 | 25 | def _do_something1(self, foo=None): 26 | body = f"Got: {foo}" 27 | return http.request.make_response(body) 28 | 29 | def _do_something2(self, default_arg, foo=None): 30 | body = f"{default_arg} -> got: {foo}" 31 | return http.request.make_response(body) 32 | -------------------------------------------------------------------------------- /endpoint_route_handler/tests/test_endpoint.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | from contextlib import contextmanager 5 | 6 | import odoo 7 | from odoo.tools import mute_logger 8 | 9 | from ..registry import EndpointRegistry 10 | from .common import CommonEndpoint 11 | from .fake_controllers import CTRLFake 12 | 13 | 14 | @contextmanager 15 | def new_rollbacked_env(): 16 | # Borrowed from `component` 17 | registry = odoo.modules.registry.Registry(odoo.tests.common.get_db_name()) 18 | uid = odoo.SUPERUSER_ID 19 | cr = registry.cursor() 20 | try: 21 | yield odoo.api.Environment(cr, uid, {}) 22 | finally: 23 | cr.rollback() # we shouldn't have to commit anything 24 | cr.close() 25 | 26 | 27 | def make_new_route(env, **kw): 28 | model = env["endpoint.route.handler.tool"] 29 | vals = { 30 | "name": "Test custom route", 31 | "route": "/my/test/route", 32 | "request_method": "GET", 33 | } 34 | vals.update(kw) 35 | new_route = model.new(vals) 36 | return new_route 37 | 38 | 39 | class TestEndpoint(CommonEndpoint): 40 | def tearDown(self): 41 | EndpointRegistry.wipe_registry_for(self.env.cr) 42 | super().tearDown() 43 | 44 | def test_as_tool_base_data(self): 45 | new_route = make_new_route(self.env) 46 | self.assertEqual(new_route.route, "/my/test/route") 47 | first_hash = new_route.endpoint_hash 48 | self.assertTrue(first_hash) 49 | new_route.route += "/new" 50 | self.assertNotEqual(new_route.endpoint_hash, first_hash) 51 | 52 | @mute_logger("odoo.addons.base.models.ir_http") 53 | def test_as_tool_register_single_controller(self): 54 | new_route = make_new_route(self.env) 55 | options = { 56 | "handler": { 57 | "klass_dotted_path": CTRLFake._path, 58 | "method_name": "custom_handler", 59 | } 60 | } 61 | 62 | with self._get_mocked_request(): 63 | new_route._register_single_controller(options=options, init=True) 64 | # Ensure the routing rule is registered 65 | rmap = self.env["ir.http"].routing_map() 66 | self.assertIn("/my/test/route", [x.rule for x in rmap._rules]) 67 | 68 | # Ensure is updated when needed 69 | new_route.route += "/new" 70 | with self._get_mocked_request(): 71 | new_route._register_single_controller(options=options, init=True) 72 | rmap = self.env["ir.http"].routing_map() 73 | self.assertNotIn("/my/test/route", [x.rule for x in rmap._rules]) 74 | self.assertIn("/my/test/route/new", [x.rule for x in rmap._rules]) 75 | 76 | @mute_logger("odoo.addons.base.models.ir_http") 77 | def test_as_tool_register_controllers(self): 78 | new_route = make_new_route(self.env) 79 | options = { 80 | "handler": { 81 | "klass_dotted_path": CTRLFake._path, 82 | "method_name": "custom_handler", 83 | } 84 | } 85 | 86 | with self._get_mocked_request(): 87 | new_route._register_controllers(options=options, init=True) 88 | # Ensure the routing rule is registered 89 | rmap = self.env["ir.http"].routing_map() 90 | self.assertIn("/my/test/route", [x.rule for x in rmap._rules]) 91 | 92 | # Ensure is updated when needed 93 | new_route.route += "/new" 94 | with self._get_mocked_request(): 95 | new_route._register_controllers(options=options, init=True) 96 | rmap = self.env["ir.http"].routing_map() 97 | self.assertNotIn("/my/test/route", [x.rule for x in rmap._rules]) 98 | self.assertIn("/my/test/route/new", [x.rule for x in rmap._rules]) 99 | 100 | @mute_logger("odoo.addons.base.models.ir_http") 101 | def test_as_tool_register_controllers_dynamic_route(self): 102 | route = "/my/app/" 103 | new_route = make_new_route(self.env, route=route) 104 | options = { 105 | "handler": { 106 | "klass_dotted_path": CTRLFake._path, 107 | "method_name": "custom_handler", 108 | } 109 | } 110 | 111 | with self._get_mocked_request(): 112 | new_route._register_controllers(options=options, init=True) 113 | # Ensure the routing rule is registered 114 | rmap = self.env["ir.http"].routing_map() 115 | self.assertIn(route, [x.rule for x in rmap._rules]) 116 | 117 | 118 | class TestEndpointCrossEnv(CommonEndpoint): 119 | def setUp(self): 120 | super().setUp() 121 | EndpointRegistry.wipe_registry_for(self.env.cr) 122 | 123 | @mute_logger("odoo.addons.base.models.ir_http", "odoo.modules.registry") 124 | def test_cross_env_consistency(self): 125 | """Ensure route updates are propagated to all envs.""" 126 | route = "/my/app/" 127 | new_route = make_new_route(self.env, route=route) 128 | options = { 129 | "handler": { 130 | "klass_dotted_path": CTRLFake._path, 131 | "method_name": "custom_handler", 132 | } 133 | } 134 | 135 | env1 = self.env 136 | reg = EndpointRegistry.registry_for(self.env.cr) 137 | new_route._register_controllers(options=options) 138 | 139 | last_version0 = reg.last_version() 140 | with self._get_mocked_request(): 141 | with new_rollbacked_env() as env2: 142 | # Load maps 143 | env1["ir.http"].routing_map() 144 | env2["ir.http"].routing_map() 145 | self.assertEqual( 146 | env1["ir.http"]._endpoint_route_last_version(), last_version0 147 | ) 148 | self.assertEqual( 149 | env2["ir.http"]._endpoint_route_last_version(), last_version0 150 | ) 151 | rmap = self.env["ir.http"].routing_map() 152 | self.assertIn(route, [x.rule for x in rmap._rules]) 153 | rmap = env2["ir.http"].routing_map() 154 | self.assertIn(route, [x.rule for x in rmap._rules]) 155 | 156 | # add new route 157 | route = "/my/new/" 158 | new_route = make_new_route(self.env, route=route) 159 | new_route._register_controllers(options=options) 160 | 161 | rmap = self.env["ir.http"].routing_map() 162 | self.assertIn(route, [x.rule for x in rmap._rules]) 163 | rmap = env2["ir.http"].routing_map() 164 | self.assertIn(route, [x.rule for x in rmap._rules]) 165 | self.assertTrue( 166 | env1["ir.http"]._endpoint_route_last_version() > last_version0 167 | ) 168 | self.assertTrue( 169 | env2["ir.http"]._endpoint_route_last_version() > last_version0 170 | ) 171 | 172 | # TODO: test unregister 173 | -------------------------------------------------------------------------------- /endpoint_route_handler/tests/test_endpoint_controller.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | import os 6 | import unittest 7 | 8 | from odoo.tests.common import HttpCase 9 | 10 | from ..registry import EndpointRegistry 11 | from .fake_controllers import TestController 12 | 13 | 14 | @unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "EndpointHttpCase skipped") 15 | class EndpointHttpCase(HttpCase): 16 | def setUp(self): 17 | super().setUp() 18 | self.route_handler = self.env["endpoint.route.handler.tool"] 19 | 20 | def tearDown(self): 21 | EndpointRegistry.wipe_registry_for(self.env.cr) 22 | super().tearDown() 23 | 24 | def _make_new_route(self, options=None, **kw): 25 | vals = { 26 | "name": "Test custom route", 27 | "request_method": "GET", 28 | "readonly": False, 29 | } 30 | vals.update(kw) 31 | new_route = self.route_handler.new(vals) 32 | new_route._register_controllers(options=options) 33 | return new_route 34 | 35 | def test_call(self): 36 | options = { 37 | "handler": { 38 | "klass_dotted_path": TestController._path, 39 | "method_name": "_do_something1", 40 | } 41 | } 42 | self._make_new_route(route="/my/test/", options=options) 43 | route = "/my/test/working" 44 | response = self.url_open(route) 45 | self.assertEqual(response.status_code, 401) 46 | # Let's login now 47 | self.authenticate("admin", "admin") 48 | response = self.url_open(route) 49 | self.assertEqual(response.status_code, 200) 50 | self.assertEqual(response.content, b"Got: working") 51 | 52 | def test_call_advanced_endpoint_handler(self): 53 | options = { 54 | "handler": { 55 | "klass_dotted_path": TestController._path, 56 | "method_name": "_do_something2", 57 | "default_pargs": ("DEFAULT",), 58 | } 59 | } 60 | self._make_new_route(route="/my/advanced/test/", options=options) 61 | route = "/my/advanced/test/working" 62 | response = self.url_open(route) 63 | self.assertEqual(response.status_code, 401) 64 | # Let's login now 65 | self.authenticate("admin", "admin") 66 | response = self.url_open(route) 67 | self.assertEqual(response.status_code, 200) 68 | self.assertEqual(response.content, b"DEFAULT -> got: working") 69 | -------------------------------------------------------------------------------- /endpoint_route_handler/tests/test_registry.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Camptocamp SA 2 | # @author: Simone Orsi 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 4 | 5 | from psycopg2 import DatabaseError 6 | 7 | from odoo.tests.common import TransactionCase, tagged 8 | from odoo.tools import mute_logger 9 | 10 | from odoo.addons.endpoint_route_handler.exceptions import EndpointHandlerNotFound 11 | from odoo.addons.endpoint_route_handler.registry import EndpointRegistry 12 | 13 | from .fake_controllers import CTRLFake 14 | 15 | 16 | @tagged("-at_install", "post_install") 17 | class TestRegistry(TransactionCase): 18 | @classmethod 19 | def setUpClass(cls): 20 | super().setUpClass() 21 | cls.reg = EndpointRegistry.registry_for(cls.env.cr) 22 | 23 | def setUp(self): 24 | super().setUp() 25 | EndpointRegistry.wipe_registry_for(self.env.cr) 26 | 27 | def tearDown(self): 28 | EndpointRegistry.wipe_registry_for(self.env.cr) 29 | super().tearDown() 30 | 31 | def _count_rules(self, groups=("test_route_handler",)): 32 | # NOTE: use alwways groups to filter in your tests 33 | # because some other module might add rules for testing. 34 | self.env.cr.execute( 35 | "SELECT COUNT(id) FROM endpoint_route WHERE route_group IN %s", (groups,) 36 | ) 37 | return self.env.cr.fetchone()[0] 38 | 39 | def test_registry_empty(self): 40 | self.assertEqual(list(self.reg.get_rules()), []) 41 | self.assertEqual(self._count_rules(), 0) 42 | 43 | def test_last_update(self): 44 | self.assertEqual(self.reg.last_update(), 0.0) 45 | rule1, rule2 = self._make_rules(stop=3) 46 | last_update0 = self.reg.last_update() 47 | self.assertTrue(last_update0 > 0) 48 | rule1.options = { 49 | "handler": { 50 | "klass_dotted_path": CTRLFake._path, 51 | "method_name": "handler2", 52 | } 53 | } 54 | # FIXME: to test timestamp we have to mock psql datetime. 55 | # self.reg.update_rules([rule1]) 56 | # last_update1 = self.reg.last_update() 57 | # self.assertTrue(last_update1 > last_update0) 58 | # rule2.options = { 59 | # "handler": { 60 | # "klass_dotted_path": CTRLFake._path, 61 | # "method_name": "handler2", 62 | # } 63 | # } 64 | # self.reg.update_rules([rule2]) 65 | # last_update2 = self.reg.last_update() 66 | # self.assertTrue(last_update2 > last_update1) 67 | 68 | def test_last_version(self): 69 | last_version0 = self.reg.last_version() 70 | self._make_rules(stop=3) 71 | last_version1 = self.reg.last_version() 72 | self.assertTrue(last_version1 > last_version0) 73 | 74 | def _make_rules(self, stop=5, start=1, **kw): 75 | res = [] 76 | for i in range(start, stop): 77 | key = f"route{i}" 78 | route = f"/test/{i}" 79 | options = { 80 | "handler": { 81 | "klass_dotted_path": CTRLFake._path, 82 | "method_name": "handler1", 83 | } 84 | } 85 | routing = {"routes": []} 86 | endpoint_hash = i 87 | route_group = "test_route_handler" 88 | rule = self.reg.make_rule( 89 | key, 90 | route, 91 | options, 92 | routing, 93 | endpoint_hash, 94 | route_group=route_group, 95 | ) 96 | for k, v in kw.items(): 97 | setattr(rule, k, v) 98 | res.append(rule) 99 | self.reg.update_rules(res) 100 | return res 101 | 102 | def test_add_rule(self): 103 | self._make_rules(stop=5) 104 | self.assertEqual(self._count_rules(), 4) 105 | self.assertEqual(self.reg._get_rule("route1").endpoint_hash, "1") 106 | self.assertEqual(self.reg._get_rule("route2").endpoint_hash, "2") 107 | self.assertEqual(self.reg._get_rule("route3").endpoint_hash, "3") 108 | self.assertEqual(self.reg._get_rule("route4").endpoint_hash, "4") 109 | 110 | def test_get_rules(self): 111 | self._make_rules(stop=4) 112 | self.assertEqual(self._count_rules(), 3) 113 | self.assertEqual( 114 | [x.key for x in self.reg.get_rules()], ["route1", "route2", "route3"] 115 | ) 116 | self._make_rules(start=10, stop=14) 117 | self.assertEqual(self._count_rules(), 7) 118 | self.reg.get_rules() 119 | self.assertEqual( 120 | sorted([x.key for x in self.reg.get_rules()]), 121 | sorted( 122 | [ 123 | "route1", 124 | "route2", 125 | "route3", 126 | "route10", 127 | "route11", 128 | "route12", 129 | "route13", 130 | ] 131 | ), 132 | ) 133 | 134 | def test_update_rule(self): 135 | rule1, rule2 = self._make_rules(stop=3) 136 | self.assertEqual( 137 | self.reg._get_rule("route1").handler_options.method_name, "handler1" 138 | ) 139 | self.assertEqual( 140 | self.reg._get_rule("route2").handler_options.method_name, "handler1" 141 | ) 142 | rule1.options = { 143 | "handler": { 144 | "klass_dotted_path": CTRLFake._path, 145 | "method_name": "handler2", 146 | } 147 | } 148 | rule2.options = { 149 | "handler": { 150 | "klass_dotted_path": CTRLFake._path, 151 | "method_name": "handler3", 152 | } 153 | } 154 | self.reg.update_rules([rule1, rule2]) 155 | self.assertEqual( 156 | self.reg._get_rule("route1").handler_options.method_name, "handler2" 157 | ) 158 | self.assertEqual( 159 | self.reg._get_rule("route2").handler_options.method_name, "handler3" 160 | ) 161 | 162 | @mute_logger("odoo.sql_db") 163 | def test_rule_constraints(self): 164 | rule1, rule2 = self._make_rules(stop=3) 165 | msg = ( 166 | 'duplicate key value violates unique constraint "endpoint_route__key_uniq"' 167 | ) 168 | with self.assertRaisesRegex(DatabaseError, msg), self.env.cr.savepoint(): 169 | self.reg._create({rule1.key: rule1.to_row()}) 170 | msg = ( 171 | "duplicate key value violates unique constraint " 172 | '"endpoint_route__endpoint_hash_uniq"' 173 | ) 174 | with self.assertRaisesRegex(DatabaseError, msg), self.env.cr.savepoint(): 175 | rule2.endpoint_hash = rule1.endpoint_hash 176 | rule2.key = "key3" 177 | self.reg._create({rule2.key: rule2.to_row()}) 178 | 179 | def test_drop_rule(self): 180 | rules = self._make_rules(stop=3) 181 | self.assertEqual(self._count_rules(), 2) 182 | self.reg.drop_rules([x.key for x in rules]) 183 | self.assertEqual(self._count_rules(), 0) 184 | 185 | def test_endpoint_lookup_ko(self): 186 | options = { 187 | "handler": { 188 | "klass_dotted_path": "no.where.to.be.SeenKlass", 189 | "method_name": "foo", 190 | } 191 | } 192 | rule = self._make_rules(stop=2, options=options)[0] 193 | with self.assertRaises(EndpointHandlerNotFound): 194 | rule.endpoint # pylint: disable=pointless-statement # noqa: B018 195 | 196 | def test_endpoint_lookup_ok(self): 197 | rule = self._make_rules(stop=2)[0] 198 | expected = ( 199 | "`_. 46 | In case of trouble, please check there if your issue has already been reported. 47 | If you spotted it first, help us to smash it by providing a detailed and welcomed 48 | `feedback `_. 49 | 50 | Do not contact contributors directly about support or help with technical issues. 51 | 52 | Credits 53 | ======= 54 | 55 | Authors 56 | ------- 57 | 58 | * Creu Blanca 59 | * Camptocamp 60 | 61 | Contributors 62 | ------------ 63 | 64 | - Enric Tobella 65 | - Alexandre Fayolle 66 | 67 | Maintainers 68 | ----------- 69 | 70 | This module is maintained by the OCA. 71 | 72 | .. image:: https://odoo-community.org/logo.png 73 | :alt: Odoo Community Association 74 | :target: https://odoo-community.org 75 | 76 | OCA, or the Odoo Community Association, is a nonprofit organization whose 77 | mission is to support the collaborative development of Odoo features and 78 | promote its widespread use. 79 | 80 | .. |maintainer-etobella| image:: https://github.com/etobella.png?size=40px 81 | :target: https://github.com/etobella 82 | :alt: etobella 83 | 84 | Current `maintainer `__: 85 | 86 | |maintainer-etobella| 87 | 88 | This module is part of the `OCA/web-api `_ project on GitHub. 89 | 90 | You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. 91 | -------------------------------------------------------------------------------- /webservice/__init__.py: -------------------------------------------------------------------------------- 1 | from . import components 2 | from . import models 3 | from . import controllers 4 | -------------------------------------------------------------------------------- /webservice/__manifest__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Creu Blanca 2 | # Copyright 2022 Camptocamp SA 3 | # @author Simone Orsi 4 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 5 | 6 | { 7 | "name": "WebService", 8 | "summary": """Defines webservice abstract definition to be used generally""", 9 | "version": "18.0.1.1.0", 10 | "license": "AGPL-3", 11 | "development_status": "Production/Stable", 12 | "maintainers": ["etobella"], 13 | "author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)", 14 | "website": "https://github.com/OCA/web-api", 15 | "depends": ["component", "server_environment"], 16 | "external_dependencies": {"python": ["requests-oauthlib", "oauthlib", "responses"]}, 17 | "data": [ 18 | "security/ir.model.access.csv", 19 | "security/ir_rule.xml", 20 | "views/webservice_backend.xml", 21 | ], 22 | "demo": [], 23 | } 24 | -------------------------------------------------------------------------------- /webservice/components/__init__.py: -------------------------------------------------------------------------------- 1 | from . import base_adapter 2 | from . import request_adapter 3 | -------------------------------------------------------------------------------- /webservice/components/base_adapter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Creu Blanca 2 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 3 | 4 | from odoo.addons.component.core import AbstractComponent 5 | 6 | 7 | class BaseWebServiceAdapter(AbstractComponent): 8 | _name = "base.webservice.adapter" 9 | _collection = "webservice.backend" 10 | _webservice_protocol = False 11 | _usage = "webservice.request" 12 | 13 | @classmethod 14 | def _component_match(cls, work, usage=None, model_name=None, **kw): 15 | """Override to customize match. 16 | 17 | Registry lookup filtered by usage and model_name when landing here. 18 | Now, narrow match to `_match_attrs` attributes. 19 | """ 20 | return kw.get("webservice_protocol") in (None, cls._webservice_protocol) 21 | -------------------------------------------------------------------------------- /webservice/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import oauth2 2 | -------------------------------------------------------------------------------- /webservice/controllers/oauth2.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Camptocamp SA 2 | # @author Alexandre Fayolle 3 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 4 | import json 5 | import logging 6 | 7 | from oauthlib.oauth2.rfc6749 import errors 8 | from werkzeug.urls import url_encode 9 | 10 | from odoo import http 11 | from odoo.http import request 12 | 13 | _logger = logging.getLogger(__name__) 14 | 15 | 16 | class OAuth2Controller(http.Controller): 17 | @http.route( 18 | "/webservice//oauth2/redirect", 19 | type="http", 20 | auth="public", 21 | csrf=False, 22 | ) 23 | def redirect(self, backend_id, **params): 24 | backend = request.env["webservice.backend"].browse(backend_id).sudo() 25 | if backend.auth_type != "oauth2" or backend.oauth2_flow != "web_application": 26 | _logger.error("unexpected backed config for backend %d", backend_id) 27 | raise errors.MismatchingRedirectURIError() 28 | expected_state = backend.oauth2_state 29 | state = params.get("state") 30 | if state != expected_state: 31 | _logger.error("unexpected state: %s", state) 32 | raise errors.MismatchingStateError() 33 | code = params.get("code") 34 | adapter = ( 35 | backend._get_adapter() 36 | ) # we expect an adapter that supports web_application 37 | token = adapter._fetch_token_from_authorization(code) 38 | backend.write( 39 | { 40 | "oauth2_token": json.dumps(token), 41 | "oauth2_state": False, 42 | } 43 | ) 44 | # after saving the token, redirect to the backend form view 45 | uid = request.session.uid 46 | user = request.env["res.users"].sudo().browse(uid) 47 | cids = request.httprequest.cookies.get("cids", str(user.company_id.id)) 48 | cids = [int(cid) for cid in cids.split(",")] 49 | record_action = backend._get_access_action() 50 | url_params = { 51 | "model": backend._name, 52 | "id": backend.id, 53 | "active_id": backend.id, 54 | "action": record_action.get("id"), 55 | } 56 | view_id = backend.get_formview_id() 57 | if view_id: 58 | url_params["view_id"] = view_id 59 | 60 | if cids: 61 | url_params["cids"] = ",".join([str(cid) for cid in cids]) 62 | url = f"/web?#{url_encode(url_params)}" 63 | return request.redirect(url) 64 | -------------------------------------------------------------------------------- /webservice/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import webservice_backend 2 | -------------------------------------------------------------------------------- /webservice/models/webservice_backend.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Creu Blanca 2 | # Copyright 2022 Camptocamp SA 3 | # @author Simone Orsi 4 | # @author Alexandre Fayolle 5 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 6 | import logging 7 | 8 | from odoo import _, api, exceptions, fields, models 9 | from odoo.tools import config 10 | 11 | _logger = logging.getLogger(__name__) 12 | 13 | 14 | class WebserviceBackend(models.Model): 15 | _name = "webservice.backend" 16 | _inherit = ["collection.base", "server.env.techname.mixin", "server.env.mixin"] 17 | _description = "WebService Backend" 18 | 19 | name = fields.Char(required=True) 20 | tech_name = fields.Char(required=True) 21 | protocol = fields.Selection([("http", "HTTP Request")], required=True) 22 | url = fields.Char(required=True) 23 | auth_type = fields.Selection( 24 | selection=[ 25 | ("none", "Public"), 26 | ("user_pwd", "Username & password"), 27 | ("api_key", "API Key"), 28 | ("oauth2", "OAuth2"), 29 | ], 30 | required=True, 31 | ) 32 | username = fields.Char(auth_type="user_pwd") 33 | password = fields.Char(auth_type="user_pwd") 34 | api_key = fields.Char(string="API Key", auth_type="api_key") 35 | api_key_header = fields.Char(string="API Key header", auth_type="api_key") 36 | oauth2_flow = fields.Selection( 37 | [ 38 | ("backend_application", "Backend Application (Client Credentials Grant)"), 39 | ("web_application", "Web Application (Authorization Code Grant)"), 40 | ], 41 | readonly=False, 42 | ) 43 | oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2") 44 | oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2") 45 | oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2") 46 | oauth2_authorization_url = fields.Char(string="Authorization URL") 47 | oauth2_audience = fields.Char( 48 | string="Audience" 49 | # no auth_type because not required 50 | ) 51 | oauth2_scope = fields.Char(help="scope of the the authorization") 52 | oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)") 53 | redirect_url = fields.Char( 54 | compute="_compute_redirect_url", 55 | help="The redirect URL to be used as part of the OAuth2 authorisation flow", 56 | ) 57 | oauth2_state = fields.Char( 58 | help="random key generated when authorization flow starts " 59 | "to ensure that no CSRF attack happen" 60 | ) 61 | content_type = fields.Selection( 62 | [ 63 | ("application/json", "JSON"), 64 | ("application/xml", "XML"), 65 | ("application/x-www-form-urlencoded", "Form"), 66 | ], 67 | ) 68 | company_id = fields.Many2one("res.company", string="Company") 69 | 70 | @api.constrains("auth_type") 71 | def _check_auth_type(self): 72 | valid_fields = { 73 | k: v for k, v in self._fields.items() if hasattr(v, "auth_type") 74 | } 75 | for rec in self: 76 | if rec.auth_type == "none": 77 | continue 78 | _fields = [v for v in valid_fields.values() if v.auth_type == rec.auth_type] 79 | missing = [] 80 | for _field in _fields: 81 | if not rec[_field.name]: 82 | missing.append(_field) 83 | if missing: 84 | raise exceptions.UserError(rec._msg_missing_auth_param(missing)) 85 | 86 | def _msg_missing_auth_param(self, missing_fields): 87 | def get_selection_value(fname): 88 | return self._fields.get(fname).convert_to_export(self[fname], self) 89 | 90 | return _( 91 | "Webservice '%(name)s' requires '%(auth_type)s' authentication. " 92 | "However, the following field(s) are not valued: %(fields)s" 93 | ) % { 94 | "name": self.name, 95 | "auth_type": get_selection_value("auth_type"), 96 | "fields": ", ".join([f.string for f in missing_fields]), 97 | } 98 | 99 | def _valid_field_parameter(self, field, name): 100 | extra_params = ("auth_type",) 101 | return name in extra_params or super()._valid_field_parameter(field, name) 102 | 103 | def call(self, method, *args, **kwargs): 104 | _logger.debug("backend %s: call %s %s %s", self.name, method, args, kwargs) 105 | response = getattr(self._get_adapter(), method)(*args, **kwargs) 106 | _logger.debug("backend %s: response: \n%s", self.name, response) 107 | return response 108 | 109 | def _get_adapter(self): 110 | with self.work_on(self._name) as work: 111 | return work.component( 112 | usage="webservice.request", 113 | webservice_protocol=self._get_adapter_protocol(), 114 | ) 115 | 116 | def _get_adapter_protocol(self): 117 | protocol = self.protocol 118 | if self.auth_type.startswith("oauth2"): 119 | protocol += f"+{self.auth_type}-{self.oauth2_flow}" 120 | return protocol 121 | 122 | @api.depends("auth_type", "oauth2_flow") 123 | def _compute_redirect_url(self): 124 | get_param = self.env["ir.config_parameter"].sudo().get_param 125 | base_url = get_param("web.base.url") 126 | if base_url.startswith("http://") and not config["test_enable"]: 127 | _logger.warning( 128 | "web.base.url is configured in http. Oauth2 requires using https" 129 | ) 130 | base_url = base_url[len("http://") :] 131 | if not base_url.startswith("https://"): 132 | base_url = f"https://{base_url}" 133 | for rec in self: 134 | if rec.auth_type == "oauth2" and rec.oauth2_flow == "web_application": 135 | rec.redirect_url = f"{base_url}/webservice/{rec.id}/oauth2/redirect" 136 | else: 137 | rec.redirect_url = False 138 | 139 | def button_authorize(self): 140 | _logger.info("Button OAuth2 Authorize") 141 | authorize_url = self._get_adapter().redirect_to_authorize() 142 | _logger.info("Redirecting to %s", authorize_url) 143 | return { 144 | "type": "ir.actions.act_url", 145 | "url": authorize_url, 146 | "target": "self", 147 | } 148 | 149 | @property 150 | def _server_env_fields(self): 151 | base_fields = super()._server_env_fields 152 | webservice_fields = { 153 | "protocol": {}, 154 | "url": {}, 155 | "auth_type": {}, 156 | "username": {}, 157 | "password": {}, 158 | "api_key": {}, 159 | "api_key_header": {}, 160 | "content_type": {}, 161 | "oauth2_flow": {}, 162 | "oauth2_scope": {}, 163 | "oauth2_clientid": {}, 164 | "oauth2_client_secret": {}, 165 | "oauth2_authorization_url": {}, 166 | "oauth2_token_url": {}, 167 | "oauth2_audience": {}, 168 | } 169 | webservice_fields.update(base_fields) 170 | return webservice_fields 171 | 172 | def _compute_server_env(self): 173 | # OVERRIDE: reset ``oauth2_flow`` when ``auth_type`` is not "oauth2", even if 174 | # defined otherwise in server env vars 175 | res = super()._compute_server_env() 176 | self.filtered(lambda r: r.auth_type != "oauth2").oauth2_flow = None 177 | return res 178 | -------------------------------------------------------------------------------- /webservice/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["whool"] 3 | build-backend = "whool.buildapi" 4 | -------------------------------------------------------------------------------- /webservice/readme/CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | - Enric Tobella \<\> 2 | - Alexandre Fayolle \<\> 3 | -------------------------------------------------------------------------------- /webservice/readme/DESCRIPTION.md: -------------------------------------------------------------------------------- 1 | This module creates WebService frameworks to be used globally. 2 | 3 | The module introduces support for HTTP Request protocol. The webservice HTTP call returns by default the content of the response. A context 'content_only' can be passed to get the full response object. 4 | -------------------------------------------------------------------------------- /webservice/security/ir.model.access.csv: -------------------------------------------------------------------------------- 1 | id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 2 | access_webservice_backend_edit,webservice_backend edit,model_webservice_backend,base.group_system,1,1,1,1 3 | -------------------------------------------------------------------------------- /webservice/security/ir_rule.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | webservice_backend multi-company 5 | 6 | 7 | ['|', ('company_id','=',False), ('company_id', 'in', company_ids)] 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /webservice/static/description/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OCA/web-api/12959dc92a6cf22c5878b8daa49acc73e0629279/webservice/static/description/icon.png -------------------------------------------------------------------------------- /webservice/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import test_oauth2 2 | from . import test_webservice 3 | from . import test_utils 4 | -------------------------------------------------------------------------------- /webservice/tests/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Creu Blanca 2 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 3 | from contextlib import contextmanager 4 | from unittest import mock 5 | from urllib.parse import urlparse 6 | 7 | from requests import PreparedRequest, Session 8 | 9 | from odoo.tests.common import tagged 10 | 11 | from odoo.addons.component.tests.common import TransactionComponentCase 12 | 13 | 14 | @tagged("-at_install", "post_install") 15 | class CommonWebService(TransactionComponentCase): 16 | @classmethod 17 | def _setup_context(cls): 18 | return dict( 19 | cls.env.context, 20 | tracking_disable=True, 21 | queue_job__no_delay=True, 22 | ) 23 | 24 | @classmethod 25 | def _setup_env(cls): 26 | cls.env = cls.env(context=cls._setup_context()) 27 | 28 | @classmethod 29 | def _setup_records(cls): 30 | pass 31 | 32 | @classmethod 33 | def setUpClass(cls): 34 | cls._super_send = Session.send 35 | super().setUpClass() 36 | cls._setup_env() 37 | cls._setup_records() 38 | 39 | @classmethod 40 | def _request_handler(cls, s: Session, r: PreparedRequest, /, **kw): 41 | if urlparse(r.url).netloc in ("localhost.demo.odoo", "custom.url"): 42 | return cls._super_send(s, r) 43 | return super()._request_handler(s, r, **kw) 44 | 45 | 46 | @contextmanager 47 | def mock_cursor(cr): 48 | # Preserve the original methods and attributes 49 | org_close = cr.close 50 | org_autocommit = cr._cnx.autocommit 51 | org_commit = cr.commit 52 | 53 | try: 54 | # Mock methods and attributes 55 | cr.close = mock.Mock() 56 | cr.commit = mock.Mock() 57 | # Mocking the autocommit attribute 58 | mock_autocommit = mock.PropertyMock(return_value=False) 59 | type(cr._cnx).autocommit = mock_autocommit 60 | 61 | # Mock the cursor method to return the current cr 62 | with mock.patch("odoo.sql_db.Connection.cursor", return_value=cr): 63 | yield cr 64 | 65 | finally: 66 | # Restore the original methods and attributes 67 | cr.close = org_close 68 | cr.commit = org_commit 69 | # Restore the original autocommit property 70 | type(cr._cnx).autocommit = org_autocommit 71 | -------------------------------------------------------------------------------- /webservice/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Camptocamp SA 2 | # @author Simone Orsi 3 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 4 | 5 | 6 | from odoo.tests.common import BaseCase 7 | 8 | from odoo.addons.webservice.utils import sanitize_url_for_log 9 | 10 | 11 | class TestUtils(BaseCase): 12 | def test_url_cleanup(self): 13 | url = "https://custom.url/?a=1&apikey=secret&password=moresecret" 14 | self.assertEqual(sanitize_url_for_log(url), "https://custom.url/?a=1") 15 | -------------------------------------------------------------------------------- /webservice/tests/test_webservice.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Creu Blanca 2 | # Copyright 2022 Camptocamp SA 3 | # @author Simone Orsi 4 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 5 | 6 | import responses 7 | from requests import auth 8 | from requests import exceptions as http_exceptions 9 | 10 | from odoo import exceptions 11 | 12 | from .common import CommonWebService 13 | 14 | 15 | class TestWebService(CommonWebService): 16 | @classmethod 17 | def _setup_records(cls): 18 | res = super()._setup_records() 19 | cls.url = "https://localhost.demo.odoo/" 20 | cls.webservice = cls.env["webservice.backend"].create( 21 | { 22 | "name": "WebService", 23 | "protocol": "http", 24 | "url": cls.url, 25 | "content_type": "application/xml", 26 | "tech_name": "demo_ws", 27 | "auth_type": "none", 28 | } 29 | ) 30 | return res 31 | 32 | def test_web_service_not_found(self): 33 | with self.assertRaises(http_exceptions.ConnectionError): 34 | self.webservice.call("get") 35 | 36 | def test_auth_validation(self): 37 | msg = ( 38 | r"Webservice 'WebService' " 39 | r"requires 'Username & password' authentication. " 40 | r"However, the following field\(s\) are not valued: Username, Password" 41 | ) 42 | with self.assertRaisesRegex(exceptions.UserError, msg): 43 | self.webservice.write( 44 | { 45 | "auth_type": "user_pwd", 46 | } 47 | ) 48 | 49 | msg = ( 50 | r"Webservice 'WebService' " 51 | r"requires 'Username & password' authentication. " 52 | r"However, the following field\(s\) are not valued: Password" 53 | ) 54 | with self.assertRaisesRegex(exceptions.UserError, msg): 55 | self.webservice.write({"auth_type": "user_pwd", "username": "user"}) 56 | 57 | msg = ( 58 | r"Webservice 'WebService' " 59 | r"requires 'API Key' authentication. " 60 | r"However, the following field\(s\) are not valued: API Key, API Key header" 61 | ) 62 | with self.assertRaisesRegex(exceptions.UserError, msg): 63 | self.webservice.write( 64 | { 65 | "auth_type": "api_key", 66 | } 67 | ) 68 | 69 | msg = ( 70 | r"Webservice 'WebService' " 71 | r"requires 'API Key' authentication. " 72 | r"However, the following field\(s\) are not valued: API Key header" 73 | ) 74 | with self.assertRaisesRegex(exceptions.UserError, msg): 75 | self.webservice.write( 76 | { 77 | "auth_type": "api_key", 78 | "api_key": "foo", 79 | } 80 | ) 81 | 82 | @responses.activate 83 | def test_web_service_get(self): 84 | responses.add(responses.GET, self.url, body="{}") 85 | result = self.webservice.call("get") 86 | self.assertEqual(result, b"{}") 87 | self.assertEqual(len(responses.calls), 1) 88 | self.assertEqual( 89 | responses.calls[0].request.headers["Content-Type"], "application/xml" 90 | ) 91 | 92 | @responses.activate 93 | def test_web_service_get_url_combine(self): 94 | endpoint = "api/test" 95 | responses.add(responses.GET, self.url + endpoint, body="{}") 96 | result = self.webservice.call("get", url="api/test") 97 | self.assertEqual(result, b"{}") 98 | self.assertEqual(len(responses.calls), 1) 99 | self.assertEqual( 100 | responses.calls[0].request.headers["Content-Type"], "application/xml" 101 | ) 102 | 103 | @responses.activate 104 | def test_web_service_get_url_combine_full_url(self): 105 | endpoint = "api/test" 106 | responses.add(responses.GET, self.url + endpoint, body="{}") 107 | result = self.webservice.call("get", url="https://localhost.demo.odoo/api/test") 108 | self.assertEqual(result, b"{}") 109 | self.assertEqual(len(responses.calls), 1) 110 | self.assertEqual( 111 | responses.calls[0].request.headers["Content-Type"], "application/xml" 112 | ) 113 | 114 | @responses.activate 115 | def test_web_service_post(self): 116 | responses.add(responses.POST, self.url, body="{}") 117 | result = self.webservice.call("post", data="demo_response") 118 | self.assertEqual(result, b"{}") 119 | self.assertEqual( 120 | responses.calls[0].request.headers["Content-Type"], "application/xml" 121 | ) 122 | self.assertEqual(responses.calls[0].request.body, "demo_response") 123 | 124 | @responses.activate 125 | def test_web_service_put(self): 126 | responses.add(responses.PUT, self.url, body="{}") 127 | result = self.webservice.call("put", data="demo_response") 128 | self.assertEqual(result, b"{}") 129 | self.assertEqual( 130 | responses.calls[0].request.headers["Content-Type"], "application/xml" 131 | ) 132 | self.assertEqual(responses.calls[0].request.body, "demo_response") 133 | 134 | @responses.activate 135 | def test_web_service_backend_username(self): 136 | self.webservice.write( 137 | {"auth_type": "user_pwd", "username": "user", "password": "pass"} 138 | ) 139 | responses.add(responses.GET, self.url, body="{}") 140 | result = self.webservice.call("get") 141 | self.assertEqual(result, b"{}") 142 | self.assertEqual(len(responses.calls), 1) 143 | self.assertEqual( 144 | responses.calls[0].request.headers["Content-Type"], "application/xml" 145 | ) 146 | data = auth._basic_auth_str("user", "pass") 147 | self.assertEqual(responses.calls[0].request.headers["Authorization"], data) 148 | 149 | @responses.activate 150 | def test_web_service_username(self): 151 | self.webservice.write( 152 | {"auth_type": "user_pwd", "username": "user", "password": "pass"} 153 | ) 154 | responses.add(responses.GET, self.url, body="{}") 155 | result = self.webservice.call("get", auth=("user2", "pass2")) 156 | self.assertEqual(result, b"{}") 157 | self.assertEqual(len(responses.calls), 1) 158 | self.assertEqual( 159 | responses.calls[0].request.headers["Content-Type"], "application/xml" 160 | ) 161 | data = auth._basic_auth_str("user2", "pass2") 162 | self.assertEqual(responses.calls[0].request.headers["Authorization"], data) 163 | 164 | @responses.activate 165 | def test_web_service_backend_api_key(self): 166 | self.webservice.write( 167 | {"auth_type": "api_key", "api_key": "123xyz", "api_key_header": "Api-Key"} 168 | ) 169 | responses.add(responses.POST, self.url, body="{}") 170 | result = self.webservice.call("post") 171 | self.assertEqual(result, b"{}") 172 | self.assertEqual(len(responses.calls), 1) 173 | self.assertEqual( 174 | responses.calls[0].request.headers["Content-Type"], "application/xml" 175 | ) 176 | self.assertEqual(responses.calls[0].request.headers["Api-Key"], "123xyz") 177 | 178 | @responses.activate 179 | def test_web_service_headers(self): 180 | responses.add(responses.GET, self.url, body="{}") 181 | result = self.webservice.call("get", headers={"demo_header": "HEADER"}) 182 | self.assertEqual(result, b"{}") 183 | self.assertEqual(len(responses.calls), 1) 184 | self.assertEqual( 185 | responses.calls[0].request.headers["Content-Type"], "application/xml" 186 | ) 187 | self.assertEqual(responses.calls[0].request.headers["demo_header"], "HEADER") 188 | 189 | @responses.activate 190 | def test_web_service_call_args(self): 191 | url = "https://custom.url" 192 | responses.add(responses.POST, url, body="{}") 193 | result = self.webservice.call( 194 | "post", url=url, headers={"demo_header": "HEADER"} 195 | ) 196 | self.assertEqual(result, b"{}") 197 | self.assertEqual(len(responses.calls), 1) 198 | self.assertEqual( 199 | responses.calls[0].request.headers["Content-Type"], "application/xml" 200 | ) 201 | self.assertEqual(responses.calls[0].request.headers["demo_header"], "HEADER") 202 | 203 | url = self.url + "custom/path" 204 | self.webservice.url += "{endpoint}" 205 | responses.add(responses.POST, url, body="{}") 206 | result = self.webservice.call( 207 | "post", 208 | url_params={"endpoint": "custom/path"}, 209 | headers={"demo_header": "HEADER"}, 210 | ) 211 | self.assertEqual(result, b"{}") 212 | self.assertEqual(len(responses.calls), 2) 213 | self.assertEqual( 214 | responses.calls[0].request.headers["Content-Type"], "application/xml" 215 | ) 216 | self.assertEqual(responses.calls[0].request.headers["demo_header"], "HEADER") 217 | -------------------------------------------------------------------------------- /webservice/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Camptocamp SA 2 | # @author Simone Orsi 3 | # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). 4 | 5 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 6 | 7 | 8 | def sanitize_url_for_log(url, blacklisted_keys=None): 9 | """Sanitize url to avoid loggin sensitive data""" 10 | blacklisted_keys = blacklisted_keys or ("apikey", "password", "pwd") 11 | parsed = urlparse(url) 12 | query = parse_qs(parsed.query, keep_blank_values=False) 13 | clean_query = {} 14 | 15 | def is_blacklisted(k): 16 | for bl_key in blacklisted_keys: 17 | if bl_key.lower() in k.lower(): 18 | return True 19 | 20 | for k, v in query.items(): 21 | if not is_blacklisted(k): 22 | clean_query[k] = v 23 | 24 | parsed = parsed._replace(query=urlencode(clean_query, True)) 25 | return urlunparse(parsed) 26 | -------------------------------------------------------------------------------- /webservice/views/webservice_backend.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | webservice.backend.form (in webservice) 7 | webservice.backend 8 | 9 |
10 |
11 |
18 | 19 |
20 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 42 | 48 | 54 | 59 | 64 | 68 | 73 | 78 | 83 | 88 | 93 | 97 | 98 |
99 |
100 |
101 |
102 | 103 | 104 | webservice.backend.search (in webservice) 105 | webservice.backend 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | webservice.backend.tree (in webservice) 118 | webservice.backend 119 | 120 | 121 | 122 | 123 | 124 | 125 | 130 | 131 | 132 | 133 | 134 | 135 | WebService Backend 136 | webservice.backend 137 | list,form 138 | [] 139 | {} 140 | 141 | 142 | 143 | WebService Backend 144 | 145 | 146 | 147 | 148 |
149 | --------------------------------------------------------------------------------