├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .towncrier-template.rst ├── CHANGES.rst ├── LICENSE ├── README.rst ├── click_odoo_contrib ├── __init__.py ├── _addon_hash.py ├── _backup.py ├── _dbutils.py ├── backupdb.py ├── copydb.py ├── dropdb.py ├── gitutils.py ├── initdb.py ├── listdb.py ├── makepot.py ├── manifest.py ├── restoredb.py ├── uninstall.py └── update.py ├── newsfragments └── .gitignore ├── pyproject.toml ├── setup.py ├── tests ├── conftest.py ├── data │ ├── test_addon_hash │ │ ├── README.rst │ │ ├── data │ │ │ ├── f1.xml │ │ │ └── f2.xml │ │ ├── i18n │ │ │ ├── en.po │ │ │ ├── en_US.po │ │ │ ├── fr.po │ │ │ ├── fr_BE.po │ │ │ └── test.pot │ │ ├── i18n_extra │ │ │ ├── en.po │ │ │ ├── fr.po │ │ │ └── nl_NL.po │ │ ├── models │ │ │ ├── stuff.py │ │ │ ├── stuff.pyc │ │ │ └── stuff.pyo │ │ └── static │ │ │ └── src │ │ │ └── some.js │ ├── test_initdb │ │ └── addon1 │ │ │ └── __openerp__.py │ ├── test_makepot │ │ ├── addon_test_makepot │ │ │ ├── __init__.py │ │ │ ├── __openerp__.py │ │ │ ├── i18n │ │ │ │ ├── fr.po.bad │ │ │ │ ├── fr.po.fuzzy │ │ │ │ └── fr.po.old │ │ │ └── models │ │ │ │ ├── __init__.py │ │ │ │ └── testmodel.py │ │ └── addon_test_makepot_2 │ │ │ ├── __init__.py │ │ │ ├── __openerp__.py │ │ │ └── models │ │ │ ├── __init__.py │ │ │ └── testmodel.py │ ├── test_manifest │ │ ├── addon1 │ │ │ └── __openerp__.py │ │ ├── addon_uninstallable │ │ │ └── __manifest__.py │ │ └── setup │ │ │ └── README.txt │ └── test_update │ │ ├── v1 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ ├── v2 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ ├── v3.1 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ ├── addon_d1 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ ├── addon_d2 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ ├── v3 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ ├── addon_d1 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ ├── addon_d2 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ ├── v4 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ ├── addon_d1 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ ├── v5 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ ├── __openerp__.py │ │ │ └── migrations │ │ │ │ └── 5.0 │ │ │ │ └── pre-migrate.py │ │ ├── addon_d1 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ ├── v6 │ │ ├── addon_app │ │ │ ├── __init__.py │ │ │ ├── __openerp__.py │ │ │ ├── migrations │ │ │ │ └── 5.0 │ │ │ │ │ └── pre-migrate.py │ │ │ └── res_users.py │ │ ├── addon_d1 │ │ │ ├── __init__.py │ │ │ └── __openerp__.py │ │ └── expected.json │ │ └── v7 │ │ ├── addon_app │ │ ├── __init__.py │ │ ├── __openerp__.py │ │ ├── migrations │ │ │ └── 5.0 │ │ │ │ └── pre-migrate.py │ │ └── res_users.py │ │ └── addon_d1 │ │ ├── __init__.py │ │ └── __openerp__.py ├── scripts │ └── install_odoo.py ├── test_addon_hash.py ├── test_backupdb.py ├── test_copydb.py ├── test_dropdb.py ├── test_gitutils.py ├── test_initdb.py ├── test_listdb.py ├── test_makepot.py ├── test_manifest.py ├── test_restoredb.py ├── test_uninstall.py └── test_update.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "master" 8 | tags: 9 | - "[0-9]+.[0-9]+.[0-9]+" 10 | 11 | jobs: 12 | tests: 13 | runs-on: ${{matrix.machine}} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - python-version: "3.6" 19 | toxenv: "py36-11.0" 20 | machine: ubuntu-20.04 21 | - python-version: "3.6" 22 | toxenv: "py36-12.0" 23 | machine: ubuntu-20.04 24 | - python-version: "3.6" 25 | toxenv: "py36-13.0" 26 | machine: ubuntu-20.04 27 | - python-version: "3.6" 28 | toxenv: "py36-14.0" 29 | machine: ubuntu-20.04 30 | - python-version: "3.8" 31 | toxenv: "py38-15.0" 32 | machine: ubuntu-22.04 33 | - python-version: "3.10" 34 | toxenv: "py310-16.0" 35 | machine: ubuntu-22.04 36 | - python-version: "3.10" 37 | toxenv: "py310-17.0" 38 | machine: ubuntu-22.04 39 | - python-version: "3.12" 40 | toxenv: "py312-17.0" 41 | machine: ubuntu-22.04 42 | - python-version: "3.12" 43 | toxenv: "py312-18.0" 44 | machine: ubuntu-24.04 45 | - python-version: "3.10" 46 | toxenv: "twine_check" 47 | machine: ubuntu-22.04 48 | services: 49 | postgres: 50 | image: postgres:12 51 | env: 52 | POSTGRES_USER: odoo 53 | POSTGRES_PASSWORD: odoo 54 | ports: 55 | - 5432:5432 56 | # needed because the postgres container does not provide a healthcheck 57 | options: 58 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 59 | --health-retries 5 60 | env: 61 | PGHOST: localhost 62 | PGPORT: 5432 63 | PGUSER: odoo 64 | PGPASSWORD: odoo 65 | steps: 66 | - uses: actions/checkout@v3 67 | - uses: actions/setup-python@v4 68 | with: 69 | python-version: "${{ matrix.python-version }}" 70 | - uses: actions/cache@v3 71 | with: 72 | path: ~/.cache/pip 73 | key: ${{ runner.os }}-pip-${{ matrix.toxenv }} 74 | - name: Install system dependencies 75 | run: | 76 | sudo apt-get update -qq 77 | sudo apt-get install -qq --no-install-recommends \ 78 | libxml2-dev libxslt1-dev \ 79 | libldap2-dev libsasl2-dev \ 80 | gettext 81 | pip install tox virtualenv 82 | - name: Run tox 83 | run: tox -e ${{ matrix.toxenv }} 84 | - uses: codecov/codecov-action@v3 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: 4 | - published 5 | 6 | name: release 7 | 8 | jobs: 9 | pypi: 10 | name: upload release to PyPI 11 | runs-on: ubuntu-latest 12 | environment: release 13 | 14 | permissions: 15 | # Used to authenticate to PyPI via OIDC. 16 | id-token: write 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - uses: actions/setup-python@v4 22 | with: 23 | python-version: ">= 3.8" 24 | 25 | - name: build 26 | run: pipx run build 27 | 28 | - name: publish 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.swp 3 | /.pytest_cache 4 | /tmp 5 | /tests/data/test_makepot/addon_test_makepot/i18n/* 6 | /tests/data/test_makepot/addon_test_makepot_2/i18n/* 7 | /.vscode 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | env/ 19 | build/ 20 | develop-eggs/ 21 | dist/ 22 | downloads/ 23 | eggs/ 24 | .eggs/ 25 | lib/ 26 | lib64/ 27 | parts/ 28 | sdist/ 29 | var/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *,cover 53 | 54 | # Translations 55 | *.mo 56 | 57 | # Django stuff: 58 | *.log 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | # pycharm dev env 67 | .idea/ 68 | .ropeproject/ 69 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: "^tests/data/.*$" 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: debug-statements 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.7.0 12 | hooks: 13 | - id: ruff 14 | args: [--exit-non-zero-on-fix] 15 | - id: ruff-format 16 | -------------------------------------------------------------------------------- /.towncrier-template.rst: -------------------------------------------------------------------------------- 1 | {% for section, _ in sections.items() %} 2 | {% if section %}{{section}} 3 | {{ '~' * section|length }} 4 | 5 | {% endif %} 6 | 7 | {% if sections[section] %} 8 | {% for category, val in definitions.items() if category in sections[section]%} 9 | **{{ definitions[category]['name'] }}** 10 | 11 | {% if definitions[category]['showcontent'] %} 12 | {% for text, values in sections[section][category].items() %} 13 | - {{ text }} ({{ values|join(', ') }}) 14 | {% endfor %} 15 | 16 | {% else %} 17 | - {{ sections[section][category]['']|join(', ') }} 18 | 19 | {% endif %} 20 | {% if sections[section][category]|length == 0 %} 21 | No significant changes. 22 | 23 | {% else %} 24 | {% endif %} 25 | 26 | {% endfor %} 27 | {% else %} 28 | No significant changes. 29 | 30 | 31 | {% endif %} 32 | {% endfor %} 33 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ~~~~~~~ 3 | 4 | .. towncrier release notes start 5 | 6 | 1.20 (2024-10-23) 7 | ----------------- 8 | 9 | **Features** 10 | 11 | - Odoo 18 compatibility (`#150 12 | `_, `#152 13 | `_, `#154 14 | `_) 15 | 16 | 1.19 (2024-07-22) 17 | ----------------- 18 | 19 | **Features** 20 | 21 | - click-odoo-restoredb: Add ``--neutralize`` option. This works only in odoo 16.0 and above. (`#143 `_) 22 | 23 | 24 | 1.18.1 (2023-11-16) 25 | ------------------- 26 | 27 | **Features** 28 | 29 | - click-odoo-update : Do not run/update Odoo when no module needs updating. (`#144 `_) 30 | 31 | 32 | 1.18.0 (2023-10-29) 33 | ------------------- 34 | 35 | **Features** 36 | 37 | - Support Odoo 17. (`#190 `_) 38 | 39 | 40 | 1.17.0 (2023-09-03) 41 | ------------------- 42 | 43 | **Features** 44 | 45 | - New ``click-odoo-listdb`` command. (`#126 `_) 46 | - ``click-odoo-update``: exclude the ``tests/`` directory from checksum computation 47 | A modification in tests alone should not require a database upgrade. (`#125 `_) 48 | - ``click-odoo-update``: set ``create_date`` and ``write_date`` on the ``ir_config_parameter`` checksums record (`#128 `_) 49 | 50 | 51 | 1.16.0 (2022-09-21) 52 | ------------------- 53 | 54 | **Features** 55 | 56 | - Add dependency on manifestoo_core to obtain Odoo core addons list (used by 57 | click-odoo-update to ignore core addons). (`#114 `_) 58 | - Adapt click-odoo-update for Odoo 16. (`#119 `_) 59 | 60 | **Deprecations and Removals** 61 | 62 | - Remove support for Python < 3.6 and Odoo < 11. (`#110 `_) 63 | 64 | 65 | 1.15.1 (2021-12-04) 66 | ------------------- 67 | 68 | **Bugfixes** 69 | 70 | - Silence Odoo 15 noisy warnings about using autocommit. (`#105 `_) 71 | 72 | 73 | 1.15.0 (2021-10-06) 74 | ------------------- 75 | 76 | **Features** 77 | 78 | - Update core addons lists, with Odoo 15 support. (`#104 `_) 79 | 80 | 81 | 1.14.0 (2021-06-28) 82 | ------------------- 83 | 84 | **Features** 85 | 86 | - Adding a new option to enable using rsync and hardlinks for copying filestore: 87 | `--filestore-copy-mode [default|rsync|hardlink]`. (`#86 `_) 88 | 89 | 90 | 1.13.0 (2021-06-25) 91 | ------------------- 92 | 93 | **Features** 94 | 95 | - Backup and restore commands: add support for "dump" format (`#79 `_) 96 | - ``click-odoo-makepot``: add --modules option to select modules to export. (`#92 `_) 97 | - ``click-odoo-update``: also pass all modified modules in ``to upgrade`` state to 98 | Odoo for update; this helps upgrading when there are new dependencies, in 99 | combination with Odoo `#72661 `__. (`#97 `_) 100 | 101 | 102 | **Bugfixes** 103 | 104 | - ``click-odoo-update``: do not attempt to update addons that are uninstallable. (`#89 `_) 105 | 106 | 107 | 1.12.0 (2020-11-25) 108 | ------------------- 109 | 110 | **Features** 111 | 112 | - ``click-odoo-makepot`` gained new options controlling how it merges 113 | new strings in existing ``.po`` files: ``--no-fuzzy-matching`` and 114 | ``--purge-old-translation``. (`#87 `_) 115 | 116 | 117 | 1.11.0 (2020-10-01) 118 | ------------------- 119 | 120 | **Features** 121 | 122 | - In ``click-odoo-copydb``, reset ``database.*`` system parameters, to prevent 123 | conflicts between databases (database.uuid, database.secret, 124 | database.enterprise_code, ...) (`#25 `_) 125 | - Add ``click-odoo-restoredb`` command. (`#32 `_) 126 | - Update core addons lists (for click-odoo-update --ignore-core-addons), 127 | including Odoo 14 support. (`#81 `_) 128 | 129 | 130 | 1.10.1 (2020-04-29) 131 | ------------------- 132 | 133 | **Bugfixes** 134 | 135 | - click-odoo-update: fix packaging issue (missing core addons lists). (`#77 `_) 136 | 137 | 138 | 1.10.0 (2020-04-28) 139 | ------------------- 140 | 141 | **Features** 142 | 143 | - click-odoo-initdb: add support of dot and underscore in database name. (`#35 `_) 144 | - click-odoo-update: added --list-only option. (`#68 `_) 145 | - click-odoo-update: add --ignore-addons and --ignore-core-addons options to 146 | exclude addons from checksum change detection. (`#69 `_) 147 | 148 | 149 | **Improved Documentation** 150 | 151 | - initdb, dropdb, update: move out of beta. (`#70 `_) 152 | 153 | 154 | **Deprecations and Removals** 155 | 156 | - Remove deprecated click-odoo-upgrade. (`#71 `_) 157 | 158 | 159 | 1.9.0 (2020-03-23) 160 | ------------------ 161 | - click-odoo-update: acquire an advisory lock on the database so multiple 162 | instances of click-odoo-update will not start at the same time on the 163 | same database (useful when there are several Odoo instances running 164 | on the same database and all running click-odoo-update at startup) 165 | 166 | 1.8.0 (2019-10-01) 167 | ------------------ 168 | - Support Odoo SaaS versions 169 | - click-odoo-update now has some support for updating while another Odoo 170 | instance is running against the same database, by using a watcher that 171 | aborts the update in case a DB lock happens (this is an advanced feature) 172 | 173 | 1.7.0 (2019-09-02) 174 | ------------------ 175 | - makepot: always check validity of .po files 176 | 177 | 1.6.0 (2019-03-28) 178 | ------------------ 179 | - update: support postgres 9.4 180 | - backupdb: work correctly when list_db is false too 181 | - backupdb: new --(no-)filestore option 182 | - dropdb: refactored to use Odoo api instead of custom code 183 | 184 | 1.5.0 (2019-02-05) 185 | ------------------ 186 | - add click-odoo-backupdb 187 | 188 | 1.4.1 (2018-11-21) 189 | ------------------ 190 | - fix broken click-odoo-update --i18n-overwrite 191 | 192 | 1.4.0 (2018-11-19) 193 | ------------------ 194 | 195 | - new click-odoo-update which implements the functionality of module_auto_update 196 | natively, alleviating the need to have module_auto_update installed in the database, 197 | and is more robust (it does a regular -u after identifying modules to update) 198 | - upgrade: deprecated in favor of click-odoo-update 199 | - initdb: save installed checksums so click-odoo-update can readily use them 200 | - initdb: add --addons-path option 201 | - copydb: fix error when source filestore did not exist 202 | 203 | 1.3.1 (2018-11-05) 204 | ------------------ 205 | - Add --unless-exists option to click-odoo-initdb 206 | 207 | 1.3.0 (2018-10-31) 208 | ------------------ 209 | - Add click-odoo-copydb 210 | - Add click-odoo-dropdb 211 | - Add --if-exists option to click-odoo-upgrade 212 | 213 | 1.2.0 (2018-10-07) 214 | ------------------ 215 | - Odoo 12 support 216 | 217 | 1.1.4 (2018-06-21) 218 | ------------------ 219 | - makepot: fix issue when addons-dir is not current directory 220 | (this should also fix issues when there are symlinks) 221 | 222 | 1.1.3 (2018-06-20) 223 | ------------------ 224 | - makepot: add --commit-message option 225 | 226 | 1.1.2 (2018-06-20) 227 | ------------------ 228 | - makepot: force git add in case .pot are in .gitignore 229 | (made for https://github.com/OCA/maintainer-quality-tools/issues/558) 230 | 231 | 1.1.1 (2018-06-16) 232 | ------------------ 233 | - makepot: add --msgmerge-if-new-pot option 234 | 235 | 1.1.0 (2018-06-13, Sevilla OCA code sprint) 236 | ------------------------------------------- 237 | - add click-odoo-makepot 238 | - in click-odoo-initdb, include active=True modules in hash computation 239 | (because modules with active=True are auto installed by Odoo) 240 | 241 | 1.0.4 (2018-06-02) 242 | ------------------ 243 | - update module list after creating a database from cache, useful when 244 | we are creating a database in an environment where modules have 245 | been added since the template was created 246 | 247 | 1.0.3 (2018-05-30) 248 | ------------------ 249 | - fix: handle situations where two initdb start at the same time 250 | ending up with an "already exists" error when creating the cached template 251 | 252 | 1.0.2 (2018-05-29) 253 | ------------------ 254 | - fix: initdb now stores attachments in database when cache is enabled, 255 | so databases created from cache do not miss the filestore 256 | 257 | 1.0.1 (2018-05-27) 258 | ------------------ 259 | - better documentation 260 | - fix: initdb now takes auto_install modules into account 261 | 262 | 1.0.0 (2018-05-27) 263 | ------------------ 264 | - add click-odoo-initdb 265 | 266 | 1.0.0b3 (2018-05-17) 267 | -------------------- 268 | - be more robust in rare case button_upgrade fails silently 269 | 270 | 1.0.0b2 (2018-03-28) 271 | -------------------- 272 | - uninstall: commit and hide --rollback 273 | - upgrade: refactor to add composable function 274 | 275 | 276 | 1.0.0b1 (2018-03-28) 277 | -------------------- 278 | - upgrade: save installed checksums after full upgrade 279 | 280 | 281 | 1.0.0a1 (2018-03-22) 282 | -------------------- 283 | - first alpha 284 | - click-odoo-uninstall 285 | - click-odoo-upgrade 286 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | click-odoo-contrib 2 | ================== 3 | 4 | .. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg 5 | :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html 6 | :alt: License: LGPL-3 7 | .. image:: https://badge.fury.io/py/click-odoo-contrib.svg 8 | :target: http://badge.fury.io/py/click-odoo-contrib 9 | 10 | ``click-odoo-contrib`` is a set of useful Odoo maintenance functions. 11 | They are available as CLI scripts (based on click-odoo_), as well 12 | as composable python functions. 13 | 14 | .. contents:: 15 | 16 | Scripts 17 | ~~~~~~~ 18 | 19 | click-odoo-copydb (beta) 20 | ------------------------ 21 | 22 | .. code:: 23 | 24 | Usage: click-odoo-copydb [OPTIONS] SOURCE DEST 25 | 26 | Create an Odoo database by copying an existing one. 27 | 28 | This script copies using postgres CREATEDB WITH TEMPLATE. It also copies 29 | the filestore. 30 | 31 | Options: 32 | -c, --config FILE ... 33 | ... 34 | -f, --force-disconnect Attempt to disconnect users from the template 35 | database. 36 | --unless-dest-exists Don't report error if destination database already 37 | exists. 38 | --if-source-exists Don't report error if source database does not 39 | exist. 40 | --filestore-copy-mode [default|rsync|hardlink] 41 | Mode for copying the filestore. Default uses 42 | python shutil copytree which copies 43 | everything. If the target filestore already 44 | exists and just needs an update you can use 45 | rsync to rsync the filestore instead. If both 46 | filestores are on the same filesystem supporting 47 | hardlinks you can use the option hardlink to hard 48 | link the files to the inodes of the files of the 49 | source directory which saves on space on the disk. 50 | --help Show this message and exit. 51 | 52 | click-odoo-dropdb (stable) 53 | -------------------------- 54 | 55 | .. code:: 56 | 57 | Usage: click-odoo-dropdb [OPTIONS] DBNAME 58 | 59 | Drop an Odoo database and associated file store. 60 | 61 | Options: 62 | -c, --config FILE ... 63 | ... 64 | --if-exists Don't report error if database doesn't exist. 65 | --help Show this message and exit. 66 | 67 | click-odoo-initdb (stable) 68 | -------------------------- 69 | 70 | .. code:: 71 | 72 | Usage: click-odoo-initdb [OPTIONS] 73 | 74 | Create an Odoo database with pre-installed modules. 75 | 76 | Almost like standard Odoo does with the -i option, except this script 77 | manages a cache of database templates with the exact same addons 78 | installed. This is particularly useful to save time when initializing test 79 | databases. 80 | 81 | Cached templates are identified by computing a sha1 checksum of modules 82 | provided with the -m option, including their dependencies and 83 | corresponding auto_install modules. 84 | 85 | Options: 86 | -c, --config FILE ... 87 | ... 88 | -n, --new-database TEXT Name of new database to create, possibly from 89 | cache. If absent, only the cache trimming 90 | operation is executed. 91 | -m, --modules TEXT Comma separated list of addons to install. 92 | [default: base] 93 | --demo / --no-demo Load Odoo demo data. [default: True] 94 | --cache / --no-cache Use a cache of database templates with the exact 95 | same addons installed. Disabling this option also 96 | disables all other cache-related operations such 97 | as max-age or size. Note: when the cache is 98 | enabled, all attachments created during database 99 | initialization are stored in database instead of 100 | the default Odoo file store. [default: True] 101 | --cache-prefix TEXT Prefix to use when naming cache template databases 102 | (max 8 characters). CAUTION: all databases named 103 | like {prefix}-____________-% will eventually be 104 | dropped by the cache control mechanism, so choose 105 | the prefix wisely. [default: cache] 106 | --cache-max-age INTEGER Drop cache templates that have not been used for 107 | more than N days. Use -1 to disable. [default: 108 | 30] 109 | --cache-max-size INTEGER Keep N most recently used cache templates. Use -1 110 | to disable. Use 0 to empty cache. [default: 5] 111 | --unless-exists Don't report error if database already exists. 112 | --help Show this message and exit. 113 | 114 | click-odoo-backupdb (beta) 115 | -------------------------- 116 | 117 | .. code:: 118 | 119 | Usage: click-odoo-backupdb [OPTIONS] DBNAME DEST 120 | 121 | Create an Odoo database backup. 122 | 123 | This script dumps the database using pg_dump. It also copies the filestore. 124 | 125 | Unlike Odoo, this script allows you to make a backup of a database without 126 | going through the web interface. This avoids timeout and file size 127 | limitation problems when databases are too large. 128 | 129 | It also allows you to make a backup directly to a directory. This type of 130 | backup has the advantage that it reduces memory consumption since the 131 | files in the filestore are directly copied to the target directory as well 132 | as the database dump. 133 | 134 | Options: 135 | -c, --config FILE ... 136 | ... 137 | --force Don't report error if destination file/folder 138 | already exists. [default: False] 139 | 140 | --if-exists Don't report error if database does not exist. 141 | --format [zip|dump|folder] Output format [default: zip] 142 | --filestore / --no-filestore Include filestore in backup [default: True] 143 | --help Show this message and exit. 144 | 145 | click-odoo-restoredb (beta) 146 | --------------------------- 147 | 148 | .. code:: 149 | 150 | Usage: click-odoo-restoredb [OPTIONS] DBNAME SOURCE 151 | 152 | Restore an Odoo database backup. 153 | 154 | This script allows you to restore databses created by using the Odoo web 155 | interface or the backupdb script. This avoids timeout and file size 156 | limitation problems when databases are too large. 157 | 158 | Options: 159 | -c, --config FILE ... 160 | ... 161 | --copy / --move This database is a copy. In order to avoid conflicts 162 | between databases, Odoo needs to know if thisdatabase was 163 | moved or copied. If you don't know, set is a copy. 164 | 165 | --force Don't report error if destination database already 166 | exists. If force and destination database exists, it will 167 | be dropped before restore. [default: False] 168 | 169 | --neutralize Neutralize the database after restore. This will disable 170 | scheduled actions, outgoing emails, and sets other 171 | external providers in test mode. This works only in odoo 172 | 16.0 and above. 173 | 174 | --jobs INTEGER Uses this many parallel jobs to restore. Only used to 175 | restore folder format backup. 176 | 177 | --help Show this message and exit. 178 | 179 | click-odoo-makepot (stable) 180 | --------------------------- 181 | 182 | .. code:: 183 | 184 | Usage: click-odoo-makepot [OPTIONS] 185 | 186 | Export translation (.pot) files of addons installed in the database and 187 | present in addons_dir. Additionally, run msgmerge on the existing .po 188 | files to keep them up to date. Commit changes to git, if any. 189 | 190 | Options: 191 | -c, --config FILE ... 192 | -d, --database TEXT ... 193 | ... 194 | --addons-dir TEXT [default: .] 195 | -m, --modules TEXT Comma separated list of addons to export 196 | translation. 197 | --msgmerge / --no-msgmerge Merge .pot changes into all .po files 198 | [default: False] 199 | --msgmerge-if-new-pot / --no-msg-merge-if-new-pot 200 | Merge .pot changes into all .po files, only 201 | if a new .pot file has been created. 202 | [default: False] 203 | --fuzzy-matching / --no-fuzzy-matching 204 | Use fuzzy matching when merging .pot changes 205 | into .po files. 206 | Only applies when --msgmerge 207 | or --msgmerge-if-new-pot are passed. 208 | [default: True] 209 | --purge-old-translations / --no-purge-old-translations 210 | Remove comment lines containing old 211 | translations from .po files. 212 | Only applies when --msgmerge 213 | or --msgmerge-if-new-pot are passed. 214 | [default: False] 215 | --commit / --no-commit Git commit exported .pot files if needed. 216 | [default: False] 217 | --help Show this message and exit. 218 | 219 | click-odoo-listdb (beta) 220 | ------------------------ 221 | 222 | .. code:: 223 | 224 | Usage: click-odoo-listdb [OPTIONS] 225 | 226 | List Odoo databases. 227 | 228 | Options: 229 | -c, --config FILE Specify the Odoo configuration file. Other ways to 230 | provide it are with the ODOO_RC or OPENERP_SERVER 231 | environment variables, or ~/.odoorc (Odoo >= 10) or 232 | ~/.openerp_serverrc. 233 | --log-level TEXT Specify the logging level. Accepted values depend on the 234 | Odoo version, and include debug, info, warn, error. 235 | [default: warn] 236 | --logfile FILE Specify the log file. 237 | --help Show this message and exit. 238 | 239 | click-odoo-uninstall (stable) 240 | ----------------------------- 241 | 242 | .. code:: 243 | 244 | Usage: click-odoo-uninstall [OPTIONS] 245 | 246 | Options: 247 | -c, --config PATH ... 248 | -d, --database TEXT ... 249 | ... 250 | -m, --modules TEXT Comma-separated list of modules to uninstall 251 | [required] 252 | --help Show this message and exit. 253 | 254 | click-odoo-update (stable) 255 | -------------------------- 256 | 257 | .. code:: 258 | 259 | Usage: click-odoo-update [OPTIONS] 260 | 261 | Update an Odoo database (odoo -u), automatically detecting addons to 262 | update based on a hash of their file content, compared to the hashes 263 | stored in the database. 264 | 265 | It allows updating in parallel while another Odoo instance is still 266 | running against the same database, by using a watcher that aborts the 267 | update in case a DB lock happens. 268 | 269 | Options: 270 | -c, --config FILE Specify the Odoo configuration file. Other ways 271 | to provide it are with the ODOO_RC or 272 | OPENERP_SERVER environment variables, or 273 | ~/.odoorc (Odoo >= 10) or ~/.openerp_serverrc. 274 | --addons-path TEXT Specify the addons path. If present, this 275 | parameter takes precedence over the addons path 276 | provided in the Odoo configuration file. 277 | -d, --database TEXT Specify the database name. If present, this 278 | parameter takes precedence over the database 279 | provided in the Odoo configuration file. 280 | --log-level TEXT Specify the logging level. Accepted values 281 | depend on the Odoo version, and include debug, 282 | info, warn, error. [default: info] 283 | --logfile FILE Specify the log file. 284 | --i18n-overwrite Overwrite existing translations 285 | --update-all Force a complete upgrade (-u base) 286 | --ignore-addons TEXT A comma-separated list of addons to ignore. 287 | These will not be updated if their checksum has 288 | changed. Use with care. 289 | --ignore-core-addons If this option is set, Odoo CE and EE addons 290 | are not updated. This is normally safe, due the 291 | Odoo stable policy. 292 | --if-exists Don't report error if database doesn't exist 293 | --watcher-max-seconds FLOAT Max DB lock seconds allowed before aborting the 294 | update process. Default: 0 (disabled). 295 | --list-only Log the list of addons to update without 296 | actually updating them. 297 | --help Show this message and exit. 298 | 299 | Useful links 300 | ~~~~~~~~~~~~ 301 | 302 | - pypi page: https://pypi.org/project/click-odoo-contrib 303 | - code repository: https://github.com/acsone/click-odoo-contrib 304 | - report issues at: https://github.com/acsone/click-odoo-contrib/issues 305 | 306 | .. _click-odoo: https://pypi.python.org/pypi/click-odoo 307 | 308 | Development 309 | ~~~~~~~~~~~ 310 | 311 | To run tests, type ``tox``. Tests are made using pytest. To run tests matching 312 | a specific keyword for, say, Odoo 12 and python 3.6, use 313 | ``tox -e py36-12.0 -- -k keyword``. For running tests you need a postgres server accessible for your user without a password at ``/var/run/postgresql/.s.PGSQL.5432``. 314 | 315 | To make sure local coding convention are respected before 316 | you commit, install 317 | `pre-commit `_ and 318 | run ``pre-commit install`` after cloning the repository. 319 | 320 | To release, create a tagged release on GitHub. This will trigger publishing to PyPI. 321 | 322 | Credits 323 | ~~~~~~~ 324 | 325 | Contributors: 326 | 327 | - Stéphane Bidoul (ACSONE_) 328 | - Thomas Binsfeld (ACSONE_) 329 | - Benjamin Willig (ACSONE_) 330 | - Jairo Llopis 331 | - Laurent Mignon (ACSONE_) 332 | - Lois Rilo (ForgeFlow_) 333 | - Dmitry Voronin 334 | 335 | .. _ACSONE: https://acsone.eu 336 | .. _Tecnativa: https://tecnativa.com 337 | .. _ForgeFlow: https://forgeflow.com 338 | 339 | Maintainer 340 | ~~~~~~~~~~ 341 | 342 | .. image:: https://www.acsone.eu/logo.png 343 | :alt: ACSONE SA/NV 344 | :target: https://www.acsone.eu 345 | 346 | This project is maintained by ACSONE SA/NV. 347 | -------------------------------------------------------------------------------- /click_odoo_contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/click_odoo_contrib/__init__.py -------------------------------------------------------------------------------- /click_odoo_contrib/_addon_hash.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV. 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 3 | 4 | import hashlib 5 | import os 6 | from fnmatch import fnmatch 7 | 8 | 9 | def _fnmatch(filename, patterns): 10 | for pattern in patterns: 11 | if fnmatch(filename, pattern): 12 | return True 13 | return False 14 | 15 | 16 | def _walk(top, exclude_patterns, keep_langs): 17 | keep_langs = {lang.split("_")[0] for lang in keep_langs} 18 | for dirpath, dirnames, filenames in os.walk(top): 19 | dirnames.sort() 20 | reldir = os.path.relpath(dirpath, top) 21 | if reldir == ".": 22 | reldir = "" 23 | for filename in sorted(filenames): 24 | filepath = os.path.join(reldir, filename) 25 | if _fnmatch(filepath, exclude_patterns): 26 | continue 27 | if keep_langs and reldir in {"i18n", "i18n_extra"}: 28 | basename, ext = os.path.splitext(filename) 29 | if ext == ".po": 30 | if basename.split("_")[0] not in keep_langs: 31 | continue 32 | yield filepath 33 | 34 | 35 | def addon_hash(top, exclude_patterns, keep_langs): 36 | """Compute a sha1 digest of file contents.""" 37 | m = hashlib.sha1() 38 | for filepath in _walk(top, exclude_patterns, keep_langs): 39 | # hash filename so empty files influence the hash 40 | m.update(filepath.encode("utf-8")) 41 | # hash file content 42 | with open(os.path.join(top, filepath), "rb") as f: 43 | m.update(f.read()) 44 | return m.hexdigest() 45 | -------------------------------------------------------------------------------- /click_odoo_contrib/_backup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV. 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 3 | import os 4 | import shutil 5 | import tempfile 6 | import zipfile 7 | from contextlib import contextmanager 8 | 9 | 10 | class AbstractBackup: 11 | """Abstract class with methods to open, read, write, close, 12 | a backup 13 | """ 14 | 15 | def __init__(self, path, mode): 16 | """ 17 | :param path: Either the path to the file, or a file-like object, or a folder 18 | or ... . 19 | :param mode: The mode can be either read "r", write "w" or append "a" 20 | """ 21 | self._path = path 22 | self._mode = mode 23 | 24 | def addtree(self, src, arcname): 25 | """Recursively add a directory tree into the backup. 26 | :param dirname: Directory to copy from 27 | :param arcname: the root path of copied files into the archive 28 | """ 29 | raise NotImplementedError() # pragma: no cover 30 | 31 | def addfile(self, filename, arcname): 32 | """Add a file to the backup. 33 | 34 | :param filename: the path to the souce file 35 | :param arcname: the path into the backup 36 | """ 37 | raise NotImplementedError() # pragma: no cover 38 | 39 | def write(self, stream, arcname): 40 | """Write a stream into the backup. 41 | 42 | :param arcname: the path into the backup 43 | """ 44 | raise NotImplementedError() # pragma: no cover 45 | 46 | def close(self): 47 | """Close the backup""" 48 | raise NotImplementedError() # pragma: no cover 49 | 50 | def delete(self): 51 | """Delete the backup""" 52 | raise NotImplementedError() # pragma: no cover 53 | 54 | 55 | class ZipBackup(AbstractBackup): 56 | format = "zip" 57 | 58 | def __init__(self, path, mode): 59 | super().__init__(path, mode) 60 | self._zipFile = zipfile.ZipFile( 61 | self._path, self._mode, compression=zipfile.ZIP_DEFLATED, allowZip64=True 62 | ) 63 | 64 | def addtree(self, src, arcname): 65 | len_prefix = len(src) + 1 66 | for dirpath, _dirnames, filenames in os.walk(src): 67 | for fname in filenames: 68 | path = os.path.normpath(os.path.join(dirpath, fname)) 69 | if os.path.isfile(path): 70 | _arcname = os.path.join(arcname, path[len_prefix:]) 71 | self._zipFile.write(path, _arcname) 72 | 73 | def addfile(self, filename, arcname): 74 | self._zipFile.write(filename, arcname) 75 | 76 | def write(self, stream, arcname): 77 | with tempfile.NamedTemporaryFile() as f: 78 | shutil.copyfileobj(stream, f) 79 | f.seek(0) 80 | self._zipFile.write(f.name, arcname) 81 | 82 | def close(self): 83 | self._zipFile.close() 84 | 85 | def delete(self): 86 | try: 87 | self.close() 88 | finally: 89 | os.unlink(self._path) 90 | 91 | 92 | class DumpBackup(AbstractBackup): 93 | format = "dump" 94 | 95 | def write(self, stream, arcname): 96 | with open(os.path.join(self._path), "wb") as f: 97 | shutil.copyfileobj(stream, f) 98 | 99 | def close(self): 100 | pass 101 | 102 | def delete(self): 103 | os.remove(self._path) 104 | 105 | 106 | class FolderBackup(AbstractBackup): 107 | format = "folder" 108 | 109 | def __init__(self, path, mode): 110 | super().__init__(path, mode) 111 | os.mkdir(self._path) 112 | 113 | def addtree(self, src, arcname): 114 | dest = os.path.join(self._path, arcname) 115 | shutil.copytree(src, dest) 116 | 117 | def addfile(self, filename, arcname): 118 | shutil.copyfile(filename, os.path.join(self._path, arcname)) 119 | 120 | def write(self, stream, arcname): 121 | with open(os.path.join(self._path, arcname), "wb") as f: 122 | shutil.copyfileobj(stream, f) 123 | 124 | def close(self): 125 | pass 126 | 127 | def delete(self): 128 | shutil.rmtree(self._path) 129 | 130 | 131 | BACKUP_FORMAT = { 132 | ZipBackup.format: ZipBackup, 133 | DumpBackup.format: DumpBackup, 134 | FolderBackup.format: FolderBackup, 135 | } 136 | 137 | 138 | @contextmanager 139 | def backup(format, path, mode): 140 | backup_class = BACKUP_FORMAT.get(format) 141 | if not backup_class: # pragma: no cover 142 | raise Exception( 143 | "Format {} not supported. Available formats: {}".format( 144 | format, "|".join(BACKUP_FORMAT.keys()) 145 | ) 146 | ) 147 | _backup = backup_class(path, mode) 148 | try: 149 | yield _backup 150 | _backup.close() 151 | except Exception as e: 152 | _backup.delete() 153 | raise e 154 | -------------------------------------------------------------------------------- /click_odoo_contrib/_dbutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import hashlib 5 | import os 6 | import warnings 7 | from contextlib import contextmanager 8 | 9 | from click_odoo import OdooEnvironment, odoo 10 | 11 | 12 | @contextmanager 13 | def pg_connect(): 14 | conn = odoo.sql_db.db_connect("postgres") 15 | cr = conn.cursor() 16 | if odoo.release.version_info > (16, 0): 17 | cr._cnx.autocommit = True 18 | else: 19 | # We are not going to use the ORM with this connection 20 | # so silence the Odoo warning about autocommit. 21 | with warnings.catch_warnings(): 22 | warnings.filterwarnings("ignore") 23 | cr.autocommit(True) 24 | try: 25 | yield cr._obj 26 | finally: 27 | cr.close() 28 | 29 | 30 | def db_exists(dbname): 31 | with pg_connect() as cr: 32 | cr.execute( 33 | "SELECT datname FROM pg_catalog.pg_database " 34 | "WHERE lower(datname) = lower(%s)", 35 | (dbname,), 36 | ) 37 | return bool(cr.fetchone()) 38 | 39 | 40 | def terminate_connections(dbname): 41 | with pg_connect() as cr: 42 | cr.execute( 43 | "SELECT pg_terminate_backend(pg_stat_activity.pid) " 44 | "FROM pg_stat_activity " 45 | "WHERE pg_stat_activity.datname = %s " 46 | "AND pid <> pg_backend_pid();", 47 | (dbname,), 48 | ) 49 | 50 | 51 | @contextmanager 52 | def db_management_enabled(): 53 | old_params = {"list_db": odoo.tools.config["list_db"]} 54 | odoo.tools.config["list_db"] = True 55 | # Work around odoo.service.db.list_dbs() not finding the database 56 | # when postgres connection info is passed as PG* environment 57 | # variables. 58 | if odoo.release.version_info < (12, 0): 59 | for v in ("host", "port", "user", "password"): 60 | odoov = "db_" + v.lower() 61 | pgv = "PG" + v.upper() 62 | if not odoo.tools.config[odoov] and pgv in os.environ: 63 | old_params[odoov] = odoo.tools.config[odoov] 64 | odoo.tools.config[odoov] = os.environ[pgv] 65 | try: 66 | yield 67 | finally: 68 | for key, value in old_params.items(): 69 | odoo.tools.config[key] = value 70 | 71 | 72 | @contextmanager 73 | def advisory_lock(cr, name): 74 | # try to make a unique lock id based on a string 75 | h = hashlib.sha1() 76 | h.update(name.encode("utf8")) 77 | lock_id = int(h.hexdigest()[:14], 16) 78 | cr.execute("SELECT pg_advisory_lock(%s::bigint)", (lock_id,)) 79 | try: 80 | yield 81 | finally: 82 | cr.execute("SELECT pg_advisory_unlock(%s::bigint)", (lock_id,)) 83 | 84 | 85 | def reset_config_parameters(dbname): 86 | """ 87 | Reset config parameters to default value. This is useful to avoid 88 | conflicts between databases on copy or restore 89 | (dbuuid, ...) 90 | """ 91 | with OdooEnvironment(dbname) as env: 92 | env["ir.config_parameter"].init(force=True) 93 | 94 | # reset enterprise keys if exists 95 | env.cr.execute( 96 | """ 97 | DELETE FROM ir_config_parameter 98 | WHERE key = 'database.enterprise_code'; 99 | 100 | UPDATE ir_config_parameter 101 | SET value = 'copy' 102 | WHERE key = 'database.expiration_reason' 103 | AND value != 'demo'; 104 | 105 | UPDATE ir_config_parameter 106 | SET value = CURRENT_DATE + INTERVAL '2 month' 107 | WHERE key = 'database.expiration_date'; 108 | 109 | """ 110 | ) 111 | -------------------------------------------------------------------------------- /click_odoo_contrib/backupdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2018 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | 5 | import json 6 | import os 7 | import shutil 8 | import subprocess 9 | import tempfile 10 | 11 | import click 12 | import click_odoo 13 | from click_odoo import odoo 14 | 15 | from ._backup import backup 16 | from ._dbutils import db_exists, db_management_enabled 17 | 18 | MANIFEST_FILENAME = "manifest.json" 19 | DBDUMP_FILENAME = "db.dump" 20 | FILESTORE_DIRNAME = "filestore" 21 | 22 | 23 | def _dump_db(dbname, backup): 24 | cmd = ["pg_dump", "--no-owner", dbname] 25 | env = odoo.tools.misc.exec_pg_environ() 26 | filename = "dump.sql" 27 | if backup.format in {"dump", "folder"}: 28 | cmd.insert(-1, "--format=c") 29 | filename = DBDUMP_FILENAME 30 | stdout = subprocess.Popen( 31 | cmd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE 32 | ).stdout 33 | backup.write(stdout, filename) 34 | 35 | 36 | def _create_manifest(cr, dbname, backup): 37 | manifest = odoo.service.db.dump_db_manifest(cr) 38 | with tempfile.NamedTemporaryFile(mode="w") as f: 39 | json.dump(manifest, f, indent=4) 40 | f.seek(0) 41 | backup.addfile(f.name, MANIFEST_FILENAME) 42 | 43 | 44 | def _backup_filestore(dbname, backup): 45 | filestore_source = odoo.tools.config.filestore(dbname) 46 | if os.path.isdir(filestore_source): 47 | backup.addtree(filestore_source, FILESTORE_DIRNAME) 48 | 49 | 50 | @click.command() 51 | @click_odoo.env_options( 52 | default_log_level="warn", with_database=False, with_rollback=False 53 | ) 54 | @click.option( 55 | "--force", 56 | is_flag=True, 57 | show_default=True, 58 | help="Don't report error if destination file/folder already exists.", 59 | ) 60 | @click.option( 61 | "--if-exists", is_flag=True, help="Don't report error if database does not exist." 62 | ) 63 | @click.option( 64 | "--format", 65 | type=click.Choice(["zip", "dump", "folder"]), 66 | default="zip", 67 | show_default=True, 68 | help="Output format", 69 | ) 70 | @click.option( 71 | "--filestore/--no-filestore", 72 | default=True, 73 | show_default=True, 74 | help="Include filestore in backup", 75 | ) 76 | @click.argument("dbname", nargs=1) 77 | @click.argument("dest", nargs=1, required=1) 78 | def main(env, dbname, dest, force, if_exists, format, filestore): 79 | """Create an Odoo database backup from an existing one. 80 | 81 | This script dumps the database using pg_dump. 82 | It also copies the filestore. 83 | 84 | Unlike Odoo, this script allows you to make a backup of a 85 | database without going through the web interface. This 86 | avoids timeout and file size limitation problems when 87 | databases are too large. 88 | 89 | It also allows you to make a backup directly to a directory. 90 | This type of backup has the advantage that it reduces 91 | memory consumption since the files in the filestore are 92 | directly copied to the target directory as well as the 93 | database dump. 94 | 95 | """ 96 | if not db_exists(dbname): 97 | msg = "Database does not exist: {}".format(dbname) 98 | if if_exists: 99 | click.echo(click.style(msg, fg="yellow")) 100 | return 101 | else: 102 | raise click.ClickException(msg) 103 | if os.path.exists(dest): 104 | msg = "Destination already exist: {}".format(dest) 105 | if not force: 106 | raise click.ClickException(msg) 107 | else: 108 | msg = "\n".join([msg, "Remove {}".format(dest)]) 109 | click.echo(click.style(msg, fg="yellow")) 110 | if os.path.isfile(dest): 111 | os.unlink(dest) 112 | else: 113 | shutil.rmtree(dest) 114 | if format == "dump": 115 | filestore = False 116 | db = odoo.sql_db.db_connect(dbname) 117 | try: 118 | with backup( 119 | format, dest, "w" 120 | ) as _backup, db.cursor() as cr, db_management_enabled(): 121 | if format != "dump": 122 | _create_manifest(cr, dbname, _backup) 123 | if filestore: 124 | _backup_filestore(dbname, _backup) 125 | _dump_db(dbname, _backup) 126 | finally: 127 | odoo.sql_db.close_db(dbname) 128 | 129 | 130 | if __name__ == "__main__": # pragma: no cover 131 | main() 132 | -------------------------------------------------------------------------------- /click_odoo_contrib/copydb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2018 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | 5 | import os 6 | import shutil 7 | import subprocess 8 | 9 | import click 10 | import click_odoo 11 | from click_odoo import odoo 12 | from psycopg2.extensions import AsIs, quote_ident 13 | 14 | from ._dbutils import ( 15 | db_exists, 16 | pg_connect, 17 | reset_config_parameters, 18 | terminate_connections, 19 | ) 20 | 21 | 22 | def _copy_db(cr, source, dest): 23 | cr.execute( 24 | "CREATE DATABASE %s WITH TEMPLATE %s", 25 | (AsIs(quote_ident(dest, cr)), AsIs(quote_ident(source, cr))), 26 | ) 27 | 28 | 29 | def _copy_filestore(source, dest, copy_mode="default"): 30 | filestore_source = odoo.tools.config.filestore(source) 31 | if os.path.isdir(filestore_source): 32 | filestore_dest = odoo.tools.config.filestore(dest) 33 | if copy_mode == "hardlink" or copy_mode == "rsync": 34 | try: 35 | if copy_mode == "hardlink": 36 | hardlink_option = ["--link-dest=" + filestore_source] 37 | else: 38 | hardlink_option = [] 39 | cmd = ( 40 | [ 41 | "rsync", 42 | "-a", 43 | "--delete-delay", 44 | ] 45 | + hardlink_option 46 | + [ 47 | filestore_source + "/", 48 | filestore_dest, 49 | ] 50 | ) 51 | subprocess.check_call(cmd) 52 | # we use one generic exception clause here because subprocess.check_call 53 | # may not only raise the documented subprocess.CalledProcessError 54 | # (when the command exits with a return code != 0) but also with at least a 55 | # few other Exceptions like PermissionError when the given command is not 56 | # executable (by the current user) or a FileNotFoundError if the given 57 | # command is not in the users PATH or cannot be found on the system 58 | except Exception as e: 59 | msg = "Error syncing filestore to: {}, {}".format(dest, e) 60 | raise click.ClickException(msg) 61 | else: 62 | shutil.copytree(filestore_source, filestore_dest) 63 | 64 | 65 | @click.command() 66 | @click_odoo.env_options( 67 | default_log_level="warn", with_database=False, with_rollback=False 68 | ) 69 | @click.option( 70 | "--force-disconnect", 71 | "-f", 72 | is_flag=True, 73 | help="Attempt to disconnect users from the template database.", 74 | ) 75 | @click.option( 76 | "--unless-dest-exists", 77 | is_flag=True, 78 | help="Don't report error if destination database already exists.", 79 | ) 80 | @click.option( 81 | "--if-source-exists", 82 | is_flag=True, 83 | help="Don't report error if source database does not exist.", 84 | ) 85 | @click.option( 86 | "--filestore-copy-mode", 87 | type=click.Choice(["default", "rsync", "hardlink"]), 88 | default="default", 89 | help="Mode for copying the filestore. Default uses python shutil copytree " 90 | "which copies everything. If the target filestore already exists and " 91 | "just needs an update you can use rsync to rsync the filestore " 92 | "instead. If both the target filestore already exists and is on the same " 93 | "disk you might use hardlink which hardlinks all files to the inode in the " 94 | "source filestore and saves you space.", 95 | ) 96 | @click.argument("source", required=True) 97 | @click.argument("dest", required=True) 98 | def main( 99 | env, 100 | source, 101 | dest, 102 | force_disconnect, 103 | unless_dest_exists, 104 | if_source_exists, 105 | filestore_copy_mode, 106 | ): 107 | """Create an Odoo database by copying an existing one. 108 | 109 | This script copies using postgres CREATEDB WITH TEMPLATE. 110 | It also copies the filestore. 111 | """ 112 | with pg_connect() as cr: 113 | if db_exists(dest): 114 | msg = "Destination database already exists: {}".format(dest) 115 | if unless_dest_exists: 116 | click.echo(click.style(msg, fg="yellow")) 117 | return 118 | else: 119 | raise click.ClickException(msg) 120 | if not db_exists(source): 121 | msg = "Source database does not exist: {}".format(source) 122 | if if_source_exists: 123 | click.echo(click.style(msg, fg="yellow")) 124 | return 125 | else: 126 | raise click.ClickException(msg) 127 | if force_disconnect: 128 | terminate_connections(source) 129 | _copy_db(cr, source, dest) 130 | reset_config_parameters(dest) 131 | _copy_filestore(source, dest, copy_mode=filestore_copy_mode) 132 | 133 | 134 | if __name__ == "__main__": # pragma: no cover 135 | main() 136 | -------------------------------------------------------------------------------- /click_odoo_contrib/dropdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2018 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | 5 | import click 6 | import click_odoo 7 | from click_odoo import odoo 8 | 9 | from ._dbutils import db_exists, db_management_enabled 10 | 11 | 12 | @click.command() 13 | @click_odoo.env_options( 14 | default_log_level="warn", with_database=False, with_rollback=False 15 | ) 16 | @click.option( 17 | "--if-exists", is_flag=True, help="Don't report error if database doesn't exist." 18 | ) 19 | @click.argument("dbname", nargs=1) 20 | def main(env, dbname, if_exists=False): 21 | """Drop an Odoo database and associated file store.""" 22 | if not db_exists(dbname): 23 | msg = "Database does not exist: {}".format(dbname) 24 | if if_exists: 25 | click.echo(click.style(msg, fg="yellow")) 26 | return 27 | else: 28 | raise click.ClickException(msg) 29 | with db_management_enabled(): 30 | odoo.service.db.exp_drop(dbname) 31 | 32 | 33 | if __name__ == "__main__": # pragma: no cover 34 | main() 35 | -------------------------------------------------------------------------------- /click_odoo_contrib/gitutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import subprocess 6 | 7 | 8 | def commit_if_needed(paths, message, cwd="."): 9 | paths = [os.path.realpath(p) for p in paths] 10 | cmd = ["git", "add", "-f", "--"] + paths 11 | subprocess.check_call(cmd, cwd=cwd) 12 | cmd = ["git", "diff", "--quiet", "--exit-code", "--cached", "--"] + paths 13 | r = subprocess.call(cmd, cwd=cwd) 14 | if r != 0: 15 | cmd = ["git", "commit", "-m", message, "--"] + paths 16 | subprocess.check_call(cmd, cwd=cwd) 17 | return True 18 | else: 19 | return False 20 | -------------------------------------------------------------------------------- /click_odoo_contrib/initdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2018 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | import contextlib 5 | import hashlib 6 | import logging 7 | import os 8 | import re 9 | from datetime import datetime, timedelta 10 | from fnmatch import fnmatch 11 | 12 | import click 13 | import click_odoo 14 | from click_odoo import odoo 15 | 16 | from ._dbutils import advisory_lock, db_exists, pg_connect 17 | from .manifest import expand_dependencies 18 | from .update import _save_installed_checksums 19 | 20 | _logger = logging.getLogger(__name__) 21 | 22 | 23 | EXCLUDE_PATTERNS = ("*.pyc", "*.pyo") 24 | 25 | 26 | def check_dbname(dbname): 27 | if not re.match("^[A-Za-z][A-Za-z0-9-_.]*$", dbname): 28 | raise click.ClickException("Invalid database name '{}'".format(dbname)) 29 | 30 | 31 | def check_cache_prefix(cache_prefix): 32 | if not re.match("^[A-Za-z][A-Za-z0-9-]{0,7}$", cache_prefix): 33 | raise click.ClickException( 34 | "Invalid cache prefix name '{}'".format(cache_prefix) 35 | ) 36 | 37 | 38 | @contextlib.contextmanager 39 | def _patch_ir_attachment_store(force_db_storage): 40 | @odoo.api.model 41 | def _db_storage(self): 42 | return "db" 43 | 44 | if not force_db_storage: 45 | yield 46 | else: 47 | # make sure attachments created during db initialization 48 | # are stored in database, so we get something consistent 49 | # when recreating the db by copying the cached template 50 | if odoo.release.version_info >= (12, 0): 51 | from odoo.addons.base.models.ir_attachment import IrAttachment 52 | else: 53 | from odoo.addons.base.ir.ir_attachment import IrAttachment 54 | orig = IrAttachment._storage 55 | IrAttachment._storage = _db_storage 56 | try: 57 | yield 58 | finally: 59 | IrAttachment._storage = orig 60 | 61 | 62 | def odoo_createdb(dbname, demo, module_names, force_db_storage): 63 | with _patch_ir_attachment_store(force_db_storage): 64 | odoo.service.db._create_empty_database(dbname) 65 | odoo.tools.config["init"] = dict.fromkeys(module_names, 1) 66 | odoo.tools.config["without_demo"] = not demo 67 | odoo.modules.registry.Registry.new(dbname, force_demo=demo, update_module=True) 68 | _logger.info(click.style(f"Created new Odoo database {dbname}.", fg="green")) 69 | with odoo.sql_db.db_connect(dbname).cursor() as cr: 70 | _save_installed_checksums(cr) 71 | odoo.sql_db.close_db(dbname) 72 | 73 | 74 | def _fnmatch(filename, patterns): 75 | for pattern in patterns: 76 | if fnmatch(filename, pattern): 77 | return True 78 | return False 79 | 80 | 81 | def _walk(top, exclude_patterns=EXCLUDE_PATTERNS): 82 | for dirpath, dirnames, filenames in os.walk(top): 83 | dirnames.sort() 84 | reldir = os.path.relpath(dirpath, top) 85 | if reldir == ".": 86 | reldir = "" 87 | for filename in sorted(filenames): 88 | filepath = os.path.join(reldir, filename) 89 | if _fnmatch(filepath, exclude_patterns): 90 | continue 91 | yield filepath 92 | 93 | 94 | def addons_hash(module_names, with_demo): 95 | h = hashlib.sha1() 96 | h.update("!demo={}!".format(int(bool(with_demo))).encode("utf8")) 97 | for module_name in sorted(expand_dependencies(module_names, True, True)): 98 | module_path = odoo.modules.get_module_path(module_name) 99 | h.update(module_name.encode("utf8")) 100 | for filepath in _walk(module_path): 101 | h.update(filepath.encode("utf8")) 102 | with open(os.path.join(module_path, filepath), "rb") as f: 103 | h.update(f.read()) 104 | return h.hexdigest() 105 | 106 | 107 | def refresh_module_list(dbname): 108 | with click_odoo.OdooEnvironment(database=dbname) as env: 109 | env["ir.module.module"].update_list() 110 | 111 | 112 | class DbCache: 113 | """Manage a cache of db templates. 114 | 115 | Templates are named prefix-YYYYmmddHHMM-hashsum, where 116 | YYYYmmddHHMM is the date and time when the given hashsum has last been 117 | used for that prefix. 118 | """ 119 | 120 | HASH_SIZE = hashlib.sha1().digest_size * 2 121 | MAX_HASHSUM = "f" * HASH_SIZE 122 | 123 | def __init__(self, prefix, pgcr): 124 | check_cache_prefix(prefix) 125 | self.prefix = prefix 126 | self.pgcr = pgcr 127 | 128 | def _lock(self): 129 | return advisory_lock(self.pgcr, self.prefix) 130 | 131 | def _make_pattern(self, dt=None, hs=None): 132 | if dt: 133 | dtpart = dt.strftime("%Y%m%d%H%M") 134 | else: 135 | dtpart = "_" * 12 136 | if hs: 137 | hspart = hs 138 | else: 139 | hspart = "_" * self.HASH_SIZE 140 | pattern = self.prefix + "-" + dtpart + "-" + hspart 141 | # 63 is max postgres db name, so we may truncate the hash part 142 | return pattern[:63] 143 | 144 | def _make_new_template_name(self, hashsum): 145 | return self._make_pattern(dt=datetime.utcnow(), hs=hashsum) 146 | 147 | def _create_db_from_template(self, dbname, template): 148 | _logger.info( 149 | click.style( 150 | f"Creating database {dbname} from template {template}", 151 | fg="green", 152 | ) 153 | ) 154 | self.pgcr.execute( 155 | f""" 156 | CREATE DATABASE "{dbname}" 157 | ENCODING 'unicode' 158 | TEMPLATE "{template}" 159 | """ 160 | ) 161 | 162 | def _rename_db(self, dbname_from, dbname_to): 163 | self.pgcr.execute( 164 | f""" 165 | ALTER DATABASE "{dbname_from}" 166 | RENAME TO "{dbname_to}" 167 | """ 168 | ) 169 | 170 | def _drop_db(self, dbname): 171 | _logger.info(f"Dropping database {dbname}") 172 | self.pgcr.execute( 173 | f""" 174 | DROP DATABASE "{dbname}" 175 | """ 176 | ) 177 | 178 | def _find_template(self, hashsum): 179 | """search same prefix and hashsum, any date""" 180 | pattern = self.prefix + "-____________-" + hashsum 181 | self.pgcr.execute( 182 | """ 183 | SELECT datname FROM pg_database 184 | WHERE datname like %s 185 | ORDER BY datname DESC -- MRU first 186 | """, 187 | (pattern,), 188 | ) 189 | r = self.pgcr.fetchone() 190 | if r: 191 | return r[0] 192 | else: 193 | return None 194 | 195 | def _touch(self, template_name, hashsum): 196 | # change the template date (MRU mechanism) 197 | assert template_name.endswith(hashsum) 198 | new_template_name = self._make_new_template_name(hashsum) 199 | if template_name != new_template_name: 200 | self._rename_db(template_name, new_template_name) 201 | 202 | def create(self, new_database, hashsum): 203 | """Create a new database from a cached template matching hashsum""" 204 | with self._lock(): 205 | template_name = self._find_template(hashsum) 206 | if not template_name: 207 | return False 208 | else: 209 | self._create_db_from_template(new_database, template_name) 210 | self._touch(template_name, hashsum) 211 | return True 212 | 213 | def add(self, new_database, hashsum): 214 | """Create a new cached template""" 215 | with self._lock(): 216 | template_name = self._find_template(hashsum) 217 | if template_name: 218 | self._touch(template_name, hashsum) 219 | else: 220 | new_template_name = self._make_new_template_name(hashsum) 221 | self._create_db_from_template(new_template_name, new_database) 222 | 223 | @property 224 | def size(self): 225 | with self._lock(): 226 | pattern = self._make_pattern() 227 | self.pgcr.execute( 228 | """ 229 | SELECT count(*) FROM pg_database 230 | WHERE datname like %s 231 | """, 232 | (pattern,), 233 | ) 234 | return self.pgcr.fetchone()[0] 235 | 236 | def purge(self): 237 | with self._lock(): 238 | pattern = self._make_pattern() 239 | self.pgcr.execute( 240 | """ 241 | SELECT datname FROM pg_database 242 | WHERE datname like %s 243 | """, 244 | (pattern,), 245 | ) 246 | for (datname,) in self.pgcr.fetchall(): 247 | self._drop_db(datname) 248 | 249 | def trim_size(self, max_size): 250 | with self._lock(): 251 | pattern = self._make_pattern() 252 | self.pgcr.execute( 253 | """ 254 | SELECT datname FROM pg_database 255 | WHERE datname like %s 256 | ORDER BY datname DESC 257 | OFFSET %s 258 | """, 259 | (pattern, max_size), 260 | ) 261 | for (datname,) in self.pgcr.fetchall(): 262 | self._drop_db(datname) 263 | 264 | def trim_age(self, max_age): 265 | with self._lock(): 266 | pattern = self._make_pattern() 267 | max_name = self._make_pattern( 268 | dt=datetime.utcnow() - max_age, hs=self.MAX_HASHSUM 269 | ) 270 | self.pgcr.execute( 271 | """ 272 | SELECT datname FROM pg_database 273 | WHERE datname like %s 274 | AND datname <= %s 275 | ORDER BY datname DESC 276 | """, 277 | (pattern, max_name), 278 | ) 279 | for (datname,) in self.pgcr.fetchall(): 280 | self._drop_db(datname) 281 | 282 | 283 | @click.command() 284 | @click_odoo.env_options( 285 | default_log_level="warn", 286 | with_database=False, 287 | with_rollback=False, 288 | with_addons_path=True, 289 | ) 290 | @click.option( 291 | "--new-database", 292 | "-n", 293 | required=False, 294 | help="Name of new database to create, possibly from cache. " 295 | "If absent, only the cache trimming operation is executed.", 296 | ) 297 | @click.option( 298 | "--modules", 299 | "-m", 300 | default="base", 301 | show_default=True, 302 | help="Comma separated list of addons to install.", 303 | ) 304 | @click.option( 305 | "--demo/--no-demo", default=True, show_default=True, help="Load Odoo demo data." 306 | ) 307 | @click.option( 308 | "--cache/--no-cache", 309 | default=True, 310 | show_default=True, 311 | help="Use a cache of database templates with the exact " 312 | "same addons installed. Disabling this option " 313 | "also disables all other cache-related operations " 314 | "such as max-age or size. Note: when the cache is " 315 | "enabled, all attachments created during database " 316 | "initialization are stored in database instead " 317 | "of the default Odoo file store.", 318 | ) 319 | @click.option( 320 | "--cache-prefix", 321 | default="cache", 322 | show_default=True, 323 | help="Prefix to use when naming cache template databases " 324 | "(max 8 characters). CAUTION: all databases named like " 325 | "{prefix}-____________-% will eventually be dropped " 326 | "by the cache control mechanism, so choose the " 327 | "prefix wisely.", 328 | ) 329 | @click.option( 330 | "--cache-max-age", 331 | default=30, 332 | show_default=True, 333 | type=int, 334 | help="Drop cache templates that have not been used for " 335 | "more than N days. Use -1 to disable.", 336 | ) 337 | @click.option( 338 | "--cache-max-size", 339 | default=5, 340 | show_default=True, 341 | type=int, 342 | help="Keep N most recently used cache templates. Use " 343 | "-1 to disable. Use 0 to empty cache.", 344 | ) 345 | @click.option( 346 | "--unless-exists", 347 | is_flag=True, 348 | help="Don't report error if database already exists.", 349 | ) 350 | def main( 351 | env, 352 | new_database, 353 | modules, 354 | demo, 355 | cache, 356 | cache_prefix, 357 | cache_max_age, 358 | cache_max_size, 359 | unless_exists, 360 | ): 361 | """Create an Odoo database with pre-installed modules. 362 | 363 | Almost like standard Odoo does with the -i option, 364 | except this script manages a cache of database templates with 365 | the exact same addons installed. This is particularly useful to 366 | save time when initializing test databases. 367 | 368 | Cached templates are identified by computing a sha1 369 | checksum of modules provided with the -m option, including their 370 | dependencies and corresponding auto_install modules. 371 | """ 372 | if new_database: 373 | check_dbname(new_database) 374 | if unless_exists and db_exists(new_database): 375 | msg = "Database already exists: {}".format(new_database) 376 | click.echo(click.style(msg, fg="yellow")) 377 | return 378 | module_names = [m.strip() for m in modules.split(",")] 379 | if not cache: 380 | if new_database: 381 | odoo_createdb(new_database, demo, module_names, False) 382 | else: 383 | _logger.info( 384 | "Cache disabled and no new database name provided. " "Nothing to do." 385 | ) 386 | else: 387 | with pg_connect() as pgcr: 388 | dbcache = DbCache(cache_prefix, pgcr) 389 | if new_database: 390 | hashsum = addons_hash(module_names, demo) 391 | if dbcache.create(new_database, hashsum): 392 | _logger.info( 393 | click.style( 394 | "Found matching database template! ✨ 🍰 ✨", 395 | fg="green", 396 | bold=True, 397 | ) 398 | ) 399 | refresh_module_list(new_database) 400 | else: 401 | odoo_createdb(new_database, demo, module_names, True) 402 | dbcache.add(new_database, hashsum) 403 | if cache_max_size >= 0: 404 | dbcache.trim_size(cache_max_size) 405 | if cache_max_age >= 0: 406 | dbcache.trim_age(timedelta(days=cache_max_age)) 407 | 408 | 409 | if __name__ == "__main__": # pragma: no cover 410 | main() 411 | -------------------------------------------------------------------------------- /click_odoo_contrib/listdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2023 Moduon (https://www.moduon.team/) 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | 5 | import click 6 | import click_odoo 7 | from click_odoo import odoo 8 | 9 | from ._dbutils import db_management_enabled 10 | 11 | 12 | @click.command() 13 | @click_odoo.env_options( 14 | default_log_level="warn", with_database=False, with_rollback=False 15 | ) 16 | def main(env): 17 | """List Odoo databases.""" 18 | with db_management_enabled(): 19 | all_dbs = odoo.service.db.list_dbs() 20 | bad_dbs = odoo.service.db.list_db_incompatible(all_dbs) 21 | good_dbs = set(all_dbs) - set(bad_dbs) 22 | for db in sorted(good_dbs): 23 | print(db) 24 | odoo.sql_db.close_all() 25 | 26 | 27 | if __name__ == "__main__": # pragma: no cover 28 | main() 29 | -------------------------------------------------------------------------------- /click_odoo_contrib/makepot.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import base64 5 | import os 6 | import re 7 | import subprocess 8 | 9 | import click 10 | import click_odoo 11 | 12 | from . import gitutils, manifest 13 | 14 | LINE_PATTERNS_TO_REMOVE = [ 15 | r'"POT-Creation-Date:.*?"[\n\r]', 16 | r'"PO-Revision-Date:.*?"[\n\r]', 17 | ] 18 | 19 | PO_FILE_EXT = ".po" 20 | POT_FILE_EXT = ".pot" 21 | 22 | 23 | def export_pot( 24 | env, 25 | module, 26 | addons_dir, 27 | msgmerge, 28 | commit, 29 | msgmerge_if_new_pot, 30 | commit_message, 31 | fuzzy_matching, 32 | purge_old_translations, 33 | ): 34 | addon_name = module.name 35 | addon_dir = os.path.join(addons_dir, addon_name) 36 | i18n_path = os.path.join(addon_dir, "i18n") 37 | pot_filepath = os.path.join(i18n_path, addon_name + POT_FILE_EXT) 38 | 39 | lang_export = env["base.language.export"].create( 40 | {"lang": "__new__", "format": "po", "modules": [(6, 0, [module.id])]} 41 | ) 42 | lang_export.act_getfile() 43 | 44 | if not os.path.isdir(i18n_path): 45 | os.makedirs(i18n_path) 46 | 47 | pot_is_new = not os.path.exists(pot_filepath) 48 | 49 | files_to_commit = set() 50 | 51 | files_to_commit.add(pot_filepath) 52 | with open(pot_filepath, "w", encoding="utf-8") as pot_file: 53 | file_content = base64.b64decode(lang_export.data).decode("utf-8") 54 | for pattern in LINE_PATTERNS_TO_REMOVE: 55 | file_content = re.sub(pattern, "", file_content, flags=re.MULTILINE) 56 | pot_file.write(file_content) 57 | 58 | invalid_po = 0 59 | for lang_filename in os.listdir(i18n_path): 60 | if not lang_filename.endswith(PO_FILE_EXT): 61 | continue 62 | lang_filepath = os.path.join(i18n_path, lang_filename) 63 | try: 64 | if msgmerge or (msgmerge_if_new_pot and pot_is_new): 65 | files_to_commit.add(lang_filepath) 66 | cmd = ["msgmerge", "--quiet", "-U", lang_filepath, pot_filepath] 67 | if not fuzzy_matching: 68 | cmd.append("--no-fuzzy-matching") 69 | subprocess.check_call(cmd) 70 | # Purge old translations 71 | if purge_old_translations: 72 | cmd = [ 73 | "msgattrib", 74 | "--output-file=%s" % lang_filepath, 75 | "--no-obsolete", 76 | lang_filepath, 77 | ] 78 | subprocess.check_call(cmd) 79 | else: 80 | # check .po is valid 81 | subprocess.check_output( 82 | ["msgmerge", "--quiet", lang_filepath, pot_filepath] 83 | ) 84 | except subprocess.CalledProcessError: 85 | invalid_po += 1 86 | if invalid_po: 87 | raise click.ClickException("%d invalid .po file(s) found" % invalid_po) 88 | 89 | if commit: 90 | gitutils.commit_if_needed( 91 | list(files_to_commit), 92 | commit_message.format(addon_name=addon_name), 93 | cwd=addons_dir, 94 | ) 95 | 96 | 97 | @click.command() 98 | @click_odoo.env_options(with_rollback=False, default_log_level="error") 99 | @click.option("--addons-dir", default=".", show_default=True) 100 | @click.option( 101 | "--modules", 102 | "-m", 103 | help="Comma separated list of addons to export translation.", 104 | ) 105 | @click.option( 106 | "--msgmerge / --no-msgmerge", 107 | show_default=True, 108 | help="Merge .pot changes into all .po files.", 109 | ) 110 | @click.option( 111 | "--msgmerge-if-new-pot / --no-msg-merge-if-new-pot", 112 | show_default=True, 113 | help="Merge .pot changes into all .po files, only if " 114 | "a new .pot file has been created.", 115 | ) 116 | @click.option( 117 | "--fuzzy-matching / --no-fuzzy-matching", 118 | show_default=True, 119 | default=True, 120 | help="Use fuzzy matching when merging .pot changes into .po files. " 121 | "Only applies when --msgmerge or --msgmerge-if-new-pot are passed.", 122 | ) 123 | @click.option( 124 | "--purge-old-translations / --no-purge-old-translations", 125 | show_default=True, 126 | default=False, 127 | help="Remove comment lines containing old translations from .po files. " 128 | "Only applies when --msgmerge or --msgmerge-if-new-pot are passed.", 129 | ) 130 | @click.option( 131 | "--commit / --no-commit", 132 | show_default=True, 133 | help="Git commit exported .pot files if needed.", 134 | ) 135 | @click.option( 136 | "--commit-message", show_default=True, default="[UPD] Update {addon_name}.pot" 137 | ) 138 | def main( 139 | env, 140 | addons_dir, 141 | modules, 142 | msgmerge, 143 | commit, 144 | msgmerge_if_new_pot, 145 | commit_message, 146 | fuzzy_matching, 147 | purge_old_translations, 148 | ): 149 | """Export translation (.pot) files of addons 150 | installed in the database and present in addons_dir. 151 | Check that existing .po file are syntactically correct. 152 | Optionally, run msgmerge on the existing .po files to keep 153 | them up to date. Commit changes to git, if any. 154 | """ 155 | addon_names = [addon_name for addon_name, _, _ in manifest.find_addons(addons_dir)] 156 | if modules: 157 | modules = {m.strip() for m in modules.split(",")} 158 | not_existing_modules = modules - set(addon_names) 159 | if not_existing_modules: 160 | raise click.ClickException( 161 | "Module(s) was not found: " + ", ".join(not_existing_modules) 162 | ) 163 | addon_names = [ 164 | addon_name for addon_name in addon_names if addon_name in modules 165 | ] 166 | if addon_names: 167 | modules = env["ir.module.module"].search( 168 | [("state", "=", "installed"), ("name", "in", addon_names)] 169 | ) 170 | for module in modules: 171 | export_pot( 172 | env, 173 | module, 174 | addons_dir, 175 | msgmerge, 176 | commit, 177 | msgmerge_if_new_pot, 178 | commit_message, 179 | fuzzy_matching, 180 | purge_old_translations, 181 | ) 182 | 183 | 184 | if __name__ == "__main__": 185 | main() 186 | -------------------------------------------------------------------------------- /click_odoo_contrib/manifest.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import ast 5 | import os 6 | 7 | from click_odoo import odoo 8 | 9 | MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py") 10 | 11 | 12 | class NoManifestFound(Exception): 13 | pass 14 | 15 | 16 | class ModuleNotFound(Exception): 17 | pass 18 | 19 | 20 | def get_manifest_path(addon_dir): 21 | for manifest_name in MANIFEST_NAMES: 22 | manifest_path = os.path.join(addon_dir, manifest_name) 23 | if os.path.isfile(manifest_path): 24 | return manifest_path 25 | 26 | 27 | def parse_manifest(s): 28 | return ast.literal_eval(s) 29 | 30 | 31 | def read_manifest(addon_dir): 32 | manifest_path = get_manifest_path(addon_dir) 33 | if not manifest_path: 34 | raise NoManifestFound("no Odoo manifest found in %s" % addon_dir) 35 | with open(manifest_path) as mf: 36 | return parse_manifest(mf.read()) 37 | 38 | 39 | def find_addons(addons_dir, installable_only=True): 40 | """yield (addon_name, addon_dir, manifest)""" 41 | for addon_name in sorted(os.listdir(addons_dir)): 42 | addon_dir = os.path.join(addons_dir, addon_name) 43 | try: 44 | manifest = read_manifest(addon_dir) 45 | except NoManifestFound: 46 | continue 47 | if installable_only and not manifest.get("installable", True): 48 | continue 49 | yield addon_name, addon_dir, manifest 50 | 51 | 52 | def expand_dependencies(module_names, include_auto_install=False, include_active=False): 53 | """Return a set of module names with their transitive 54 | dependencies. This method does not need an Odoo database, 55 | but requires the addons path to be initialized. 56 | """ 57 | 58 | def add_deps(name): 59 | if name in res: 60 | return 61 | res.add(name) 62 | path = odoo.modules.get_module_path(name) 63 | if not path: 64 | raise ModuleNotFound(name) 65 | manifest = read_manifest(path) 66 | for dep in manifest.get("depends", ["base"]): 67 | add_deps(dep) 68 | 69 | res = set() 70 | for module_name in module_names: 71 | add_deps(module_name) 72 | if include_active: 73 | for module_name in sorted(odoo.modules.module.get_modules()): 74 | module_path = odoo.modules.get_module_path(module_name) 75 | manifest = read_manifest(module_path) 76 | if manifest.get("active"): 77 | add_deps(module_name) 78 | if include_auto_install: 79 | auto_install_list = [] 80 | for module_name in sorted(odoo.modules.module.get_modules()): 81 | module_path = odoo.modules.get_module_path(module_name) 82 | manifest = read_manifest(module_path) 83 | if manifest.get("auto_install"): 84 | auto_install_list.append((module_name, manifest)) 85 | retry = True 86 | while retry: 87 | retry = False 88 | for module_name, manifest in auto_install_list: 89 | if module_name in res: 90 | continue 91 | depends = set(manifest.get("depends", ["base"])) 92 | if depends.issubset(res): 93 | # all dependencies of auto_install module are 94 | # installed so we add it 95 | add_deps(module_name) 96 | # retry, in case an auto_install module depends 97 | # on other auto_install modules 98 | retry = True 99 | return res 100 | -------------------------------------------------------------------------------- /click_odoo_contrib/restoredb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2019 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | 5 | import os 6 | import shutil 7 | import subprocess 8 | 9 | import click 10 | import click_odoo 11 | import psycopg2 12 | from click_odoo import OdooEnvironment, odoo 13 | 14 | from ._dbutils import db_exists, db_management_enabled, reset_config_parameters 15 | from .backupdb import DBDUMP_FILENAME, FILESTORE_DIRNAME, MANIFEST_FILENAME 16 | 17 | 18 | def _restore_from_folder(dbname, backup, copy=True, jobs=1, neutralize=False): 19 | manifest_file_path = os.path.join(backup, MANIFEST_FILENAME) 20 | dbdump_file_path = os.path.join(backup, DBDUMP_FILENAME) 21 | filestore_dir_path = os.path.join(backup, FILESTORE_DIRNAME) 22 | if not os.path.exists(manifest_file_path) or not os.path.exists(dbdump_file_path): 23 | msg = ( 24 | "{} is not folder backup created by the backupdb command. " 25 | "{} and {} files are missing.".format( 26 | backup, MANIFEST_FILENAME, DBDUMP_FILENAME 27 | ) 28 | ) 29 | raise click.ClickException(msg) 30 | 31 | odoo.service.db._create_empty_database(dbname) 32 | pg_args = ["--jobs", str(jobs), "--dbname", dbname, "--no-owner", dbdump_file_path] 33 | pg_env = odoo.tools.misc.exec_pg_environ() 34 | r = subprocess.run( 35 | ["pg_restore", *pg_args], 36 | env=pg_env, 37 | stdout=subprocess.DEVNULL, 38 | stderr=subprocess.STDOUT, 39 | ) 40 | if r.returncode != 0: 41 | raise click.ClickException("Couldn't restore database") 42 | if copy: 43 | # if it's a copy of a database, force generation of a new dbuuid 44 | reset_config_parameters(dbname) 45 | with OdooEnvironment(dbname) as env: 46 | if neutralize and odoo.release.version_info >= (16, 0): 47 | odoo.modules.neutralize.neutralize_database(env.cr) 48 | if os.path.exists(filestore_dir_path): 49 | filestore_dest = env["ir.attachment"]._filestore() 50 | shutil.move(filestore_dir_path, filestore_dest) 51 | 52 | if odoo.tools.config["unaccent"]: 53 | try: 54 | with env.cr.savepoint(): 55 | env.cr.execute("CREATE EXTENSION unaccent") 56 | except psycopg2.Error: 57 | pass 58 | odoo.sql_db.close_db(dbname) 59 | 60 | 61 | def _restore_from_file(dbname, backup, copy=True, neutralize=False): 62 | with db_management_enabled(): 63 | extra_kwargs = {} 64 | if odoo.release.version_info >= (16, 0): 65 | extra_kwargs["neutralize_database"] = neutralize 66 | odoo.service.db.restore_db(dbname, backup, copy, **extra_kwargs) 67 | odoo.sql_db.close_db(dbname) 68 | 69 | 70 | @click.command() 71 | @click_odoo.env_options( 72 | default_log_level="warn", with_database=False, with_rollback=False 73 | ) 74 | @click.option( 75 | "--copy/--move", 76 | default=True, 77 | help=( 78 | "This database is a copy.\nIn order " 79 | "to avoid conflicts between databases, Odoo needs to know if this" 80 | "database was moved or copied. If you don't know, set is a copy." 81 | ), 82 | ) 83 | @click.option( 84 | "--force", 85 | is_flag=True, 86 | show_default=True, 87 | help=( 88 | "Don't report error if destination database already exists. If " 89 | "force and destination database exists, it will be dropped before " 90 | "restore." 91 | ), 92 | ) 93 | @click.option( 94 | "--neutralize", 95 | is_flag=True, 96 | show_default=True, 97 | help=( 98 | "Neutralize the database after restore. This will disable scheduled actions, " 99 | "outgoing emails, and sets other external providers in test mode. " 100 | "This works only in odoo 16.0 and above." 101 | ), 102 | ) 103 | @click.option( 104 | "--jobs", 105 | help=( 106 | "Uses this many parallel jobs to restore. Only used to " 107 | "restore folder format backup." 108 | ), 109 | type=int, 110 | default=1, 111 | ) 112 | @click.argument("dbname", nargs=1) 113 | @click.argument( 114 | "source", 115 | nargs=1, 116 | type=click.Path( 117 | exists=True, file_okay=True, dir_okay=True, readable=True, resolve_path=True 118 | ), 119 | ) 120 | def main(env, dbname, source, copy, force, neutralize, jobs): 121 | """Restore an Odoo database backup. 122 | 123 | This script allows you to restore databses created by using the Odoo 124 | web interface or the backupdb script. This 125 | avoids timeout and file size limitation problems when 126 | databases are too large. 127 | """ 128 | if db_exists(dbname): 129 | msg = "Destination database already exists: {}".format(dbname) 130 | if not force: 131 | raise click.ClickException(msg) 132 | msg = "{}, dropping it as requested.".format(msg) 133 | click.echo(click.style(msg, fg="yellow")) 134 | with db_management_enabled(): 135 | odoo.service.db.exp_drop(dbname) 136 | if neutralize and odoo.release.version_info < (16, 0): 137 | raise click.ClickException( 138 | "--neutralize option is only available in odoo 16.0 and above" 139 | ) 140 | if os.path.isfile(source): 141 | _restore_from_file(dbname, source, copy, neutralize) 142 | else: 143 | _restore_from_folder(dbname, source, copy, jobs, neutralize) 144 | -------------------------------------------------------------------------------- /click_odoo_contrib/uninstall.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2018 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | import logging 5 | 6 | import click 7 | import click_odoo 8 | 9 | _logger = logging.getLogger(__name__) 10 | 11 | 12 | def uninstall(env, module_names): 13 | modules = env["ir.module.module"].search([("name", "in", module_names)]) 14 | _logger.info("uninstalling %s", modules.mapped("name")) 15 | modules.button_immediate_uninstall() 16 | env.cr.commit() 17 | 18 | 19 | @click.command() 20 | @click_odoo.env_options(with_rollback=False) 21 | @click.option( 22 | "--modules", 23 | "-m", 24 | required=True, 25 | help="Comma-separated list of modules to uninstall", 26 | ) 27 | def main(env, modules): 28 | module_names = [m.strip() for m in modules.split(",")] 29 | uninstall(env, module_names) 30 | 31 | 32 | if __name__ == "__main__": # pragma: no cover 33 | main() 34 | -------------------------------------------------------------------------------- /click_odoo_contrib/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2018 ACSONE SA/NV () 3 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 4 | 5 | import json 6 | import logging 7 | import os 8 | import threading 9 | from contextlib import closing, contextmanager 10 | from datetime import timedelta 11 | from time import sleep 12 | 13 | import click 14 | import click_odoo 15 | import psycopg2 16 | from click_odoo import OdooEnvironment, odoo 17 | from manifestoo_core.core_addons import get_core_addons 18 | from manifestoo_core.odoo_series import OdooSeries 19 | 20 | from ._addon_hash import addon_hash 21 | from ._dbutils import advisory_lock 22 | 23 | _logger = logging.getLogger(__name__) 24 | 25 | 26 | PARAM_INSTALLED_CHECKSUMS = "module_auto_update.installed_checksums" 27 | PARAM_EXCLUDE_PATTERNS = "module_auto_update.exclude_patterns" 28 | DEFAULT_EXCLUDE_PATTERNS = "*.pyc,*.pyo,i18n/*.pot,i18n_extra/*.pot,static/*,tests/*" 29 | 30 | 31 | class DbLockWatcher(threading.Thread): 32 | def __init__(self, database, max_seconds): 33 | super().__init__() 34 | self.daemon = True 35 | self.database = database 36 | self.max_seconds = max_seconds 37 | self.aborted = False 38 | self.watching = False 39 | 40 | def stop(self): 41 | self.watching = False 42 | 43 | def run(self): 44 | """Watch DB while another process is updating Odoo. 45 | 46 | This method will query :param:`database` to see if there are DB locks. 47 | If a lock longer than :param:`max_seconds` is detected, it will be 48 | terminated and an exception will be raised. 49 | 50 | :param str database: 51 | Name of the database that is being updated in parallel. 52 | 53 | :param float max_seconds: 54 | Max length of DB lock allowed. 55 | """ 56 | _logger.info("Starting DB lock watcher") 57 | beat = self.max_seconds / 3 58 | max_td = timedelta(seconds=self.max_seconds) 59 | own_pid_query = "SELECT pg_backend_pid()" 60 | # SQL explained in https://wiki.postgresql.org/wiki/Lock_Monitoring 61 | locks_query = """ 62 | SELECT 63 | pg_stat_activity.datname, 64 | pg_class.relname, 65 | pg_locks.transactionid, 66 | pg_locks.mode, 67 | pg_locks.granted, 68 | pg_stat_activity.usename, 69 | pg_stat_activity.query, 70 | pg_stat_activity.query_start, 71 | AGE(NOW(), pg_stat_activity.query_start) AS age, 72 | pg_stat_activity.pid 73 | FROM 74 | pg_stat_activity 75 | JOIN pg_locks ON pg_locks.pid = pg_stat_activity.pid 76 | JOIN pg_class ON pg_class.oid = pg_locks.relation 77 | WHERE 78 | NOT pg_locks.granted 79 | AND pg_stat_activity.datname = %s 80 | ORDER BY pg_stat_activity.query_start 81 | """ 82 | # See https://stackoverflow.com/a/35319598/1468388 83 | terminate_session = "SELECT pg_terminate_backend(%s)" 84 | params = odoo.sql_db.connection_info_for(self.database)[1] 85 | # Need a separate raw psycopg2 cursor without transactioning to avoid 86 | # weird concurrency errors; this cursor will only trigger SELECTs, and 87 | # it needs to access current Postgres server status, monitoring other 88 | # transactions' status, so running inside a normal, transactioned, 89 | # Odoo cursor would block such monitoring and, after all, offer no 90 | # additional protection 91 | with closing(psycopg2.connect(**params)) as watcher_conn: 92 | watcher_conn.set_isolation_level( 93 | psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT 94 | ) 95 | self.watching = True 96 | while self.watching: 97 | # Wait some time before checking locks 98 | sleep(beat) 99 | # Ensure no long blocking queries happen 100 | with closing( 101 | watcher_conn.cursor(cursor_factory=psycopg2.extras.DictCursor) 102 | ) as watcher_cr: 103 | if _logger.level <= logging.DEBUG: 104 | watcher_cr.execute(own_pid_query) 105 | watcher_pid = watcher_cr.fetchone()[0] 106 | _logger.debug( 107 | "DB lock watcher running with postgres PID %d", watcher_pid 108 | ) 109 | watcher_cr.execute(locks_query, (self.database,)) 110 | locks = watcher_cr.fetchall() 111 | if locks: 112 | _logger.warning("%d locked queries found", len(locks)) 113 | _logger.info("Query details: %r", locks) 114 | for row in locks: 115 | if row["age"] > max_td: 116 | # Terminate the query to abort the parallel update 117 | _logger.error( 118 | "Long locked query detected; aborting update cursor " 119 | "with PID %d...", 120 | row["pid"], 121 | ) 122 | self.aborted = True 123 | watcher_cr.execute(terminate_session, (row["pid"],)) 124 | 125 | 126 | def _get_param(cr, key, default=None): 127 | cr.execute("SELECT value FROM ir_config_parameter WHERE key=%s", (key,)) 128 | r = cr.fetchone() 129 | if r: 130 | return r[0] 131 | else: 132 | return default 133 | 134 | 135 | def _set_param(cr, key, value): 136 | cr.execute( 137 | "UPDATE ir_config_parameter SET value=%s, write_date=now() AT TIME ZONE 'UTC' " 138 | "WHERE key=%s", 139 | (value, key), 140 | ) 141 | if not cr.rowcount: 142 | cr.execute( 143 | "INSERT INTO ir_config_parameter (key, value, create_date, write_date) " 144 | "VALUES (%s, %s, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC')", 145 | (key, value), 146 | ) 147 | 148 | 149 | def _load_installed_checksums(cr): 150 | value = _get_param(cr, PARAM_INSTALLED_CHECKSUMS) 151 | if value: 152 | return json.loads(value) 153 | else: 154 | return {} 155 | 156 | 157 | def _save_installed_checksums(cr, ignore_addons=None): 158 | checksums = {} 159 | cr.execute("SELECT name FROM ir_module_module WHERE state='installed'") 160 | for (module_name,) in cr.fetchall(): 161 | if ignore_addons and module_name in ignore_addons: 162 | continue 163 | checksums[module_name] = _get_checksum_dir(cr, module_name) 164 | _set_param(cr, PARAM_INSTALLED_CHECKSUMS, json.dumps(checksums)) 165 | _logger.info("Database updated, new checksums stored") 166 | 167 | 168 | def _get_checksum_dir(cr, module_name): 169 | exclude_patterns = _get_param(cr, PARAM_EXCLUDE_PATTERNS, DEFAULT_EXCLUDE_PATTERNS) 170 | exclude_patterns = [p.strip() for p in exclude_patterns.split(",")] 171 | cr.execute("SELECT code FROM res_lang WHERE active") 172 | keep_langs = [r[0] for r in cr.fetchall()] 173 | 174 | module_path = odoo.modules.module.get_module_path(module_name) 175 | if module_path and os.path.isdir(module_path): 176 | checksum_dir = addon_hash(module_path, exclude_patterns, keep_langs) 177 | else: 178 | checksum_dir = False 179 | 180 | return checksum_dir 181 | 182 | 183 | def _get_modules_to_update(cr, ignore_addons=None): 184 | if ignore_addons is None: 185 | ignore_addons = [] 186 | modules_to_update = [] 187 | checksums = _load_installed_checksums(cr) 188 | cr.execute( 189 | "SELECT name FROM ir_module_module " 190 | "WHERE state in ('installed', 'to upgrade')" 191 | ) 192 | for (module_name,) in cr.fetchall(): 193 | if not _is_installable(module_name): 194 | # if the module is not installable, do not try to update it 195 | continue 196 | if module_name in ignore_addons: 197 | continue 198 | if _get_checksum_dir(cr, module_name) != checksums.get(module_name): 199 | modules_to_update.append(module_name) 200 | return modules_to_update 201 | 202 | 203 | def _is_installable(module_name): 204 | try: 205 | if odoo.release.version_info < (16, 0): 206 | manifest = odoo.modules.load_information_from_description_file(module_name) 207 | else: 208 | manifest = odoo.modules.get_manifest(module_name) 209 | return manifest["installable"] 210 | except Exception: 211 | # load_information_from_description_file populates default value 212 | # so the exception would be KeyError if the module does not exist 213 | # in addons path, or an error parsing __manifest__.py. In either 214 | # case the module will not be installable. 215 | return False 216 | 217 | 218 | def _update_db_nolock( 219 | conn, 220 | database, 221 | update_all, 222 | i18n_overwrite, 223 | watcher=None, 224 | list_only=False, 225 | ignore_addons=None, 226 | ): 227 | to_update = odoo.tools.config["update"] 228 | if update_all: 229 | to_update["base"] = 1 230 | else: 231 | with conn.cursor() as cr: 232 | for module_name in _get_modules_to_update(cr, ignore_addons): 233 | to_update[module_name] = 1 234 | if to_update: 235 | _logger.info( 236 | "Updating addons for their hash changed: %s.", 237 | ",".join(to_update.keys()), 238 | ) 239 | if list_only: 240 | odoo.tools.config["update"] = {} 241 | _logger.info("List-only selected, update is not performed.") 242 | return 243 | if not to_update: 244 | _logger.info("No module needs updating, update is not performed.") 245 | return 246 | if i18n_overwrite: 247 | odoo.tools.config["overwrite_existing_translations"] = True 248 | odoo.modules.registry.Registry.new(database, update_module=True) 249 | if watcher and watcher.aborted: 250 | # If you get here, the updating session has been terminated and it 251 | # somehow has recovered by opening a new cursor and continuing; 252 | # that's very unlikely, but just in case some paranoid module 253 | # happens to update, let's just make sure the exit code for 254 | # this script indicates always a failure 255 | raise click.Abort("Update aborted by watcher, check logs") 256 | with conn.cursor() as cr: 257 | _save_installed_checksums(cr, ignore_addons) 258 | 259 | 260 | def _update_db( 261 | database, 262 | update_all, 263 | i18n_overwrite, 264 | watcher=None, 265 | list_only=False, 266 | ignore_addons=None, 267 | ): 268 | conn = odoo.sql_db.db_connect(database) 269 | with conn.cursor() as cr, advisory_lock(cr, "click-odoo-update/" + database): 270 | _update_db_nolock( 271 | conn, 272 | database, 273 | update_all, 274 | i18n_overwrite, 275 | watcher, 276 | list_only, 277 | ignore_addons, 278 | ) 279 | 280 | 281 | @contextmanager 282 | def OdooEnvironmentWithUpdate(database, ctx, **kwargs): 283 | # Watch for database locks while Odoo updates 284 | watcher = None 285 | if ctx.params["watcher_max_seconds"] > 0: 286 | watcher = DbLockWatcher(database, ctx.params["watcher_max_seconds"]) 287 | watcher.start() 288 | ignore_addons = set() 289 | if ctx.params["ignore_addons"]: 290 | ignore_addons.update(ctx.params["ignore_addons"].strip().split(",")) 291 | if ctx.params["ignore_core_addons"]: 292 | ignore_addons.update(get_core_addons(OdooSeries(odoo.release.series))) 293 | if ignore_addons and ctx.params["update_all"]: 294 | raise click.ClickException( 295 | "--update-all and --ignore(-core)-addons cannot be used together" 296 | ) 297 | # Update Odoo datatabase 298 | try: 299 | _update_db( 300 | database, 301 | ctx.params["update_all"], 302 | ctx.params["i18n_overwrite"], 303 | watcher, 304 | ctx.params["list_only"], 305 | ignore_addons, 306 | ) 307 | finally: 308 | if watcher: 309 | watcher.stop() 310 | # If we get here, the database has been updated 311 | with OdooEnvironment(database) as env: 312 | yield env 313 | 314 | 315 | @click.command() 316 | @click_odoo.env_options( 317 | with_rollback=False, 318 | database_must_exist=False, 319 | with_addons_path=True, 320 | environment_manager=OdooEnvironmentWithUpdate, 321 | ) 322 | @click.option("--i18n-overwrite", is_flag=True, help="Overwrite existing translations") 323 | @click.option("--update-all", is_flag=True, help="Force a complete upgrade (-u base)") 324 | @click.option( 325 | "--ignore-addons", 326 | help=( 327 | "A comma-separated list of addons to ignore. " 328 | "These will not be updated if their checksum has changed. " 329 | "Use with care." 330 | ), 331 | ) 332 | @click.option( 333 | "--ignore-core-addons", 334 | is_flag=True, 335 | help=( 336 | "If this option is set, Odoo CE and EE addons are not updated. " 337 | "This is normally safe, due the Odoo stable policy." 338 | ), 339 | ) 340 | @click.option( 341 | "--if-exists", is_flag=True, help="Don't report error if database doesn't exist" 342 | ) 343 | @click.option( 344 | "--watcher-max-seconds", 345 | default=0, 346 | type=float, 347 | help=( 348 | "Max DB lock seconds allowed before aborting the update process. " 349 | "Default: 0 (disabled)." 350 | ), 351 | ) 352 | @click.option( 353 | "--list-only", 354 | is_flag=True, 355 | help="Log the list of addons to update without actually updating them.", 356 | ) 357 | def main( 358 | env, 359 | i18n_overwrite, 360 | update_all, 361 | if_exists, 362 | watcher_max_seconds, 363 | list_only, 364 | ignore_addons, 365 | ignore_core_addons, 366 | ): 367 | """Update an Odoo database (odoo -u), automatically detecting 368 | addons to update based on a hash of their file content, compared 369 | to the hashes stored in the database. 370 | 371 | If you want to update in parallel while another Odoo instance is still 372 | running against the same database, you can use `--watcher-max-seconds` 373 | to start a watcher thread that aborts the update in case a DB 374 | lock is found. You will probably need to have at least 2 odoo codebases 375 | running in parallel (the old one, serving; the new one, updating) and 376 | swap them ASAP once the update is done. This process will reduce downtime 377 | a lot, but it requires deeper knowledge of Odoo internals to be used 378 | safely, so use it at your own risk. 379 | """ 380 | if not env: 381 | msg = "Database does not exist" 382 | if if_exists: 383 | click.echo(click.style(msg, fg="yellow")) 384 | return 385 | else: 386 | raise click.ClickException(msg) 387 | # TODO: warn if modules to upgrade ? 388 | 389 | 390 | if __name__ == "__main__": # pragma: no cover 391 | main() 392 | -------------------------------------------------------------------------------- /newsfragments/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/newsfragments/.gitignore -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.towncrier] 6 | template = ".towncrier-template.rst" 7 | underlines = ["-"] 8 | title_format = "{version} ({project_date})" 9 | issue_format = "`#{issue} `_" 10 | directory = "newsfragments/" 11 | filename = "CHANGES.rst" 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | 6 | from setuptools import find_packages, setup 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | setup( 11 | name="click-odoo-contrib", 12 | description="click-odoo scripts collection", 13 | long_description="\n".join( 14 | ( 15 | open(os.path.join(here, "README.rst")).read(), 16 | open(os.path.join(here, "CHANGES.rst")).read(), 17 | ) 18 | ), 19 | use_scm_version=True, 20 | packages=find_packages(), 21 | include_package_data=True, 22 | setup_requires=["setuptools-scm"], 23 | install_requires=[ 24 | "click-odoo>=1.3.0", 25 | "manifestoo-core>=0.7", 26 | "importlib_resources ; python_version<'3.9'", 27 | ], 28 | python_requires=">=3.6", 29 | license="LGPLv3+", 30 | author="ACSONE SA/NV", 31 | author_email="info@acsone.eu", 32 | url="http://github.com/acsone/click-odoo-contrib", 33 | classifiers=[ 34 | "Development Status :: 4 - Beta", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: " 37 | "GNU Lesser General Public License v3 or later (LGPLv3+)", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Framework :: Odoo", 45 | ], 46 | entry_points=""" 47 | [console_scripts] 48 | click-odoo-uninstall=click_odoo_contrib.uninstall:main 49 | click-odoo-update=click_odoo_contrib.update:main 50 | click-odoo-copydb=click_odoo_contrib.copydb:main 51 | click-odoo-dropdb=click_odoo_contrib.dropdb:main 52 | click-odoo-initdb=click_odoo_contrib.initdb:main 53 | click-odoo-listdb=click_odoo_contrib.listdb:main 54 | click-odoo-backupdb=click_odoo_contrib.backupdb:main 55 | click-odoo-restoredb=click_odoo_contrib.restoredb:main 56 | click-odoo-makepot=click_odoo_contrib.makepot:main 57 | """, 58 | ) 59 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import subprocess 6 | import textwrap 7 | 8 | import pytest 9 | from click_odoo import odoo, odoo_bin 10 | 11 | # This hack is necessary because the way CliRunner patches 12 | # stdout is not compatible with the Odoo logging initialization 13 | # mechanism. Logging is therefore tested with subprocesses. 14 | odoo.netsvc.init_logger = lambda: None 15 | 16 | 17 | def _init_odoo_db(dbname, test_addons_dir=None): 18 | subprocess.check_call(["createdb", dbname]) 19 | cmd = [odoo_bin, "-d", dbname, "-i", "base", "--stop-after-init"] 20 | if test_addons_dir: 21 | addons_path = [ 22 | os.path.join(odoo.__path__[0], "addons"), 23 | os.path.join(odoo.__path__[0], "..", "addons"), 24 | test_addons_dir, 25 | ] 26 | cmd.append("--addons-path") 27 | cmd.append(",".join(addons_path)) 28 | subprocess.check_call(cmd) 29 | 30 | 31 | def _drop_db(dbname): 32 | subprocess.check_call(["dropdb", dbname]) 33 | 34 | 35 | @pytest.fixture(scope="module") 36 | def odoodb(request): 37 | dbname = "click-odoo-contrib-test-{}".format(odoo.release.version_info[0]) 38 | test_addons_dir = getattr(request.module, "test_addons_dir", "") 39 | _init_odoo_db(dbname, test_addons_dir) 40 | try: 41 | yield dbname 42 | finally: 43 | _drop_db(dbname) 44 | 45 | 46 | @pytest.fixture(scope="function") 47 | def odoocfg(request, tmpdir): 48 | addons_path = [ 49 | os.path.join(odoo.__path__[0], "addons"), 50 | os.path.join(odoo.__path__[0], "..", "addons"), 51 | ] 52 | test_addons_dir = getattr(request.module, "test_addons_dir", "") 53 | if test_addons_dir: 54 | addons_path.append(test_addons_dir) 55 | odoo_cfg = tmpdir / "odoo.cfg" 56 | odoo_cfg.write( 57 | textwrap.dedent( 58 | f"""\ 59 | [options] 60 | addons_path = {",".join(addons_path)} 61 | """ 62 | ) 63 | ) 64 | yield odoo_cfg 65 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/README.rst: -------------------------------------------------------------------------------- 1 | Test data for addon_hash module. 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/data/f1.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/data/f2.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n/en.po: -------------------------------------------------------------------------------- 1 | en text 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n/en_US.po: -------------------------------------------------------------------------------- 1 | en_US 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n/fr.po: -------------------------------------------------------------------------------- 1 | fr 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n/fr_BE.po: -------------------------------------------------------------------------------- 1 | fr_BE 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n/test.pot: -------------------------------------------------------------------------------- 1 | ... 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n_extra/en.po: -------------------------------------------------------------------------------- 1 | en 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n_extra/fr.po: -------------------------------------------------------------------------------- 1 | fr 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/i18n_extra/nl_NL.po: -------------------------------------------------------------------------------- 1 | nl_NL 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/models/stuff.py: -------------------------------------------------------------------------------- 1 | 1+1 2 | -------------------------------------------------------------------------------- /tests/data/test_addon_hash/models/stuff.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_addon_hash/models/stuff.pyc -------------------------------------------------------------------------------- /tests/data/test_addon_hash/models/stuff.pyo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_addon_hash/models/stuff.pyo -------------------------------------------------------------------------------- /tests/data/test_addon_hash/static/src/some.js: -------------------------------------------------------------------------------- 1 | /* Javascript */ 2 | -------------------------------------------------------------------------------- /tests/data/test_initdb/addon1/__openerp__.py: -------------------------------------------------------------------------------- 1 | # must be __openerp__.py, for compatibility with 8, 9 tests 2 | {"name": "addon 1"} 3 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/__init__.py: -------------------------------------------------------------------------------- 1 | from . import models # noqa 2 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/__openerp__.py: -------------------------------------------------------------------------------- 1 | # must be __openerp__.py, for compatibility with 8, 9 tests 2 | {"name": "addon test makepot", "installable": True} 3 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/i18n/fr.po.bad: -------------------------------------------------------------------------------- 1 | #. module: addon_test_makepot 2 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_create_uid 3 | msgid "Created on" 4 | msgstr "" 5 | 6 | #. module: addon_test_makepot 7 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_create_date 8 | msgid "Created on" 9 | msgstr "" 10 | 11 | #. module: addon_test_makepot 12 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_display_name 13 | msgid "Display Name" 14 | msgstr "" 15 | 16 | #. module: addon_test_makepot 17 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_id 18 | msgid "ID" 19 | msgstr "" 20 | 21 | #. module: addon_test_makepot 22 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model___last_update 23 | msgid "Last Modified on" 24 | msgstr "" 25 | 26 | #. module: addon_test_makepot 27 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_write_uid 28 | msgid "Last Updated by" 29 | msgstr "" 30 | 31 | #. module: addon_test_makepot 32 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_write_date 33 | msgid "Last Updated on" 34 | msgstr "" 35 | 36 | #. module: addon_test_makepot 37 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_myfield 38 | msgid "Myfield" 39 | msgstr "" 40 | 41 | #. module: addon_test_makepot 42 | #: model:ir.model,name:addon_test_makepot.model_test_model 43 | msgid "test.model" 44 | msgstr "" 45 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/i18n/fr.po.fuzzy: -------------------------------------------------------------------------------- 1 | #. module: addon_test_makepot 2 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_create_uid 3 | msgid "Created on" 4 | msgstr "" 5 | 6 | #. module: addon_test_makepot 7 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_create_date 8 | msgid "Created on" 9 | msgstr "" 10 | 11 | #. module: addon_test_makepot 12 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_display_name 13 | msgid "Display Name" 14 | msgstr "" 15 | 16 | #. module: addon_test_makepot 17 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_id 18 | #, fuzzy 19 | msgid "ID" 20 | msgstr "" 21 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/i18n/fr.po.old: -------------------------------------------------------------------------------- 1 | #. module: addon_test_makepot 2 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_create_uid 3 | msgid "Created on" 4 | msgstr "" 5 | 6 | #. module: addon_test_makepot 7 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_create_date 8 | msgid "Created on" 9 | msgstr "" 10 | 11 | #. module: addon_test_makepot 12 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_display_name 13 | msgid "Display Name" 14 | msgstr "" 15 | 16 | #. module: addon_test_makepot 17 | #: model:ir.model.fields,field_description:addon_test_makepot.field_test_model_id 18 | #, fuzzy 19 | msgid "ID" 20 | msgstr "" 21 | 22 | #~ msgid "Last Modified on" 23 | #~ msgstr "" 24 | 25 | #~ msgid "Last Updated by" 26 | #~ msgstr "" 27 | 28 | #~ msgid "Last Updated on" 29 | #~ msgstr "" 30 | 31 | #~ msgid "Myfield" 32 | #~ msgstr "" 33 | 34 | #~ msgid "test.model" 35 | #~ msgstr "" -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import testmodel # noqa 2 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot/models/testmodel.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields 2 | 3 | 4 | class TestModel(models.Model): 5 | 6 | _name = "test.model" 7 | 8 | myfield = fields.Char() 9 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot_2/__init__.py: -------------------------------------------------------------------------------- 1 | from . import models # noqa 2 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot_2/__openerp__.py: -------------------------------------------------------------------------------- 1 | # must be __openerp__.py, for compatibility with 8, 9 tests 2 | {"name": "addon test makepot 2", "installable": True} 3 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot_2/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import testmodel # noqa 2 | -------------------------------------------------------------------------------- /tests/data/test_makepot/addon_test_makepot_2/models/testmodel.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields 2 | 3 | 4 | class TestModel2(models.Model): 5 | 6 | _name = "test.model2" 7 | 8 | myfield = fields.Char() 9 | -------------------------------------------------------------------------------- /tests/data/test_manifest/addon1/__openerp__.py: -------------------------------------------------------------------------------- 1 | # must be __openerp__.py, for compatibility with 8, 9 tests 2 | {"name": "addon 1"} 3 | -------------------------------------------------------------------------------- /tests/data/test_manifest/addon_uninstallable/__manifest__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon uninstallable", "installable": False} 2 | -------------------------------------------------------------------------------- /tests/data/test_manifest/setup/README.txt: -------------------------------------------------------------------------------- 1 | This directory is not an Odoo addon. 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v1/addon_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v1/addon_app/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v1/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "1.0", 5 | "description": "First version.", 6 | } 7 | -------------------------------------------------------------------------------- /tests/data/test_update/v1/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "1.0" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/data/test_update/v2/addon_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v2/addon_app/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v2/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "2.0", 5 | "description": "Bump version.", 6 | } 7 | -------------------------------------------------------------------------------- /tests/data/test_update/v2/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "2.0" 5 | } 6 | } -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/addon_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v3.1/addon_app/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "3.1", 5 | "depends": ["addon_d1"], 6 | "description": "Add two dependencie.", 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/addon_d1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v3.1/addon_d1/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/addon_d1/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d1", "version": "3.1"} 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/addon_d2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v3.1/addon_d2/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/addon_d2/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_d2", 3 | "version": "3.0", 4 | "installable": False, 5 | "data": ["doesnotexist.xml"], 6 | } 7 | -------------------------------------------------------------------------------- /tests/data/test_update/v3.1/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "3.1" 5 | }, 6 | "addon_d1": { 7 | "state": "installed", 8 | "version": "3.1" 9 | }, 10 | "addon_d2": { 11 | "state": "installed", 12 | "version": "3.0" 13 | } 14 | } -------------------------------------------------------------------------------- /tests/data/test_update/v3/addon_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v3/addon_app/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v3/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "3.0", 5 | "depends": ["addon_d1", "addon_d2"], 6 | "description": "Add two dependencie.", 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/test_update/v3/addon_d1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v3/addon_d1/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v3/addon_d1/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d1", "version": "3.0"} 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v3/addon_d2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v3/addon_d2/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v3/addon_d2/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d2", "version": "3.0"} 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v3/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "3.0" 5 | }, 6 | "addon_d1": { 7 | "state": "installed", 8 | "version": "3.0" 9 | }, 10 | "addon_d2": { 11 | "state": "installed", 12 | "version": "3.0" 13 | } 14 | } -------------------------------------------------------------------------------- /tests/data/test_update/v4/addon_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v4/addon_app/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v4/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "4.0", 5 | "depends": ["addon_d1"], 6 | "description": "Remove addon_d2 but do not uninstall it.", 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/test_update/v4/addon_d1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v4/addon_d1/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v4/addon_d1/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d1", "version": "4.0"} 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v4/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "4.0" 5 | }, 6 | "addon_d1": { 7 | "state": "installed", 8 | "version": "4.0" 9 | }, 10 | "addon_d2": { 11 | "state": "installed" 12 | } 13 | } -------------------------------------------------------------------------------- /tests/data/test_update/v5/addon_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v5/addon_app/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v5/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "5.0", 5 | "depends": ["addon_d1"], 6 | "description": "Uninstall addon_d2 with a migration.", 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/test_update/v5/addon_app/migrations/5.0/pre-migrate.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("UPDATE ir_module_module SET state='to remove' WHERE name='addon_d2'") 3 | -------------------------------------------------------------------------------- /tests/data/test_update/v5/addon_d1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v5/addon_d1/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v5/addon_d1/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d1", "version": "5.0"} 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v5/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "5.0" 5 | }, 6 | "addon_d1": { 7 | "state": "installed", 8 | "version": "5.0" 9 | }, 10 | "addon_d2": { 11 | "state": "uninstalled" 12 | } 13 | } -------------------------------------------------------------------------------- /tests/data/test_update/v6/addon_app/__init__.py: -------------------------------------------------------------------------------- 1 | from . import res_users -------------------------------------------------------------------------------- /tests/data/test_update/v6/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "6.0", 5 | "depends": ["addon_d1"], 6 | "description": "Add field to res_users.", 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/test_update/v6/addon_app/migrations/5.0/pre-migrate.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("UPDATE ir_module_module SET state='to remove' WHERE name='addon_d2'") 3 | 4 | -------------------------------------------------------------------------------- /tests/data/test_update/v6/addon_app/res_users.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields 2 | 3 | 4 | class ResUsers(models.Model): 5 | _inherit = "res.users" 6 | custom_field = fields.Char() 7 | -------------------------------------------------------------------------------- /tests/data/test_update/v6/addon_d1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v6/addon_d1/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v6/addon_d1/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d1", "version": "6.0"} 2 | -------------------------------------------------------------------------------- /tests/data/test_update/v6/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "addon_app": { 3 | "state": "installed", 4 | "version": "6.0" 5 | }, 6 | "addon_d1": { 7 | "state": "installed", 8 | "version": "6.0" 9 | }, 10 | "addon_d2": { 11 | "state": "uninstalled" 12 | } 13 | } -------------------------------------------------------------------------------- /tests/data/test_update/v7/addon_app/__init__.py: -------------------------------------------------------------------------------- 1 | from . import res_users -------------------------------------------------------------------------------- /tests/data/test_update/v7/addon_app/__openerp__.py: -------------------------------------------------------------------------------- 1 | { 2 | "name": "addon_app", 3 | "application": True, 4 | "version": "7.0", 5 | "depends": ["addon_d1"], 6 | "data": ["no_exists.xml"], 7 | "description": "Invalid date, test update fails.", 8 | } 9 | -------------------------------------------------------------------------------- /tests/data/test_update/v7/addon_app/migrations/5.0/pre-migrate.py: -------------------------------------------------------------------------------- 1 | def migrate(cr, version): 2 | cr.execute("UPDATE ir_module_module SET state='to remove' WHERE name='addon_d2'") 3 | 4 | -------------------------------------------------------------------------------- /tests/data/test_update/v7/addon_app/res_users.py: -------------------------------------------------------------------------------- 1 | from odoo import models, fields 2 | 3 | 4 | class ResUsers(models.Model): 5 | _inherit = "res.users" 6 | custom_field = fields.Char() 7 | -------------------------------------------------------------------------------- /tests/data/test_update/v7/addon_d1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acsone/click-odoo-contrib/41d26749aa583e4ccca1d3295aa90ebceb8cf51c/tests/data/test_update/v7/addon_d1/__init__.py -------------------------------------------------------------------------------- /tests/data/test_update/v7/addon_d1/__openerp__.py: -------------------------------------------------------------------------------- 1 | {"name": "addon_d1", "version": "7.0"} 2 | -------------------------------------------------------------------------------- /tests/scripts/install_odoo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import subprocess 4 | import sys 5 | 6 | odoo_branch = sys.argv[1] 7 | odoo_dir = sys.argv[2] 8 | 9 | 10 | def odoo_installed(): 11 | try: 12 | import odoo # noqa 13 | except ImportError: 14 | return False 15 | else: 16 | return True 17 | 18 | 19 | def odoo_cloned(): 20 | return os.path.isdir(os.path.join(odoo_dir, ".git")) 21 | 22 | 23 | def clone_odoo(): 24 | subprocess.check_call( 25 | [ 26 | "git", 27 | "clone", 28 | "--depth=1", 29 | "-b", 30 | odoo_branch, 31 | "https://github.com/odoo/odoo", 32 | odoo_dir, 33 | ] 34 | ) 35 | 36 | 37 | def install_odoo(): 38 | if odoo_branch in ["11.0", "12.0", "13.0"]: 39 | # setuptools 58 dropped support for 2to3, which is required 40 | # for dependencies of older Odoo versions 41 | subprocess.check_call( 42 | [ 43 | "pip", 44 | "install", 45 | "setuptools<58", 46 | ] 47 | ) 48 | subprocess.check_call( 49 | [ 50 | "pip", 51 | "install", 52 | "--no-binary", 53 | "psycopg2", 54 | "-r", 55 | "https://raw.githubusercontent.com/OCA/OCB/{}/requirements.txt".format( 56 | odoo_branch 57 | ), 58 | ] 59 | ) 60 | subprocess.check_call(["pip", "install", "-e", odoo_dir]) 61 | 62 | 63 | def main(): 64 | if not odoo_installed(): 65 | if not odoo_cloned(): 66 | clone_odoo() 67 | install_odoo() 68 | 69 | 70 | if __name__ == "__main__": 71 | main() 72 | -------------------------------------------------------------------------------- /tests/test_addon_hash.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV. 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). 3 | 4 | import os 5 | 6 | from click_odoo_contrib import _addon_hash 7 | from click_odoo_contrib.update import DEFAULT_EXCLUDE_PATTERNS 8 | 9 | sample_dir = os.path.join(os.path.dirname(__file__), "data", "test_addon_hash") 10 | 11 | 12 | def test_basic(): 13 | files = list( 14 | _addon_hash._walk( 15 | sample_dir, exclude_patterns=["*/__pycache__/*"], keep_langs=[] 16 | ) 17 | ) 18 | assert files == [ 19 | "README.rst", 20 | "data/f1.xml", 21 | "data/f2.xml", 22 | "i18n/en.po", 23 | "i18n/en_US.po", 24 | "i18n/fr.po", 25 | "i18n/fr_BE.po", 26 | "i18n/test.pot", 27 | "i18n_extra/en.po", 28 | "i18n_extra/fr.po", 29 | "i18n_extra/nl_NL.po", 30 | "models/stuff.py", 31 | "models/stuff.pyc", 32 | "models/stuff.pyo", 33 | "static/src/some.js", 34 | ] 35 | 36 | 37 | def test_exclude(): 38 | files = list( 39 | _addon_hash._walk( 40 | sample_dir, 41 | exclude_patterns=DEFAULT_EXCLUDE_PATTERNS.split(","), 42 | keep_langs=["fr_FR", "nl"], 43 | ) 44 | ) 45 | assert files == [ 46 | "README.rst", 47 | "data/f1.xml", 48 | "data/f2.xml", 49 | "i18n/fr.po", 50 | "i18n/fr_BE.po", 51 | "i18n_extra/fr.po", 52 | "i18n_extra/nl_NL.po", 53 | "models/stuff.py", 54 | ] 55 | 56 | 57 | def test2(): 58 | checksum = _addon_hash.addon_hash( 59 | sample_dir, 60 | exclude_patterns=["*.pyc", "*.pyo", "*.pot", "static/*"], 61 | keep_langs=["fr_FR", "nl"], 62 | ) 63 | assert checksum == "fecb89486c8a29d1f760cbd01c1950f6e8421b14" 64 | -------------------------------------------------------------------------------- /tests/test_backupdb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | import textwrap 9 | import zipfile 10 | from filecmp import dircmp 11 | 12 | import pytest 13 | from click.testing import CliRunner 14 | from click_odoo import odoo 15 | from click_odoo.compat import environment_manage # not a public function of click-odoo! 16 | 17 | from click_odoo_contrib._dbutils import db_exists 18 | from click_odoo_contrib.backupdb import main 19 | 20 | TEST_DBNAME = "click-odoo-contrib-testbackupdb" 21 | TEST_FILESTORE_FILE = "/dir1/f.txt" 22 | 23 | 24 | def _dropdb(dbname): 25 | subprocess.check_call(["dropdb", "--if-exists", dbname]) 26 | 27 | 28 | def _dropdb_odoo(dbname): 29 | _dropdb(dbname) 30 | filestore_dir = odoo.tools.config.filestore(dbname) 31 | if os.path.isdir(filestore_dir): 32 | shutil.rmtree(filestore_dir) 33 | 34 | 35 | def _createdb(dbname): 36 | subprocess.check_call(["createdb", dbname]) 37 | 38 | 39 | @pytest.fixture 40 | def filestore(): 41 | filestore_dir = odoo.tools.config.filestore(TEST_DBNAME) 42 | os.makedirs(filestore_dir) 43 | filstore_file = os.path.join(filestore_dir, *TEST_FILESTORE_FILE.split("/")) 44 | os.makedirs(os.path.dirname(filstore_file)) 45 | with open(filstore_file, "w") as f: 46 | f.write("Hello world") 47 | try: 48 | yield 49 | finally: 50 | shutil.rmtree(filestore_dir) 51 | 52 | 53 | @pytest.fixture 54 | def pgdb(): 55 | _createdb(TEST_DBNAME) 56 | try: 57 | yield TEST_DBNAME 58 | finally: 59 | _dropdb(TEST_DBNAME) 60 | 61 | 62 | @pytest.fixture 63 | def manifest(): 64 | _original_f = odoo.service.db.dump_db_manifest 65 | try: 66 | odoo.service.db.dump_db_manifest = lambda a: {"manifest": "backupdb"} 67 | yield 68 | finally: 69 | odoo.service.db.dump_db_manifest = _original_f 70 | 71 | 72 | def _check_backup(backup_dir, with_filestore=True): 73 | assert os.path.exists(os.path.join(backup_dir, "dump.sql")) or os.path.exists( 74 | os.path.join(backup_dir, "db.dump") 75 | ) 76 | filestore_path = os.path.join( 77 | backup_dir, "filestore", *TEST_FILESTORE_FILE.split("/")[1:] 78 | ) 79 | if with_filestore: 80 | assert os.path.exists(filestore_path) 81 | else: 82 | assert not os.path.exists(filestore_path) 83 | assert os.path.exists(os.path.join(backup_dir, "manifest.json")) 84 | 85 | 86 | def _compare_filestore(dbname1, dbname2): 87 | filestore_dir_1 = odoo.tools.config.filestore(dbname1) 88 | filestore_dir_2 = odoo.tools.config.filestore(dbname2) 89 | if not os.path.exists(filestore_dir_1): 90 | # Odoo 8 wihout filestore 91 | assert not os.path.exists(filestore_dir_2) 92 | return 93 | diff = dircmp(filestore_dir_1, filestore_dir_2) 94 | assert not diff.left_only 95 | assert not diff.right_only 96 | assert not diff.diff_files 97 | 98 | 99 | def tests_backupdb_zip(pgdb, filestore, tmp_path, manifest): 100 | zip_path = tmp_path.joinpath("test.zip") 101 | assert not zip_path.exists() 102 | zip_filename = zip_path.as_posix() 103 | result = CliRunner().invoke(main, ["--format=zip", TEST_DBNAME, zip_filename]) 104 | assert result.exit_code == 0 105 | assert zip_path.exists() 106 | extract_dir = tmp_path.joinpath("extract_dir").as_posix() 107 | with zipfile.ZipFile(zip_filename) as zfile: 108 | zfile.extractall(extract_dir) 109 | _check_backup(extract_dir) 110 | 111 | 112 | def tests_backupdb_zip_no_filestore(pgdb, filestore, tmp_path, manifest): 113 | zip_path = tmp_path.joinpath("test.zip") 114 | assert not zip_path.exists() 115 | zip_filename = zip_path.as_posix() 116 | result = CliRunner().invoke( 117 | main, ["--format=zip", "--no-filestore", TEST_DBNAME, zip_filename] 118 | ) 119 | assert result.exit_code == 0 120 | assert zip_path.exists() 121 | extract_dir = tmp_path.joinpath("extract_dir").as_posix() 122 | with zipfile.ZipFile(zip_filename) as zfile: 123 | zfile.extractall(extract_dir) 124 | _check_backup(extract_dir, with_filestore=False) 125 | 126 | 127 | def tests_backupdb_folder(pgdb, filestore, tmp_path, manifest): 128 | backup_path = tmp_path.joinpath("backup2") 129 | assert not backup_path.exists() 130 | backup_dir = backup_path.as_posix() 131 | result = CliRunner().invoke(main, ["--format=folder", TEST_DBNAME, backup_dir]) 132 | assert result.exit_code == 0 133 | assert backup_path.exists() 134 | _check_backup(backup_dir) 135 | 136 | 137 | def tests_backupdb_folder_no_filestore(pgdb, filestore, tmp_path, manifest): 138 | backup_path = tmp_path.joinpath("backup2") 139 | assert not backup_path.exists() 140 | backup_dir = backup_path.as_posix() 141 | result = CliRunner().invoke( 142 | main, ["--format=folder", "--no-filestore", TEST_DBNAME, backup_dir] 143 | ) 144 | assert result.exit_code == 0 145 | assert backup_path.exists() 146 | _check_backup(backup_dir, with_filestore=False) 147 | 148 | 149 | def tests_backupdb_no_list_db(odoodb, filestore, tmp_path): 150 | """backupdb should work even if list_db is set to False into odoo.cfg""" 151 | zip_path = tmp_path.joinpath("test.zip") 152 | assert not zip_path.exists() 153 | zip_filename = zip_path.as_posix() 154 | odoo_cfg = tmp_path / "odoo.cfg" 155 | odoo_cfg.write_text( 156 | textwrap.dedent( 157 | """\ 158 | [options] 159 | list_db = False 160 | """ 161 | ) 162 | ) 163 | cmd = [ 164 | sys.executable, 165 | "-m", 166 | "click_odoo_contrib.backupdb", 167 | "-c", 168 | str(odoo_cfg), 169 | odoodb, 170 | zip_filename, 171 | ] 172 | subprocess.check_call(cmd) 173 | assert zip_path.exists() 174 | 175 | 176 | def tests_backupdb_not_exists(): 177 | assert not db_exists(TEST_DBNAME) 178 | result = CliRunner().invoke(main, [TEST_DBNAME, "out"]) 179 | assert result.exit_code != 0 180 | assert "Database does not exist" in result.output 181 | result = CliRunner().invoke(main, ["--if-exists", TEST_DBNAME, "out"]) 182 | assert result.exit_code == 0 183 | assert "Database does not exist" in result.output 184 | 185 | 186 | def tests_backupdb_force_folder(pgdb, filestore, tmp_path, manifest): 187 | backup_dir = tmp_path.as_posix() 188 | result = CliRunner().invoke(main, ["--format=folder", TEST_DBNAME, backup_dir]) 189 | assert result.exit_code != 0 190 | assert "Destination already exist" in result.output 191 | result = CliRunner().invoke( 192 | main, ["--format=folder", "--force", TEST_DBNAME, backup_dir] 193 | ) 194 | assert result.exit_code == 0 195 | assert "Destination already exist" in result.output 196 | 197 | 198 | def tests_backupdb_force_zip(pgdb, filestore, tmp_path, manifest): 199 | zip_path = tmp_path.joinpath("test.zip") 200 | zip_path.write_text("empty") 201 | zip_filename = zip_path.as_posix() 202 | result = CliRunner().invoke(main, ["--format=zip", TEST_DBNAME, zip_filename]) 203 | assert result.exit_code != 0 204 | assert "Destination already exist" in result.output 205 | result = CliRunner().invoke( 206 | main, ["--format=zip", "--force", TEST_DBNAME, zip_filename] 207 | ) 208 | assert result.exit_code == 0 209 | assert "Destination already exist" in result.output 210 | 211 | 212 | def tests_backupdb_zip_restore(odoodb, odoocfg, tmp_path): 213 | """Test zip backup compatibility with native Odoo restore API""" 214 | zip_path = tmp_path.joinpath("test.zip") 215 | zip_filename = zip_path.as_posix() 216 | result = CliRunner().invoke(main, ["--format=zip", odoodb, zip_filename]) 217 | assert result.exit_code == 0 218 | assert zip_path.exists() 219 | try: 220 | assert not db_exists(TEST_DBNAME) 221 | with environment_manage(): 222 | odoo.service.db.restore_db(TEST_DBNAME, zip_filename, copy=True) 223 | odoo.sql_db.close_db(TEST_DBNAME) 224 | assert db_exists(TEST_DBNAME) 225 | _compare_filestore(odoodb, TEST_DBNAME) 226 | finally: 227 | _dropdb_odoo(TEST_DBNAME) 228 | 229 | 230 | def tests_backupdb_folder_restore(odoodb, odoocfg, tmp_path): 231 | """Test the compatibility of the db dump file of a folder backup 232 | with native Odoo restore API 233 | """ 234 | backup_path = tmp_path.joinpath("backup") 235 | assert not backup_path.exists() 236 | backup_dir = backup_path.as_posix() 237 | result = CliRunner().invoke(main, ["--format=folder", odoodb, backup_dir]) 238 | assert result.exit_code == 0 239 | assert backup_path.exists() 240 | try: 241 | assert not db_exists(TEST_DBNAME) 242 | dumpfile = os.path.join(backup_dir, "db.dump") 243 | with environment_manage(): 244 | odoo.service.db.restore_db(TEST_DBNAME, dumpfile, copy=True) 245 | odoo.sql_db.close_db(TEST_DBNAME) 246 | assert db_exists(TEST_DBNAME) 247 | finally: 248 | _dropdb_odoo(TEST_DBNAME) 249 | 250 | 251 | @pytest.mark.parametrize("no_filestore", [True, False]) 252 | def tests_backupdb_dump(pgdb, tmp_path, no_filestore): 253 | dump_path = tmp_path.joinpath("test.dump") 254 | assert not dump_path.exists() 255 | dump_filename = dump_path.as_posix() 256 | args = ["--format=dump", pgdb, dump_filename] 257 | if no_filestore: 258 | args.insert(0, "--no-filestore") 259 | result = CliRunner().invoke(main, args) 260 | assert result.exit_code == 0 261 | assert dump_path.exists() 262 | 263 | 264 | def tests_backupdb_force_dump(pgdb, tmp_path): 265 | dump_path = tmp_path.joinpath("test.dump") 266 | dump_path.write_text("empty") 267 | dump_filename = dump_path.as_posix() 268 | result = CliRunner().invoke(main, ["--format=dump", pgdb, dump_filename]) 269 | assert result.exit_code != 0 270 | assert "Destination already exist" in result.output 271 | result = CliRunner().invoke(main, ["--format=dump", "--force", pgdb, dump_filename]) 272 | assert result.exit_code == 0 273 | assert "Destination already exist" in result.output 274 | 275 | 276 | def tests_backupdb_dump_restore(odoodb, odoocfg, tmp_path): 277 | """Test dump backup compatibility with native Odoo restore API""" 278 | dump_path = tmp_path.joinpath("test.dump") 279 | dump_filename = dump_path.as_posix() 280 | result = CliRunner().invoke(main, ["--format=dump", odoodb, dump_filename]) 281 | assert result.exit_code == 0 282 | assert dump_path.exists() 283 | try: 284 | assert not db_exists(TEST_DBNAME) 285 | with environment_manage(): 286 | odoo.service.db.restore_db(TEST_DBNAME, dump_filename, copy=True) 287 | odoo.sql_db.close_db(TEST_DBNAME) 288 | assert db_exists(TEST_DBNAME) 289 | finally: 290 | _dropdb_odoo(TEST_DBNAME) 291 | -------------------------------------------------------------------------------- /tests/test_copydb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | from collections import defaultdict 8 | 9 | import pytest 10 | from click.testing import CliRunner 11 | from click_odoo import OdooEnvironment, odoo 12 | 13 | from click_odoo_contrib._dbutils import db_exists 14 | from click_odoo_contrib.copydb import main 15 | 16 | TEST_DBNAME = "click-odoo-contrib-testcopydb" 17 | TEST_DBNAME_NEW = "click-odoo-contrib-testcopydb-new" 18 | 19 | ENTERPRISE_IR_CONGIG_KEYS = [ 20 | "database.enterprise_code", 21 | "database.expiration_reason", 22 | "database.expiration_date", 23 | ] 24 | 25 | 26 | def _dropdb(dbname): 27 | subprocess.check_call(["dropdb", "--if-exists", dbname]) 28 | 29 | 30 | def _createdb(dbname): 31 | subprocess.check_call(["createdb", dbname]) 32 | 33 | 34 | def _get_reset_config_params_key(): 35 | major = odoo.release.version_info[0] 36 | default = ["database.uuid", "database.create_date", "web.base.url"] 37 | default = default + ENTERPRISE_IR_CONGIG_KEYS 38 | if major >= 9: 39 | default.append("database.secret") 40 | if major >= 12: 41 | default.extend(["base.login_cooldown_after", "base.login_cooldown_duration"]) 42 | return default 43 | 44 | 45 | def _assert_ir_config_reset(db1, db2): 46 | params_by_db = defaultdict(dict) 47 | for db in (db1, db2): 48 | with OdooEnvironment(database=db) as env: 49 | IrConfigParameters = env["ir.config_parameter"] 50 | for key in _get_reset_config_params_key(): 51 | params_by_db[db][key] = IrConfigParameters.get_param(key) 52 | params1 = params_by_db[db1] 53 | params2 = params_by_db[db2] 54 | assert set(params1.keys()) == set(params2.keys()) 55 | for k, v in params1.items(): 56 | assert v != params2[k] 57 | 58 | 59 | def _has_rsync(): 60 | try: 61 | subprocess.check_call(["which", "rsync"]) 62 | return True 63 | except subprocess.CalledProcessError as e: 64 | print(e) 65 | return False 66 | 67 | 68 | @pytest.fixture 69 | def filestore(): 70 | filestore_dir = odoo.tools.config.filestore(TEST_DBNAME) 71 | os.makedirs(filestore_dir) 72 | try: 73 | yield 74 | finally: 75 | shutil.rmtree(filestore_dir) 76 | 77 | 78 | @pytest.fixture 79 | def pgdb(): 80 | subprocess.check_call(["createdb", TEST_DBNAME]) 81 | try: 82 | yield TEST_DBNAME 83 | finally: 84 | _dropdb(TEST_DBNAME) 85 | 86 | 87 | @pytest.fixture 88 | def ir_config_param_test_values(odoodb): 89 | with OdooEnvironment(database=odoodb) as env: 90 | for key in _get_reset_config_params_key(): 91 | env.cr.execute( 92 | """ 93 | UPDATE 94 | ir_config_parameter 95 | SET 96 | value='test value' 97 | WHERE 98 | key=%s 99 | RETURNING id 100 | """, 101 | (key,), 102 | ) 103 | if not env.cr.fetchall(): 104 | # Config parameter doesn't exist: create (ex enterprise params) 105 | env.cr.execute( 106 | """ 107 | INSERT INTO 108 | ir_config_parameter 109 | (key, value) 110 | VALUES 111 | (%s, 'test value') 112 | """, 113 | (key,), 114 | ) 115 | 116 | 117 | def tests_copydb(odoodb, ir_config_param_test_values): 118 | filestore_dir_new = odoo.tools.config.filestore(TEST_DBNAME_NEW) 119 | filestore_dir_original = odoo.tools.config.filestore(odoodb) 120 | if not os.path.exists(filestore_dir_original): 121 | os.makedirs(filestore_dir_original) 122 | try: 123 | assert not db_exists(TEST_DBNAME_NEW) 124 | assert not os.path.exists(filestore_dir_new) 125 | result = CliRunner().invoke( 126 | main, ["--force-disconnect", odoodb, TEST_DBNAME_NEW] 127 | ) 128 | assert result.exit_code == 0 129 | # this assert will indirectly test that the new db exists 130 | _assert_ir_config_reset(odoodb, TEST_DBNAME_NEW) 131 | assert os.path.isdir(filestore_dir_new) 132 | finally: 133 | _dropdb(TEST_DBNAME_NEW) 134 | if os.path.isdir(filestore_dir_new): 135 | shutil.rmtree(filestore_dir_new) 136 | 137 | 138 | def tests_copydb_template_absent(): 139 | assert not db_exists(TEST_DBNAME) 140 | assert not db_exists(TEST_DBNAME_NEW) 141 | result = CliRunner().invoke(main, [TEST_DBNAME, TEST_DBNAME_NEW]) 142 | assert result.exit_code != 0 143 | assert "Source database does not exist" in result.output 144 | result = CliRunner().invoke( 145 | main, ["--if-source-exists", TEST_DBNAME, TEST_DBNAME_NEW] 146 | ) 147 | assert result.exit_code == 0 148 | assert "Source database does not exist" in result.output 149 | 150 | 151 | def test_copydb_target_exists(pgdb): 152 | _createdb(TEST_DBNAME_NEW) 153 | try: 154 | assert db_exists(TEST_DBNAME) 155 | assert db_exists(TEST_DBNAME_NEW) 156 | result = CliRunner().invoke(main, [TEST_DBNAME, TEST_DBNAME_NEW]) 157 | assert result.exit_code != 0 158 | assert "Destination database already exists" in result.output 159 | result = CliRunner().invoke( 160 | main, ["--unless-dest-exists", TEST_DBNAME, TEST_DBNAME_NEW] 161 | ) 162 | assert result.exit_code == 0 163 | assert "Destination database already exists" in result.output 164 | finally: 165 | _dropdb(TEST_DBNAME_NEW) 166 | 167 | 168 | def test_copydb_template_not_exists_target_exists(): 169 | _createdb(TEST_DBNAME_NEW) 170 | try: 171 | assert not db_exists(TEST_DBNAME) 172 | assert db_exists(TEST_DBNAME_NEW) 173 | result = CliRunner().invoke( 174 | main, 175 | [ 176 | "--if-source-exists", 177 | "--unless-dest-exists", 178 | TEST_DBNAME, 179 | TEST_DBNAME_NEW, 180 | ], 181 | ) 182 | assert result.exit_code == 0 183 | finally: 184 | _dropdb(TEST_DBNAME_NEW) 185 | 186 | 187 | def test_copydb_no_source_filestore(odoodb, ir_config_param_test_values): 188 | filestore_dir_new = odoo.tools.config.filestore(TEST_DBNAME_NEW) 189 | filestore_dir_original = odoo.tools.config.filestore(odoodb) 190 | if os.path.exists(filestore_dir_original): 191 | shutil.rmtree(filestore_dir_original) 192 | try: 193 | result = CliRunner().invoke( 194 | main, ["--force-disconnect", odoodb, TEST_DBNAME_NEW] 195 | ) 196 | assert result.exit_code == 0 197 | # this assert will indirectly test that the new db exists 198 | _assert_ir_config_reset(odoodb, TEST_DBNAME_NEW) 199 | assert not os.path.isdir(filestore_dir_new) 200 | finally: 201 | _dropdb(TEST_DBNAME_NEW) 202 | 203 | 204 | @pytest.mark.skipif(not _has_rsync(), reason="Cannot find `rsync` on test system") 205 | def test_copydb_rsync(odoodb): 206 | # given an db with an existing filestore directory 207 | filestore_dir_new = odoo.tools.config.filestore(TEST_DBNAME_NEW) 208 | filestore_dir_original = odoo.tools.config.filestore(odoodb) 209 | if not os.path.exists(filestore_dir_original): 210 | os.makedirs(filestore_dir_original) 211 | try: 212 | # when running copydb with mode rsync 213 | result = CliRunner().invoke( 214 | main, ["--filestore-copy-mode=rsync", odoodb, TEST_DBNAME_NEW] 215 | ) 216 | 217 | # then the sync should be successful 218 | assert result.exit_code == 0 219 | # and the new db should exist 220 | _assert_ir_config_reset(odoodb, TEST_DBNAME_NEW) 221 | # and the filestore directory for the copied db should have been created 222 | assert os.path.isdir(filestore_dir_new) 223 | finally: 224 | # cleanup: drop copied db and created filestore dir 225 | _dropdb(TEST_DBNAME_NEW) 226 | if os.path.isdir(filestore_dir_new): 227 | shutil.rmtree(filestore_dir_new) 228 | 229 | 230 | @pytest.mark.skipif(not _has_rsync(), reason="Cannot find `rsync` on test system") 231 | def test_copydb_rsync_preexisting_filestore_dir(odoodb): 232 | # given an db with an existing filestore directory 233 | filestore_dir_new = odoo.tools.config.filestore(TEST_DBNAME_NEW) 234 | filestore_dir_original = odoo.tools.config.filestore(odoodb) 235 | if not os.path.exists(filestore_dir_original): 236 | os.makedirs(filestore_dir_original) 237 | # and an existing target filestore 238 | if not os.path.exists(filestore_dir_new): 239 | os.makedirs(filestore_dir_new) 240 | try: 241 | # when running copydb with mode rsync 242 | result = CliRunner().invoke( 243 | main, ["--filestore-copy-mode=rsync", odoodb, TEST_DBNAME_NEW] 244 | ) 245 | 246 | # then the sync should be successful 247 | assert result.exit_code == 0 248 | # and the new db should exist 249 | _assert_ir_config_reset(odoodb, TEST_DBNAME_NEW) 250 | # and the filestore directory for the copied db should have been created 251 | assert os.path.isdir(filestore_dir_new) 252 | finally: 253 | # cleanup: drop copied db and created filestore dir 254 | _dropdb(TEST_DBNAME_NEW) 255 | if os.path.isdir(filestore_dir_new): 256 | shutil.rmtree(filestore_dir_new) 257 | 258 | 259 | @pytest.mark.skipif(not _has_rsync(), reason="Cannot find `rsync` on test system") 260 | def test_copydb_rsync_hardlinks(odoodb): 261 | # given an db with an existing filestore directory 262 | filestore_dir_new = odoo.tools.config.filestore(TEST_DBNAME_NEW) 263 | filestore_dir_original = odoo.tools.config.filestore(odoodb) 264 | if not os.path.exists(filestore_dir_original): 265 | os.makedirs(filestore_dir_original) 266 | try: 267 | # when running copydb with mode rsync 268 | result = CliRunner().invoke( 269 | main, ["--filestore-copy-mode=hardlink", odoodb, TEST_DBNAME_NEW] 270 | ) 271 | 272 | # then the sync should be successful 273 | assert result.exit_code == 0 274 | # and the new db should exist 275 | _assert_ir_config_reset(odoodb, TEST_DBNAME_NEW) 276 | # and the filestore directory for the copied db should have been created 277 | assert os.path.isdir(filestore_dir_new) 278 | finally: 279 | # cleanup: drop copied db and created filestore dir 280 | _dropdb(TEST_DBNAME_NEW) 281 | if os.path.isdir(filestore_dir_new): 282 | shutil.rmtree(filestore_dir_new) 283 | 284 | 285 | @pytest.mark.skipif(not _has_rsync(), reason="Cannot find `rsync` on test system") 286 | def test_copydb_rsync_error(odoodb): 287 | # given an db with an existing filestore directory 288 | filestore_dir_new = odoo.tools.config.filestore(TEST_DBNAME_NEW) 289 | filestore_dir_original = odoo.tools.config.filestore(odoodb) 290 | if not os.path.exists(filestore_dir_original): 291 | os.makedirs(filestore_dir_original) 292 | # and an existing target filestore 293 | if not os.path.exists(filestore_dir_new): 294 | os.makedirs(filestore_dir_new) 295 | # that cannot be read nor written 296 | os.chmod(filestore_dir_new, 0) 297 | 298 | try: 299 | # when running copydb with mode rsync 300 | result = CliRunner().invoke( 301 | main, ["--filestore-copy-mode=rsync", odoodb, TEST_DBNAME_NEW] 302 | ) 303 | 304 | # then the sync should be erroneous 305 | assert result.exit_code != 0 306 | # and an error should be given for the filestore 307 | assert "Error syncing filestore" in result.output 308 | finally: 309 | # cleanup: drop copied db and created filestore dir 310 | _dropdb(TEST_DBNAME_NEW) 311 | if os.path.isdir(filestore_dir_new): 312 | # make target dir writable again to be able to delete it 313 | os.chmod(filestore_dir_new, 0o700) 314 | shutil.rmtree(filestore_dir_new) 315 | -------------------------------------------------------------------------------- /tests/test_dropdb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | 9 | import pytest 10 | from click_odoo import odoo 11 | 12 | from click_odoo_contrib._dbutils import db_exists 13 | 14 | TEST_DBNAME = "click-odoo-contrib-testdropdb" 15 | 16 | 17 | def _dropdb(dbname): 18 | subprocess.check_call(["dropdb", "--if-exists", dbname]) 19 | 20 | 21 | @pytest.fixture 22 | def filestore(): 23 | filestore_dir = odoo.tools.config.filestore(TEST_DBNAME) 24 | os.makedirs(filestore_dir) 25 | try: 26 | yield 27 | finally: 28 | if os.path.exists(filestore_dir): 29 | shutil.rmtree(filestore_dir) 30 | 31 | 32 | @pytest.fixture 33 | def pgdb(): 34 | subprocess.check_call(["createdb", TEST_DBNAME]) 35 | try: 36 | yield TEST_DBNAME 37 | finally: 38 | _dropdb(TEST_DBNAME) 39 | 40 | 41 | def test_dropdb_exists(pgdb, filestore): 42 | filestore_dir = odoo.tools.config.filestore(TEST_DBNAME) 43 | # sanity check for fixture 44 | assert os.path.isdir(filestore_dir) 45 | assert db_exists(TEST_DBNAME) 46 | # drop 47 | subprocess.check_call( 48 | [ 49 | sys.executable, 50 | "-m", 51 | "click_odoo_contrib.dropdb", 52 | "--if-exists", 53 | "--log-level=debug_sql", 54 | TEST_DBNAME, 55 | ], 56 | universal_newlines=True, 57 | ) 58 | assert not os.path.exists(filestore_dir), filestore_dir 59 | assert not db_exists(TEST_DBNAME) 60 | 61 | 62 | def test_dropdb_not_exists(): 63 | assert not db_exists(TEST_DBNAME) 64 | with pytest.raises(subprocess.CalledProcessError) as e: 65 | subprocess.check_output( 66 | [ 67 | sys.executable, 68 | "-m", 69 | "click_odoo_contrib.dropdb", 70 | TEST_DBNAME, 71 | ], 72 | universal_newlines=True, 73 | stderr=subprocess.STDOUT, 74 | ) 75 | assert "Database does not exist" in e.value.output 76 | output = subprocess.check_output( 77 | [ 78 | sys.executable, 79 | "-m", 80 | "click_odoo_contrib.dropdb", 81 | "--if-exists", 82 | TEST_DBNAME, 83 | ], 84 | universal_newlines=True, 85 | stderr=subprocess.STDOUT, 86 | ) 87 | assert "Database does not exist" in output 88 | -------------------------------------------------------------------------------- /tests/test_gitutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import subprocess 6 | 7 | import pytest 8 | 9 | from click_odoo_contrib.gitutils import commit_if_needed 10 | 11 | 12 | @pytest.fixture 13 | def gitdir(tmpdir): 14 | subprocess.check_call(["git", "init"], cwd=str(tmpdir)) 15 | subprocess.check_call(["git", "config", "user.name", "tester"], cwd=str(tmpdir)) 16 | subprocess.check_call( 17 | ["git", "config", "user.email", "tester@example.com"], cwd=str(tmpdir) 18 | ) 19 | yield tmpdir 20 | 21 | 22 | def _git_ls_files(cwd): 23 | output = subprocess.check_output( 24 | ["git", "ls-files"], cwd=str(cwd), universal_newlines=True 25 | ) 26 | return output.strip().split("\n") 27 | 28 | 29 | def _git_staged_files(cwd): 30 | output = subprocess.check_output( 31 | ["git", "diff", "--cached", "--name-only"], 32 | cwd=str(cwd), 33 | universal_newlines=True, 34 | ) 35 | return output.strip().split("\n") 36 | 37 | 38 | def _git_add(paths, cwd): 39 | cmd = ["git", "add", "--"] + paths 40 | subprocess.check_call(cmd, cwd=str(cwd)) 41 | 42 | 43 | def test_git_commit_if_needed(gitdir): 44 | assert "file1" not in _git_ls_files(gitdir) 45 | file1 = gitdir / "file1" 46 | file1.ensure(file=True) 47 | assert commit_if_needed([str(file1)], "msg", cwd=str(gitdir)) 48 | assert "file1" in _git_ls_files(gitdir) 49 | # no change, commit_if_needed returns False 50 | assert not commit_if_needed([str(file1)], "msg", cwd=str(gitdir)) 51 | # some change 52 | file1.write("stuff") 53 | assert commit_if_needed([str(file1)], "msg", cwd=str(gitdir)) 54 | # some unrelated file not in git 55 | file2 = gitdir / "file2" 56 | file2.ensure(file=True) 57 | assert not commit_if_needed([str(file1)], "msg", cwd=str(gitdir)) 58 | assert "file2" not in _git_ls_files(gitdir) 59 | # some unrelated file in git index 60 | _git_add([str(file2)], gitdir) 61 | assert not commit_if_needed([str(file1)], "msg", cwd=str(gitdir)) 62 | assert "file1" not in _git_staged_files(gitdir) 63 | assert "file2" in _git_staged_files(gitdir) 64 | # add subdirectory 65 | dir1 = gitdir / "dir1" 66 | dir1.ensure(dir=True) 67 | file3 = dir1 / "file3" 68 | file3.ensure(file=True) 69 | assert commit_if_needed([str(file3)], "msg", cwd=str(gitdir)) 70 | assert "dir1/file3" in _git_ls_files(gitdir) 71 | 72 | 73 | def test_commit_git_ignored(gitdir): 74 | file1 = gitdir / "file1.pot" 75 | file1.ensure(file=True) 76 | gitignore = gitdir / ".gitignore" 77 | gitignore.write("*.pot\n") 78 | assert commit_if_needed([str(file1)], "msg", cwd=str(gitdir)) 79 | assert "file1.pot" in _git_ls_files(gitdir) 80 | 81 | 82 | def test_commit_reldir(gitdir): 83 | with gitdir.as_cwd(): 84 | os.mkdir("subdir") 85 | file1 = "subdir/file1" 86 | with open(file1, "w"): 87 | pass 88 | assert commit_if_needed([file1], "msg", cwd="subdir") 89 | -------------------------------------------------------------------------------- /tests/test_initdb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import subprocess 6 | import sys 7 | import textwrap 8 | from datetime import datetime, timedelta 9 | from unittest import mock 10 | 11 | import click_odoo 12 | import pytest 13 | from click.testing import CliRunner 14 | 15 | from click_odoo_contrib import initdb 16 | from click_odoo_contrib._dbutils import pg_connect 17 | from click_odoo_contrib.initdb import DbCache, main 18 | 19 | TEST_DBNAME = "click-odoo-contrib-testinitdb" 20 | TEST_DBNAME_NEW = "click-odoo-contrib-testinitdb-new" 21 | TEST_PREFIX = "tstpfx9" 22 | TEST_HASH1 = "a" * DbCache.HASH_SIZE 23 | TEST_HASH2 = "b" * DbCache.HASH_SIZE 24 | TEST_HASH3 = "c" * DbCache.HASH_SIZE 25 | TODAY = datetime(2018, 5, 10) 26 | TODAY_MINUS_2 = datetime(2018, 5, 8) 27 | TODAY_MINUS_4 = datetime(2018, 5, 6) 28 | ADDONS_PATH = ",".join( 29 | [ 30 | os.path.join(click_odoo.odoo.__path__[0], "addons"), 31 | os.path.join(click_odoo.odoo.__path__[0], "..", "addons"), 32 | os.path.join(os.path.dirname(__file__), "data", "test_initdb"), 33 | ] 34 | ) 35 | 36 | 37 | def _dropdb(dbname): 38 | subprocess.check_call(["dropdb", "--if-exists", dbname]) 39 | 40 | 41 | @pytest.fixture 42 | def pgdb(): 43 | subprocess.check_call(["createdb", TEST_DBNAME]) 44 | try: 45 | yield TEST_DBNAME 46 | finally: 47 | _dropdb(TEST_DBNAME) 48 | 49 | 50 | @pytest.fixture 51 | def dbcache(): 52 | with pg_connect() as pgcr: 53 | c = DbCache(TEST_PREFIX, pgcr) 54 | try: 55 | yield c 56 | finally: 57 | c.purge() 58 | 59 | 60 | def test_dbcache_create(pgdb, dbcache): 61 | assert dbcache.size == 0 62 | assert not dbcache.create(TEST_DBNAME_NEW, TEST_HASH1) 63 | with mock.patch.object(initdb, "datetime") as mock_dt: 64 | # create a few db with known dates 65 | mock_dt.utcnow.return_value = TODAY_MINUS_4 66 | dbcache.add(pgdb, TEST_HASH1) 67 | assert dbcache.size == 1 68 | mock_dt.utcnow.return_value = TODAY 69 | assert dbcache.create(TEST_DBNAME_NEW, TEST_HASH1) 70 | try: 71 | assert dbcache.size == 1 72 | # ensure the cached template has been "touched" 73 | dbcache.trim_age(timedelta(days=3)) 74 | assert dbcache.size == 1 75 | finally: 76 | _dropdb(TEST_DBNAME_NEW) 77 | # test recreate (same day) 78 | assert dbcache.create(TEST_DBNAME_NEW, TEST_HASH1) 79 | _dropdb(TEST_DBNAME_NEW) 80 | 81 | 82 | def test_dbcache_purge(pgdb, dbcache): 83 | assert dbcache.size == 0 84 | dbcache.add(pgdb, TEST_HASH1) 85 | assert dbcache.size == 1 86 | dbcache.purge() 87 | assert dbcache.size == 0 88 | 89 | 90 | def test_dbcache_trim_size(pgdb, dbcache): 91 | assert dbcache.size == 0 92 | dbcache.add(pgdb, TEST_HASH1) 93 | assert dbcache.size == 1 94 | dbcache.add(pgdb, TEST_HASH2) 95 | assert dbcache.size == 2 96 | dbcache.add(pgdb, TEST_HASH3) 97 | assert dbcache.size == 3 98 | dbcache.trim_size(max_size=2) 99 | assert dbcache.size == 2 100 | result = CliRunner().invoke( 101 | main, 102 | [ 103 | "--cache-prefix", 104 | TEST_PREFIX, 105 | "--cache-max-size", 106 | "-1", 107 | "--cache-max-age", 108 | "-1", 109 | ], 110 | ) 111 | assert result.exit_code == 0 112 | assert dbcache.size == 2 113 | result = CliRunner().invoke( 114 | main, 115 | [ 116 | "--cache-prefix", 117 | TEST_PREFIX, 118 | "--cache-max-size", 119 | "1", 120 | "--cache-max-age", 121 | "-1", 122 | ], 123 | ) 124 | assert result.exit_code == 0 125 | assert dbcache.size == 1 126 | result = CliRunner().invoke( 127 | main, 128 | [ 129 | "--cache-prefix", 130 | TEST_PREFIX, 131 | "--cache-max-size", 132 | "0", 133 | "--cache-max-age", 134 | "-1", 135 | ], 136 | ) 137 | assert result.exit_code == 0 138 | assert dbcache.size == 0 139 | 140 | 141 | def test_dbcache_trim_age(pgdb, dbcache): 142 | assert dbcache.size == 0 143 | with mock.patch.object(initdb, "datetime") as mock_dt: 144 | # create a few db with known dates 145 | mock_dt.utcnow.return_value = TODAY 146 | dbcache.add(pgdb, TEST_HASH1) 147 | assert dbcache.size == 1 148 | mock_dt.utcnow.return_value = TODAY_MINUS_2 149 | dbcache.add(pgdb, TEST_HASH2) 150 | assert dbcache.size == 2 151 | mock_dt.utcnow.return_value = TODAY_MINUS_4 152 | dbcache.add(pgdb, TEST_HASH3) 153 | assert dbcache.size == 3 154 | # get back to TODAY 155 | mock_dt.utcnow.return_value = TODAY 156 | # trim older than 5 days: no change 157 | dbcache.trim_age(timedelta(days=5)) 158 | assert dbcache.size == 3 159 | # trim older than 3 days: drop one 160 | dbcache.trim_age(timedelta(days=3)) 161 | assert dbcache.size == 2 162 | # do nothing 163 | result = CliRunner().invoke( 164 | main, 165 | [ 166 | "--cache-prefix", 167 | TEST_PREFIX, 168 | "--cache-max-size", 169 | "-1", 170 | "--cache-max-age", 171 | "-1", 172 | ], 173 | ) 174 | assert result.exit_code == 0 175 | assert dbcache.size == 2 176 | # drop older than 1 day, drop one 177 | result = CliRunner().invoke( 178 | main, 179 | [ 180 | "--cache-prefix", 181 | TEST_PREFIX, 182 | "--cache-max-size", 183 | "-1", 184 | "--cache-max-age", 185 | "1", 186 | ], 187 | ) 188 | assert result.exit_code == 0 189 | assert dbcache.size == 1 190 | # drop today too, drop everything 191 | result = CliRunner().invoke( 192 | main, 193 | [ 194 | "--cache-prefix", 195 | TEST_PREFIX, 196 | "--cache-max-size", 197 | "-1", 198 | "--cache-max-age", 199 | "0", 200 | ], 201 | ) 202 | assert result.exit_code == 0 203 | assert dbcache.size == 0 204 | 205 | 206 | def test_create_cmd_cache(dbcache, tmpdir): 207 | assert dbcache.size == 0 208 | try: 209 | result = CliRunner().invoke( 210 | main, 211 | ["--cache-prefix", TEST_PREFIX, "-n", TEST_DBNAME_NEW, "-m", "auth_signup"], 212 | ) 213 | assert result.exit_code == 0 214 | assert dbcache.size == 1 215 | with click_odoo.OdooEnvironment(database=TEST_DBNAME_NEW) as env: 216 | m = env["ir.module.module"].search( 217 | [("name", "=", "auth_signup"), ("state", "=", "installed")] 218 | ) 219 | assert m, "auth_signup module not installed" 220 | env.cr.execute( 221 | """ 222 | SELECT COUNT(*) FROM ir_attachment 223 | WHERE store_fname IS NOT NULL 224 | """ 225 | ) 226 | assert env.cr.fetchone()[0] == 0, "some attachments are not stored in db" 227 | finally: 228 | _dropdb(TEST_DBNAME_NEW) 229 | # try again, from cache this time 230 | with mock.patch.object(initdb, "odoo_createdb") as m: 231 | try: 232 | result = CliRunner().invoke( 233 | main, 234 | [ 235 | "--cache-prefix", 236 | TEST_PREFIX, 237 | "--new-database", 238 | TEST_DBNAME_NEW, 239 | "--modules", 240 | "auth_signup", 241 | ], 242 | ) 243 | assert result.exit_code == 0 244 | assert m.call_count == 0 245 | assert dbcache.size == 1 246 | finally: 247 | _dropdb(TEST_DBNAME_NEW) 248 | # now run again, with a new addon in path 249 | # and make sure the list of modules has been updated 250 | # after creating the database from cache 251 | odoo_cfg = tmpdir / "odoo.cfg" 252 | odoo_cfg.write( 253 | textwrap.dedent( 254 | f"""\ 255 | [options] 256 | addons_path = {ADDONS_PATH} 257 | """ 258 | ) 259 | ) 260 | cmd = [ 261 | sys.executable, 262 | "-m", 263 | "click_odoo_contrib.initdb", 264 | "-c", 265 | str(odoo_cfg), 266 | "--cache-prefix", 267 | TEST_PREFIX, 268 | "--new-database", 269 | TEST_DBNAME_NEW, 270 | "--modules", 271 | "auth_signup", 272 | ] 273 | try: 274 | subprocess.check_call(cmd) 275 | with click_odoo.OdooEnvironment(database=TEST_DBNAME_NEW) as env: 276 | assert env["ir.module.module"].search( 277 | [("name", "=", "addon1")] 278 | ), "module addon1 not present in new database" 279 | finally: 280 | _dropdb(TEST_DBNAME_NEW) 281 | 282 | 283 | def test_create_cmd_nocache(dbcache): 284 | assert dbcache.size == 0 285 | try: 286 | result = CliRunner().invoke( 287 | main, ["--no-cache", "-n", TEST_DBNAME_NEW, "-m", "auth_signup"] 288 | ) 289 | assert result.exit_code == 0 290 | assert dbcache.size == 0 291 | with click_odoo.OdooEnvironment(database=TEST_DBNAME_NEW) as env: 292 | m = env["ir.module.module"].search( 293 | [("name", "=", "auth_signup"), ("state", "=", "installed")] 294 | ) 295 | assert m, "auth_signup module not installed" 296 | finally: 297 | _dropdb(TEST_DBNAME_NEW) 298 | 299 | 300 | def test_dbcache_add_concurrency(pgdb, dbcache): 301 | assert dbcache.size == 0 302 | dbcache.add(pgdb, TEST_HASH1) 303 | assert dbcache.size == 1 304 | dbcache.add(pgdb, TEST_HASH1) 305 | assert dbcache.size == 1 306 | 307 | 308 | def test_unless_exists_exists(pgdb): 309 | result = CliRunner().invoke(main, ["--unless-exists", "-n", TEST_DBNAME]) 310 | assert result.exit_code == 0 311 | assert "Database already exists" in result.output 312 | result = CliRunner().invoke(main, ["-n", TEST_DBNAME]) 313 | assert result.exit_code != 0 314 | assert "already exists" in result.output 315 | -------------------------------------------------------------------------------- /tests/test_listdb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Moduon (https://www.moduon.team/) 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | import subprocess 4 | 5 | from click.testing import CliRunner 6 | 7 | from click_odoo_contrib.listdb import main 8 | 9 | 10 | def test_listdb(odoodb): 11 | """Test that it only lists odoo-ready databases.""" 12 | try: 13 | subprocess.check_call(["createdb", f"{odoodb}-not-odoo"]) 14 | result = CliRunner().invoke(main) 15 | assert result.stdout.strip() == odoodb 16 | finally: 17 | subprocess.check_call(["dropdb", f"{odoodb}-not-odoo"]) 18 | -------------------------------------------------------------------------------- /tests/test_makepot.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | 8 | import click_odoo 9 | from click.testing import CliRunner 10 | 11 | from click_odoo_contrib.makepot import main 12 | 13 | # this extends the addons path of the odoodb and odoocfg fixtures 14 | test_addons_dir = os.path.join(os.path.dirname(__file__), "data", "test_makepot") 15 | 16 | 17 | def test_makepot_base(odoodb, odoocfg, tmpdir): 18 | addon_name = "addon_test_makepot" 19 | addon_path = os.path.join(test_addons_dir, addon_name) 20 | i18n_path = os.path.join(addon_path, "i18n") 21 | pot_filepath = os.path.join(i18n_path, addon_name + ".pot") 22 | 23 | subprocess.check_call( 24 | [ 25 | click_odoo.odoo_bin, 26 | "-d", 27 | odoodb, 28 | "-c", 29 | str(odoocfg), 30 | "-i", 31 | addon_name, 32 | "--stop-after-init", 33 | ] 34 | ) 35 | 36 | if os.path.exists(pot_filepath): 37 | os.remove(pot_filepath) 38 | 39 | result = CliRunner().invoke( 40 | main, ["-d", odoodb, "-c", str(odoocfg), "--addons-dir", test_addons_dir] 41 | ) 42 | 43 | assert result.exit_code == 0 44 | assert os.path.isdir(i18n_path) 45 | assert os.path.isfile(pot_filepath) 46 | with open(pot_filepath) as f: 47 | assert "myfield" in f.read() 48 | 49 | 50 | def test_makepot_modules(odoodb, odoocfg, tmpdir): 51 | subprocess.check_call( 52 | [ 53 | click_odoo.odoo_bin, 54 | "-d", 55 | odoodb, 56 | "-c", 57 | str(odoocfg), 58 | "-i", 59 | "addon_test_makepot,addon_test_makepot_2", 60 | "--stop-after-init", 61 | ] 62 | ) 63 | addon_name = "addon_test_makepot" 64 | addon_path = os.path.join(test_addons_dir, addon_name) 65 | i18n_path = os.path.join(addon_path, "i18n") 66 | pot_filepath = os.path.join(i18n_path, addon_name + ".pot") 67 | 68 | if os.path.exists(pot_filepath): 69 | os.remove(pot_filepath) 70 | 71 | addon_name_2 = "addon_test_makepot_2" 72 | addon_path_2 = os.path.join(test_addons_dir, addon_name_2) 73 | i18n_path_2 = os.path.join(addon_path_2, "i18n") 74 | pot_filepath_2 = os.path.join(i18n_path_2, addon_name_2 + ".pot") 75 | 76 | if os.path.exists(pot_filepath_2): 77 | os.remove(pot_filepath_2) 78 | 79 | result = CliRunner().invoke( 80 | main, 81 | [ 82 | "-d", 83 | odoodb, 84 | "-c", 85 | str(odoocfg), 86 | "--addons-dir", 87 | test_addons_dir, 88 | "--modules", 89 | "addon_test_makepot_2", 90 | ], 91 | ) 92 | assert result.exit_code == 0 93 | assert not os.path.isdir(pot_filepath) 94 | assert os.path.isdir(i18n_path_2) 95 | assert os.path.isfile(pot_filepath_2) 96 | with open(pot_filepath_2) as f: 97 | assert "myfield" in f.read() 98 | 99 | 100 | def test_makepot_absent_module(odoodb): 101 | result = CliRunner().invoke( 102 | main, 103 | [ 104 | "-d", 105 | odoodb, 106 | "--addons-dir", 107 | test_addons_dir, 108 | "--modules", 109 | "not_existing_module", 110 | ], 111 | ) 112 | assert result.exit_code != 0 113 | assert "Module(s) was not found" in result.stdout 114 | 115 | 116 | def test_makepot_msgmerge(odoodb, odoocfg, tmpdir): 117 | addon_name = "addon_test_makepot" 118 | addon_path = os.path.join(test_addons_dir, addon_name) 119 | i18n_path = os.path.join(addon_path, "i18n") 120 | pot_filepath = os.path.join(i18n_path, addon_name + ".pot") 121 | fr_filepath = os.path.join(i18n_path, "fr.po") 122 | 123 | subprocess.check_call( 124 | [ 125 | click_odoo.odoo_bin, 126 | "-d", 127 | odoodb, 128 | "-c", 129 | str(odoocfg), 130 | "-i", 131 | addon_name, 132 | "--stop-after-init", 133 | ] 134 | ) 135 | 136 | if not os.path.exists(i18n_path): 137 | os.makedirs(i18n_path) 138 | if os.path.exists(pot_filepath): 139 | os.remove(pot_filepath) 140 | # create empty fr.po, that will be updated by msgmerge 141 | with open(fr_filepath, "w"): 142 | pass 143 | assert os.path.getsize(fr_filepath) == 0 144 | 145 | result = CliRunner().invoke( 146 | main, 147 | [ 148 | "-d", 149 | odoodb, 150 | "-c", 151 | str(odoocfg), 152 | "--addons-dir", 153 | test_addons_dir, 154 | "--msgmerge", 155 | ], 156 | ) 157 | 158 | assert result.exit_code == 0 159 | assert os.path.getsize(fr_filepath) != 0 160 | with open(fr_filepath) as f: 161 | assert "myfield" in f.read() 162 | 163 | 164 | def test_makepot_msgmerge_no_fuzzy(odoodb, odoocfg, tmpdir): 165 | addon_name = "addon_test_makepot" 166 | addon_path = os.path.join(test_addons_dir, addon_name) 167 | i18n_path = os.path.join(addon_path, "i18n") 168 | pot_filepath = os.path.join(i18n_path, addon_name + ".pot") 169 | fr_filepath = os.path.join(i18n_path, "fr.po") 170 | 171 | # Copy .fuzzy to .po 172 | shutil.copyfile(os.path.join(i18n_path, "fr.po.fuzzy"), fr_filepath) 173 | 174 | subprocess.check_call( 175 | [ 176 | click_odoo.odoo_bin, 177 | "-d", 178 | odoodb, 179 | "-c", 180 | str(odoocfg), 181 | "-i", 182 | addon_name, 183 | "--stop-after-init", 184 | ] 185 | ) 186 | 187 | if not os.path.exists(i18n_path): 188 | os.makedirs(i18n_path) 189 | if os.path.exists(pot_filepath): 190 | os.remove(pot_filepath) 191 | # create empty fr.po, that will be updated by msgmerge 192 | with open(fr_filepath, "w"): 193 | pass 194 | assert os.path.getsize(fr_filepath) == 0 195 | 196 | result = CliRunner().invoke( 197 | main, 198 | [ 199 | "-d", 200 | odoodb, 201 | "-c", 202 | str(odoocfg), 203 | "--addons-dir", 204 | test_addons_dir, 205 | "--msgmerge", 206 | "--no-fuzzy-matching", 207 | ], 208 | ) 209 | 210 | assert result.exit_code == 0 211 | assert os.path.getsize(fr_filepath) != 0 212 | with open(fr_filepath) as f: 213 | assert "#, fuzzy" not in f.read() 214 | 215 | 216 | def test_makepot_msgmerge_purge_old(odoodb, odoocfg, tmpdir): 217 | addon_name = "addon_test_makepot" 218 | addon_path = os.path.join(test_addons_dir, addon_name) 219 | i18n_path = os.path.join(addon_path, "i18n") 220 | pot_filepath = os.path.join(i18n_path, addon_name + ".pot") 221 | fr_filepath = os.path.join(i18n_path, "fr.po") 222 | 223 | # Copy .old to .po 224 | shutil.copyfile(os.path.join(i18n_path, "fr.po.old"), fr_filepath) 225 | 226 | subprocess.check_call( 227 | [ 228 | click_odoo.odoo_bin, 229 | "-d", 230 | odoodb, 231 | "-c", 232 | str(odoocfg), 233 | "-i", 234 | addon_name, 235 | "--stop-after-init", 236 | ] 237 | ) 238 | 239 | if not os.path.exists(i18n_path): 240 | os.makedirs(i18n_path) 241 | if os.path.exists(pot_filepath): 242 | os.remove(pot_filepath) 243 | # create empty fr.po, that will be updated by msgmerge 244 | with open(fr_filepath, "w"): 245 | pass 246 | assert os.path.getsize(fr_filepath) == 0 247 | 248 | result = CliRunner().invoke( 249 | main, 250 | [ 251 | "-d", 252 | odoodb, 253 | "-c", 254 | str(odoocfg), 255 | "--addons-dir", 256 | test_addons_dir, 257 | "--msgmerge", 258 | "--purge-old-translations", 259 | ], 260 | ) 261 | 262 | assert result.exit_code == 0 263 | assert os.path.getsize(fr_filepath) != 0 264 | with open(fr_filepath) as f: 265 | assert "#~ msgid" not in f.read() 266 | 267 | 268 | def test_makepot_msgmerge_if_new_pot(odoodb, odoocfg, tmpdir): 269 | addon_name = "addon_test_makepot" 270 | addon_path = os.path.join(test_addons_dir, addon_name) 271 | i18n_path = os.path.join(addon_path, "i18n") 272 | pot_filepath = os.path.join(i18n_path, addon_name + ".pot") 273 | fr_filepath = os.path.join(i18n_path, "fr.po") 274 | 275 | subprocess.check_call( 276 | [ 277 | click_odoo.odoo_bin, 278 | "-d", 279 | odoodb, 280 | "-c", 281 | str(odoocfg), 282 | "-i", 283 | addon_name, 284 | "--stop-after-init", 285 | ] 286 | ) 287 | 288 | if not os.path.exists(i18n_path): 289 | os.makedirs(i18n_path) 290 | if os.path.exists(pot_filepath): 291 | os.remove(pot_filepath) 292 | # create empty .pot 293 | with open(pot_filepath, "w"): 294 | pass 295 | # create empty fr.po 296 | with open(fr_filepath, "w"): 297 | pass 298 | assert os.path.getsize(fr_filepath) == 0 299 | 300 | result = CliRunner().invoke( 301 | main, 302 | [ 303 | "-d", 304 | odoodb, 305 | "-c", 306 | str(odoocfg), 307 | "--addons-dir", 308 | test_addons_dir, 309 | "--msgmerge-if-new-pot", 310 | ], 311 | ) 312 | 313 | assert result.exit_code == 0 314 | # po file not changed because .pot did exist 315 | assert os.path.getsize(fr_filepath) == 0 316 | 317 | # now remove pot file so a new one will 318 | # be created and msgmerge will run 319 | os.remove(pot_filepath) 320 | 321 | result = CliRunner().invoke( 322 | main, 323 | [ 324 | "-d", 325 | odoodb, 326 | "-c", 327 | str(odoocfg), 328 | "--addons-dir", 329 | test_addons_dir, 330 | "--msgmerge-if-new-pot", 331 | ], 332 | ) 333 | 334 | with open(fr_filepath) as f: 335 | assert "myfield" in f.read() 336 | 337 | 338 | def test_makepot_detect_bad_po(odoodb, odoocfg, capfd): 339 | addon_name = "addon_test_makepot" 340 | addon_path = os.path.join(test_addons_dir, addon_name) 341 | i18n_path = os.path.join(addon_path, "i18n") 342 | fr_filepath = os.path.join(i18n_path, "fr.po") 343 | 344 | subprocess.check_call( 345 | [ 346 | click_odoo.odoo_bin, 347 | "-d", 348 | odoodb, 349 | "-c", 350 | str(odoocfg), 351 | "-i", 352 | addon_name, 353 | "--stop-after-init", 354 | ] 355 | ) 356 | 357 | shutil.copy(fr_filepath + ".bad", fr_filepath) 358 | 359 | capfd.readouterr() 360 | result = CliRunner().invoke( 361 | main, ["-d", odoodb, "-c", str(odoocfg), "--addons-dir", test_addons_dir] 362 | ) 363 | 364 | assert result.exit_code != 0 365 | capture = capfd.readouterr() 366 | assert "duplicate message definition" in capture.err 367 | assert "msgmerge: found 1 fatal error" in capture.err 368 | -------------------------------------------------------------------------------- /tests/test_manifest.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import os 5 | 6 | import pytest 7 | from click_odoo import odoo 8 | 9 | from click_odoo_contrib import manifest 10 | 11 | HERE = os.path.dirname(__file__) 12 | ADDONS_DIR = os.path.join(HERE, "data/test_manifest") 13 | 14 | 15 | def test_manifest_find_addons(): 16 | addons = list(manifest.find_addons(ADDONS_DIR)) 17 | assert len(addons) == 1 18 | assert addons[0][0] == "addon1" 19 | 20 | 21 | def test_manifest_find_addons_uninstallable(): 22 | addons = list(manifest.find_addons(ADDONS_DIR, installable_only=False)) 23 | assert len(addons) == 2 24 | assert addons[0][0] == "addon1" 25 | assert addons[1][0] == "addon_uninstallable" 26 | 27 | 28 | def test_manifest_expand_dependencies(): 29 | res = manifest.expand_dependencies(["auth_signup", "base_import"]) 30 | assert "auth_signup" in res 31 | assert "mail" in res # dependency of auth_signup 32 | assert "base_import" in res 33 | assert "base" in res # obviously 34 | assert "web" in res # base_import depends on web 35 | if odoo.release.version_info < (12, 0): 36 | assert "auth_crypt" not in res 37 | else: 38 | assert "iap" not in res # iap is auto_install 39 | 40 | 41 | def test_manifest_expand_dependencies_auto_install(): 42 | res = manifest.expand_dependencies(["auth_signup"], include_auto_install=True) 43 | assert "auth_signup" in res 44 | assert "base" in res # obviously 45 | if odoo.release.version_info < (12, 0): 46 | assert "auth_crypt" in res # auth_crypt is autoinstall 47 | else: 48 | assert "iap" in res # iap is auto_install 49 | assert "web" in res # web is autoinstall 50 | assert "base_import" in res # base_import is indirect autoinstall 51 | 52 | 53 | def test_manifest_expand_dependencies_not_found(): 54 | with pytest.raises(manifest.ModuleNotFound): 55 | manifest.expand_dependencies(["not_a_module"]) 56 | -------------------------------------------------------------------------------- /tests/test_restoredb.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import operator 5 | import os 6 | import shutil 7 | import subprocess 8 | from collections import defaultdict 9 | 10 | import click_odoo 11 | import pytest 12 | from click.testing import CliRunner 13 | from click_odoo import odoo 14 | 15 | from click_odoo_contrib._dbutils import db_exists 16 | from click_odoo_contrib.backupdb import main as backupdb 17 | from click_odoo_contrib.restoredb import main as restoredb 18 | 19 | TEST_DBNAME = "click-odoo-contrib-testrestoredb" 20 | 21 | _DEFAULT_IR_CONFIG_PARAMETERS = ["database.uuid", "database.create_date"] 22 | 23 | 24 | def _createdb(dbname): 25 | subprocess.check_call(["createdb", dbname]) 26 | 27 | 28 | def _dropdb(dbname): 29 | odoo.sql_db.close_all() 30 | subprocess.check_call(["dropdb", "--if-exists", dbname]) 31 | 32 | 33 | def _dropdb_odoo(dbname): 34 | _dropdb(dbname) 35 | filestore_dir = odoo.tools.config.filestore(dbname) 36 | if os.path.isdir(filestore_dir): 37 | shutil.rmtree(filestore_dir) 38 | 39 | 40 | def _check_default_params(db1, db2, operator): 41 | params_by_db = defaultdict(dict) 42 | for db in (db1, db2): 43 | with click_odoo.OdooEnvironment(database=db) as env: 44 | IrConfigParameters = env["ir.config_parameter"] 45 | for key in _DEFAULT_IR_CONFIG_PARAMETERS: 46 | params_by_db[db][key] = IrConfigParameters.get_param(key) 47 | params1 = params_by_db[db1] 48 | params2 = params_by_db[db2] 49 | assert set(params1.keys()) == set(params2.keys()) 50 | for k, v in params1.items(): 51 | assert operator(v, params2[k]) 52 | 53 | 54 | @pytest.fixture(params=["folder", "zip", "dump"]) 55 | def backup(request, odoodb, odoocfg, tmp_path): 56 | if request.param == "folder": 57 | name = "backup" 58 | elif request.param == "zip": 59 | name = "backup.zip" 60 | else: 61 | name = "backup.dump" 62 | path = tmp_path.joinpath(name) 63 | posix_path = path.as_posix() 64 | CliRunner().invoke( 65 | backupdb, ["--format={}".format(request.param), odoodb, posix_path] 66 | ) 67 | yield posix_path, odoodb 68 | 69 | 70 | def test_db_restore(backup): 71 | assert not db_exists(TEST_DBNAME) 72 | backup_path, original_db = backup 73 | try: 74 | result = CliRunner().invoke(restoredb, [TEST_DBNAME, backup_path]) 75 | assert result.exit_code == 0 76 | assert db_exists(TEST_DBNAME) 77 | # default restore mode is copy -> default params are not preserved 78 | _check_default_params(TEST_DBNAME, original_db, operator.ne) 79 | finally: 80 | _dropdb_odoo(TEST_DBNAME) 81 | 82 | 83 | def test_db_restore_target_exists(backup): 84 | _createdb(TEST_DBNAME) 85 | backup_path, original_db = backup 86 | try: 87 | result = CliRunner().invoke(restoredb, [TEST_DBNAME, backup_path]) 88 | assert result.exit_code != 0, result.output 89 | assert "Destination database already exists" in result.output 90 | finally: 91 | _dropdb_odoo(TEST_DBNAME) 92 | try: 93 | result = CliRunner().invoke(restoredb, ["--force", TEST_DBNAME, backup_path]) 94 | assert result.exit_code == 0 95 | assert db_exists(TEST_DBNAME) 96 | finally: 97 | _dropdb_odoo(TEST_DBNAME) 98 | 99 | 100 | def test_db_restore_move(backup): 101 | assert not db_exists(TEST_DBNAME) 102 | backup_path, original_db = backup 103 | try: 104 | result = CliRunner().invoke(restoredb, ["--move", TEST_DBNAME, backup_path]) 105 | assert result.exit_code == 0 106 | assert db_exists(TEST_DBNAME) 107 | # when database is moved, default params are preserved 108 | _check_default_params(TEST_DBNAME, original_db, operator.eq) 109 | finally: 110 | _dropdb_odoo(TEST_DBNAME) 111 | 112 | 113 | def test_db_restore_neutralize(backup): 114 | assert not db_exists(TEST_DBNAME) 115 | backup_path, _original_db = backup 116 | try: 117 | result = CliRunner().invoke( 118 | restoredb, ["--neutralize", TEST_DBNAME, backup_path] 119 | ) 120 | if odoo.release.version_info < (16, 0): 121 | assert result.exit_code != 0, result.output 122 | assert ( 123 | "--neutralize option is only available in odoo 16.0 and above" 124 | in result.output 125 | ) 126 | assert not db_exists(TEST_DBNAME) 127 | else: 128 | assert result.exit_code == 0 129 | assert db_exists(TEST_DBNAME) 130 | with click_odoo.OdooEnvironment(database=TEST_DBNAME) as env: 131 | IrConfigParameters = env["ir.config_parameter"] 132 | assert IrConfigParameters.get_param("database.is_neutralized") == "true" 133 | finally: 134 | _dropdb_odoo(TEST_DBNAME) 135 | -------------------------------------------------------------------------------- /tests/test_uninstall.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | from click.testing import CliRunner 5 | from click_odoo import OdooEnvironment 6 | 7 | from click_odoo_contrib.uninstall import main 8 | 9 | 10 | def test_uninstall(odoodb): 11 | with OdooEnvironment(database=odoodb) as env: 12 | Imm = env["ir.module.module"] 13 | assert Imm.search([("name", "=", "base_import"), ("state", "=", "installed")]) 14 | result = CliRunner().invoke(main, ["-d", odoodb, "-m", "base_import"]) 15 | assert result.exit_code == 0 16 | with OdooEnvironment(database=odoodb) as env: 17 | Imm = env["ir.module.module"] 18 | assert not Imm.search( 19 | [("name", "=", "base_import"), ("state", "=", "installed")] 20 | ) 21 | -------------------------------------------------------------------------------- /tests/test_update.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 ACSONE SA/NV () 2 | # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). 3 | 4 | import json 5 | import os 6 | import subprocess 7 | import sys 8 | 9 | import pytest 10 | from click.testing import CliRunner 11 | from click_odoo import OdooEnvironment, odoo, odoo_bin 12 | 13 | from click_odoo_contrib.update import _load_installed_checksums, main 14 | 15 | # this extends the addons path of the odoodb and odoocfg fixtures 16 | # we use the v1 dir, so the first install work (since it's only since version 12 17 | # that Odoo updates the modules list before installing) 18 | test_addons_dir = os.path.join(os.path.dirname(__file__), "data", "test_update", "v1") 19 | 20 | 21 | def _addons_dir(v): 22 | return os.path.join(os.path.dirname(__file__), "data", "test_update", v) 23 | 24 | 25 | def _addons_path(v): 26 | return ",".join( 27 | [ 28 | os.path.join(odoo.__path__[0], "addons"), 29 | os.path.join(odoo.__path__[0], "..", "addons"), 30 | _addons_dir(v), 31 | ] 32 | ) 33 | 34 | 35 | def _check_expected(odoodb, v): 36 | with OdooEnvironment(database=odoodb) as env: 37 | with open(os.path.join(_addons_dir(v), "expected.json")) as f: 38 | expected = json.load(f) 39 | for addon_name, expected_data in expected.items(): 40 | env.cr.execute( 41 | "SELECT state, latest_version FROM ir_module_module WHERE name=%s", 42 | (addon_name,), 43 | ) 44 | state, version = env.cr.fetchone() 45 | expected_state = expected_data.get("state") 46 | if expected_state: 47 | assert state == expected_state, addon_name 48 | expected_version = expected_data.get("version") 49 | if expected_version: 50 | assert version.split(".")[2:] == expected_version.split("."), addon_name 51 | 52 | 53 | def _install_one(odoodb, v): 54 | cmd = [ 55 | odoo_bin, 56 | "--addons-path", 57 | _addons_path("v1"), 58 | "-d", 59 | odoodb, 60 | "-i", 61 | "addon_app", 62 | "--stop-after-init", 63 | ] 64 | subprocess.check_call(cmd) 65 | 66 | 67 | def _update_one(odoodb, v, ignore_addons=None, ignore_core_addons=False): 68 | cmd = [ 69 | sys.executable, 70 | "-m", 71 | "click_odoo_contrib.update", 72 | "--addons-path", 73 | _addons_path(v), 74 | "-d", 75 | odoodb, 76 | ] 77 | if ignore_addons: 78 | cmd.extend(["--ignore-addons", ignore_addons]) 79 | if ignore_core_addons: 80 | cmd.append("--ignore-core-addons") 81 | subprocess.check_call(cmd) 82 | 83 | 84 | def _update_list(odoodb, v): 85 | cmd = [ 86 | sys.executable, 87 | "-m", 88 | "click_odoo_contrib.update", 89 | "--addons-path", 90 | _addons_path(v), 91 | "-d", 92 | odoodb, 93 | "--list-only", 94 | ] 95 | subprocess.check_call(cmd) 96 | 97 | 98 | def test_update(odoodb): 99 | _install_one(odoodb, "v1") 100 | _check_expected(odoodb, "v1") 101 | # With --list-only option update shouldn't be performed: 102 | _update_list(odoodb, "v2") 103 | _check_expected(odoodb, "v1") 104 | # With --ignore-addons addon_app, update should not be performed 105 | _update_one(odoodb, "v1", ignore_addons="addon_app") 106 | _check_expected(odoodb, "v1") 107 | # Default update should: 108 | _update_one(odoodb, "v2") 109 | _check_expected(odoodb, "v2") 110 | _update_one(odoodb, "v3") 111 | _check_expected(odoodb, "v3") 112 | with OdooEnvironment(odoodb) as env: 113 | checksums = _load_installed_checksums(env.cr) 114 | print(checksums) 115 | assert "base" in checksums 116 | assert checksums.get("addon_app") == "bb1ff827fd6084e69180557c3183989100ddb62b" 117 | assert checksums.get("addon_d1") == "ff46eefbe846e1a46ff3de74e117fd285b72f298" 118 | assert checksums.get("addon_d2") == "edf58645e2e55a2d282320206f73df09a746a4ab" 119 | # 3.1 sets addons_d1 as uninstallable: it stays installed 120 | _update_one(odoodb, "v3.1") 121 | _check_expected(odoodb, "v3.1") 122 | _update_one(odoodb, "v4") 123 | _check_expected(odoodb, "v4") 124 | _update_one(odoodb, "v5") 125 | _check_expected(odoodb, "v5") 126 | _update_one(odoodb, "v6", ignore_core_addons=True) 127 | _check_expected(odoodb, "v6") 128 | with OdooEnvironment(odoodb) as env: 129 | checksums = _load_installed_checksums(env.cr) 130 | assert "base" not in checksums # because ignore_Core_addons=True 131 | with pytest.raises(subprocess.CalledProcessError): 132 | _update_one(odoodb, "v7") 133 | if odoo.release.version_info >= (12, 0): 134 | # Odoo >= 12 does -u in a transaction 135 | _check_expected(odoodb, "v6") 136 | 137 | 138 | def test_update_db_not_exists(): 139 | runner = CliRunner() 140 | result = runner.invoke(main, ["-d", "dbthatdoesnotexist"]) 141 | assert result.exit_code != 0 142 | runner = CliRunner() 143 | result = runner.invoke(main, ["--if-exists", "-d", "dbthatdoesnotexist"]) 144 | assert result.exit_code == 0 145 | 146 | 147 | def test_update_i18n_overwrite(odoodb): 148 | cmd = [ 149 | sys.executable, 150 | "-m", 151 | "click_odoo_contrib.update", 152 | "--i18n-overwrite", 153 | "-d", 154 | odoodb, 155 | ] 156 | subprocess.check_call(cmd) 157 | # TODO how to test i18n-overwrite was effectively applied? 158 | 159 | 160 | def test_parallel_watcher(odoodb): 161 | # Test that the parallel updater does not disturb normal operation 162 | cmd = [ 163 | sys.executable, 164 | "-m", 165 | "click_odoo_contrib.update", 166 | "--watcher-max-seconds", 167 | "30", 168 | "-d", 169 | odoodb, 170 | ] 171 | subprocess.check_call(cmd) 172 | # TODO Test an actual lock 173 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | py312-{16.0,17.0,18.0} 9 | py311-{16.0,17.0} 10 | py310-{16.0,17.0} 11 | py38-{15.0} 12 | py36-{11.0,12.0,13.0,14.0} 13 | twine_check 14 | pre_commit 15 | skip_missing_interpreters = True 16 | 17 | [testenv] 18 | commands = 19 | 11.0: {toxinidir}/tests/scripts/install_odoo.py 11.0 {envdir}/src/odoo 20 | 12.0: {toxinidir}/tests/scripts/install_odoo.py 12.0 {envdir}/src/odoo 21 | 13.0: {toxinidir}/tests/scripts/install_odoo.py 13.0 {envdir}/src/odoo 22 | 14.0: {toxinidir}/tests/scripts/install_odoo.py 14.0 {envdir}/src/odoo 23 | 15.0: {toxinidir}/tests/scripts/install_odoo.py 15.0 {envdir}/src/odoo 24 | 16.0: {toxinidir}/tests/scripts/install_odoo.py 16.0 {envdir}/src/odoo 25 | 17.0: {toxinidir}/tests/scripts/install_odoo.py 17.0 {envdir}/src/odoo 26 | 18.0: {toxinidir}/tests/scripts/install_odoo.py 18.0 {envdir}/src/odoo 27 | pytest --verbose --cov=click_odoo_contrib --cov-branch --cov-report=html --cov-report=term --cov-report=xml {posargs} 28 | deps = 29 | pytest 30 | pytest-cov 31 | usedevelop = True 32 | passenv = 33 | SSH_AUTH_SOCK 34 | PGHOST 35 | PGPORT 36 | PGUSER 37 | PGPASSWORD 38 | PGDATABASE 39 | # allow running our install_odoo.py script 40 | allowlist_externals = * 41 | 42 | [testenv:twine_check] 43 | description = check that the long description is valid 44 | deps = twine 45 | skip_install = true 46 | commands = 47 | pip wheel -w {envtmpdir}/build --no-deps . 48 | twine check {envtmpdir}/build/* 49 | 50 | [testenv:pre_commit] 51 | deps = 52 | pre-commit 53 | commands = 54 | pre-commit run --all-files --show-diff-on-failure 55 | 56 | [pytest] 57 | filterwarnings = 58 | once::DeprecationWarning 59 | once::PendingDeprecationWarning 60 | --------------------------------------------------------------------------------