├── .editorconfig ├── .flake8 ├── .github ├── file-filters.yml └── workflows │ ├── issues.yml │ ├── lint.yaml │ ├── release.yml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .tx └── config ├── AUTHORS ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── compose.yml ├── docs ├── _ext │ └── version.py ├── admin.rst ├── api.rst ├── changes.rst ├── conf.py ├── cookbook.rst ├── faq.rst ├── fields.rst ├── globals.txt ├── index.rst ├── install.rst ├── middleware.rst ├── requirements.pip └── settings.rst ├── manage.py ├── mypy.ini ├── pyproject.toml ├── src └── concurrency │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── apps.py │ ├── compat.py │ ├── config.py │ ├── core.py │ ├── exceptions.py │ ├── fields.py │ ├── forms.py │ ├── locale │ └── en │ │ └── LC_MESSAGES │ │ └── django.po │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── triggers.py │ ├── middleware.py │ ├── models.py │ ├── templates │ └── concurrency │ │ └── delete_selected_confirmation.html │ ├── templatetags │ ├── __init__.py │ └── concurrency.py │ ├── triggers.py │ ├── utils.py │ └── views.py ├── tests ├── .coveragerc ├── conftest.py ├── demoapp │ └── demo │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── auth_migrations │ │ ├── 0001_initial.py │ │ ├── 0002_concurrency_add_version_to_group.py │ │ └── __init__.py │ │ ├── auth_migrations5 │ │ ├── 0001_initial.py │ │ ├── 0002_concurrency_add_version_to_group.py │ │ └── __init__.py │ │ ├── backends.py │ │ ├── base.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20160909_1544.py │ │ ├── 0003_auto_20171207_1254.py │ │ └── __init__.py │ │ ├── migrations5 │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── settings.py │ │ ├── templates │ │ ├── 404.html │ │ └── 409.html │ │ ├── urls.py │ │ └── util.py ├── dumpdata.json ├── test_admin_actions.py ├── test_admin_edit.py ├── test_admin_list_editable.py ├── test_api.py ├── test_base.py ├── test_checks.py ├── test_command.py ├── test_concurrencymetainfo.py ├── test_conditional.py ├── test_config.py ├── test_core.py ├── test_enable_disable.py ├── test_forms.py ├── test_issues.py ├── test_loaddata_dumpdata.py ├── test_manager.py ├── test_middleware.py ├── test_reversion.py ├── test_templatetags.py ├── test_threads.py ├── test_triggers.py ├── test_triggerversionfield.py ├── test_utils.py └── test_views.py ├── tox.ini └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*.py] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Use 2 spaces for the HTML files 14 | [*.html] 15 | indent_size = 2 16 | 17 | # The JSON files contain newlines inconsistently 18 | [*.json] 19 | indent_size = 2 20 | insert_final_newline = ignore 21 | 22 | [**/admin/js/vendor/**] 23 | indent_style = ignore 24 | indent_size = ignore 25 | 26 | # Minified JavaScript files shouldn't be changed 27 | [**.min.js] 28 | indent_style = ignore 29 | insert_final_newline = ignore 30 | 31 | # Makefiles always use tabs for indentation 32 | [Makefile] 33 | indent_style = tab 34 | 35 | # Batch files use tabs for indentation 36 | [*.bat] 37 | indent_style = tab 38 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 12 3 | max-line-length = 120 4 | exclude = 5 | .*/ 6 | __pycache__ 7 | docs 8 | ~build 9 | dist 10 | *.md 11 | 12 | per-file-ignores = 13 | src/**/migrations/*.py:E501 14 | -------------------------------------------------------------------------------- /.github/file-filters.yml: -------------------------------------------------------------------------------- 1 | dependencies: &dependencies 2 | - 'pdm.lock' 3 | - 'pyproject.toml' 4 | 5 | actions: &actions 6 | - added|modified: './.github/**/*' 7 | 8 | python: &python 9 | - added|modified: 'src/**' 10 | - added|modified: 'tests/**' 11 | - added|modified: '.pre-commit-config.yaml' 12 | 13 | changelog: 14 | - 'CHANGELOG.md' 15 | 16 | run_tests: &tests 17 | - *actions 18 | - *python 19 | - *dependencies 20 | - 'tox.ini' 21 | 22 | package: 23 | - *tests 24 | - *dependencies 25 | 26 | docs: 27 | - added|modified: './docs/**/*' 28 | - modified: './README.md' 29 | - modified: './github/workflows/docs.yml' 30 | - modified: './github/file-filters.yml' 31 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 13 | days-before-stale: 60 14 | days-before-close: 10 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | jobs: 11 | changes: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 1 14 | defaults: 15 | run: 16 | shell: bash 17 | outputs: 18 | run_tests: ${{ steps.changes.outputs.run_tests }} 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | - id: changes 23 | name: Check for file changes 24 | uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 25 | with: 26 | base: ${{ github.ref }} 27 | token: ${{ github.token }} 28 | filters: .github/file-filters.yml 29 | 30 | pkg_meta: 31 | needs: [ changes ] 32 | if: needs.changes.outputs.run_tests == 'true' 33 | 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions/setup-python@v2 38 | 39 | - name: Install dependencies 40 | run: | 41 | python -m pip install --upgrade pip tox 42 | - name: Lint with flake8 43 | run: | 44 | tox -e pkg_meta 45 | lint: 46 | needs: [ changes ] 47 | if: needs.changes.outputs.run_tests == 'true' 48 | 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: actions/setup-python@v2 53 | 54 | - name: Install dependencies 55 | run: | 56 | python -m pip install --upgrade pip tox 57 | - name: Lint with flake8 58 | run: | 59 | tox -e lint 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | on: 3 | push: 4 | tags: ["*"] 5 | 6 | env: 7 | dists-artifact-name: python-package-distributions 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Install the latest version of uv 17 | uses: astral-sh/setup-uv@v5 18 | with: 19 | enable-cache: true 20 | cache-dependency-glob: "pyproject.toml" 21 | github-token: ${{ secrets.GITHUB_TOKEN }} 22 | - name: Build package 23 | run: uv build --python 3.13 --python-preference only-managed --sdist --wheel . --out-dir dist 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: ${{ env.dists-artifact-name }} 28 | path: dist/* 29 | 30 | release: 31 | needs: 32 | - build 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: release 36 | url: https://pypi.org/project/django-concurrency/${{ github.ref_name }} 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v4 42 | with: 43 | name: ${{ env.dists-artifact-name }} 44 | path: dist/ 45 | - name: Publish to PyPI 46 | uses: pypa/gh-action-pypi-publish@v1.12.4 47 | with: 48 | attestations: true 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | jobs: 11 | changes: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 1 14 | defaults: 15 | run: 16 | shell: bash 17 | outputs: 18 | run_tests: ${{ steps.changes.outputs.run_tests }} 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | - id: changes 23 | name: Check for file changes 24 | uses: dorny/paths-filter@0bc4621a3135347011ad047f9ecf449bf72ce2bd # v3.0.0 25 | with: 26 | base: ${{ github.ref }} 27 | token: ${{ github.token }} 28 | filters: .github/file-filters.yml 29 | 30 | test: 31 | needs: [ changes ] 32 | if: needs.changes.outputs.run_tests == 'true' 33 | 34 | runs-on: ubuntu-latest 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python: [ "3.13" ] 39 | django: [ "52", "42", "32" ] 40 | db-engine: [ "pg", "mysql" ] 41 | env: 42 | PY_VER: ${{ matrix.python}} 43 | DJ_VER: ${{ matrix.django}} 44 | DBENGINE: ${{ matrix.db-engine}} 45 | MYSQL_USER: 'root' 46 | MYSQL_PASSWORD: 'root' 47 | 48 | services: 49 | postgres: 50 | image: postgres 51 | env: 52 | POSTGRES_USER: postgres 53 | POSTGRES_PASSWORD: postgres 54 | POSTGRES_DB: concurrency 55 | ports: 56 | - 5432:5432 57 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 58 | 59 | mysql: 60 | image: mysql:5.7 61 | env: 62 | MYSQL_DATABASE: test_db 63 | MYSQL_USER: user 64 | MYSQL_PASSWORD: password 65 | MYSQL_ROOT_PASSWORD: rootpassword 66 | ports: 67 | - 33306:3306 68 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 69 | 70 | steps: 71 | - uses: actions/checkout@v4 72 | 73 | - name: Setup MySQL 74 | run: | 75 | sudo /etc/init.d/mysql start 76 | mysql -e 'CREATE DATABASE concurrency;' -uroot -proot 77 | mysql -e 'SHOW DATABASES;' -uroot -proot 78 | 79 | - name: Install the latest version of uv 80 | uses: astral-sh/setup-uv@v5 81 | with: 82 | version: "latest" 83 | enable-cache: true 84 | cache-dependency-glob: | 85 | pyproject.toml 86 | uv.lock 87 | cache-suffix: ${{ matrix.python }}-${{ matrix.django }} 88 | github-token: ${{ secrets.GITHUB_TOKEN }} 89 | python-version: ${{ matrix.python }} 90 | 91 | - name: Install tox 92 | run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv --with tox-gh-actions 93 | 94 | - name: Install Python 95 | if: matrix.python != '3.13' 96 | run: uv python install --python-preference only-managed ${{ matrix.python }} 97 | 98 | - name: Setup test suite 99 | run: tox run -vv --notest --skip-missing-interpreters false 100 | 101 | - name: Test with 102 | run: tox run -e d${DJ_VER//.}-py${PY_VER//.}-${DBENGINE} 103 | 104 | - name: Upload test results to Codecov 105 | uses: codecov/test-results-action@v1 106 | with: 107 | env_vars: OS 108 | flags: ${{ matrix.django }} ${{ matrix.python }} 109 | token: ${{ secrets.CODECOV_TOKEN }} 110 | verbose: true 111 | 112 | - name: Upload coverage to Codecov 113 | uses: codecov/codecov-action@v5 114 | with: 115 | env_vars: OS,PYTHON 116 | fail_ci_if_error: true 117 | files: ./coverage1.xml,./coverage2.xml,!./cache 118 | flags: unittests 119 | name: codecov-umbrella 120 | token: ${{ secrets.CODECOV_TOKEN }} 121 | verbose: true 122 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ~* 2 | .* 3 | !.github 4 | !.gitignore 5 | !.editorconfig 6 | !.pre-commit-config.yaml 7 | !.readthedocs.yaml 8 | !.flake8 9 | coverage.xml 10 | notes.txt 11 | build/ 12 | dist/ 13 | __pycache__ 14 | /.eggs 15 | *.egg-info 16 | *.pyc 17 | *.log 18 | *.pot 19 | *.mo 20 | *.sqlite 21 | .testmondata 22 | .venv/ 23 | src/concurrency/version.py 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.31.0 9 | hooks: 10 | - id: check-github-workflows 11 | args: [ "--verbose" ] 12 | - repo: https://github.com/tox-dev/tox-ini-fmt 13 | rev: "1.5.0" 14 | hooks: 15 | - id: tox-ini-fmt 16 | args: [ "-p", "lint" ] 17 | - repo: https://github.com/tox-dev/pyproject-fmt 18 | rev: "v2.5.0" 19 | hooks: 20 | - id: pyproject-fmt 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | rev: "v0.9.4" 23 | hooks: 24 | - id: ruff-format 25 | - id: ruff 26 | args: [ "--fix", "--unsafe-fixes", "--exit-non-zero-on-fix" ] 27 | - repo: https://github.com/adamchainz/djade-pre-commit 28 | rev: "1.3.2" 29 | hooks: 30 | - id: djade 31 | args: [ --target-version, "5.1" ] # Replace with Django version 32 | - repo: meta 33 | hooks: 34 | - id: check-hooks-apply 35 | - id: check-useless-excludes 36 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.10" 12 | # You can also specify other tool versions: 13 | # nodejs: "20" 14 | # rust: "1.70" 15 | # golang: "1.20" 16 | 17 | # Build documentation in the "docs/" directory with Sphinx 18 | sphinx: 19 | configuration: docs/conf.py 20 | # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs 21 | # builder: "dirhtml" 22 | # Fail on all warnings to avoid broken references 23 | # fail_on_warning: true 24 | 25 | # Optionally build your docs in additional formats such as PDF and ePub 26 | # formats: 27 | # - pdf 28 | # - epub 29 | 30 | # Optional but recommended, declare the Python requirements required 31 | # to build your documentation 32 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 33 | python: 34 | install: 35 | - requirements: docs/requirements.pip 36 | - method: pip 37 | path: . 38 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [django-concurrency.django-concurrency] 2 | file_filter = concurrency/locale//LC_MESSAGES/django.po 3 | source_file = concurrency/locale/en/LC_MESSAGES/django.po 4 | source_lang = en 5 | 6 | [main] 7 | host = https://www.transifex.com 8 | type = PO 9 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Stefano Apostolico 2 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Release 2.7 2 | ----------- 3 | * fixes #256 4 | 5 | 6 | Release 2.6 7 | ----------- 8 | * add support do Django 5.x 9 | * drop support python 3.9 10 | * drop support django 3.x 11 | * move to .pyproject.toml 12 | 13 | 14 | Release 2.5 15 | ----------- 16 | * BUG FIX: fixes check() implementation 17 | * removes deprecatd MANUAL_TRIGGERS 18 | * drop support Django 2.x 19 | * drop support python 3.8 20 | 21 | 22 | Release 2.4 23 | ----------- 24 | * add support Django 4 25 | * add support Python 3.10 26 | 27 | 28 | Release 2.3 29 | ----------- 30 | * Removes code producing DeprecationError 31 | * add :setting:`AUTO_CREATE_TRIGGERS` and deprecate :setting:`MANUAL_TRIGGERS` 32 | * add support for Postgres 13 33 | * add ability to customise SQL to create triggers :setting:`TRIGGERS_FACTORY` 34 | 35 | Release 2.2 36 | ------------- 37 | * drop suppot django<3.0 38 | * drop suppot Python<3.6 39 | 40 | 41 | Release 2.1.1 42 | ------------- 43 | * fixes packaging 44 | 45 | Release 2.1 ( not released on pypi) 46 | ----------------------------------- 47 | * drop support Python < 3.5 48 | * add support Django 2.2 / 3.0 49 | * drop support Django < 1.11 50 | 51 | 52 | Release 2.0 53 | ----------- 54 | * drop official support to Django < 1.10 55 | * add support Django 2.1 56 | * removed deprecated api `concurrency_check` 57 | * BACKWARD INCOMPATIBLE: version field is now mandatory in any save operation. Use ``VERSION_FIELD_REQUIRED=False`` to have the old behaviour. 58 | * `disable_concurrency` now has `start()`, `finish()` to be called as command 59 | 60 | 61 | Release 1.4 (13 Sep 2016) 62 | ------------------------- 63 | * closes :issue:`81`. Add docs and check. 64 | * fixes :issue:`80`. (thanks Naddiseo for the useful support) 65 | * Django 1.11 compatibility 66 | * some minor support for Django 2.0 67 | 68 | 69 | Release 1.3.2 (10 Sep 2016) 70 | --------------------------- 71 | * fixes bug in ConditionalVersionField that produced 'maximum recursion error' when a model had a ManyToManyField with a field to same model (self-relation) 72 | 73 | 74 | Release 1.3.1 (15 Jul 2016) 75 | --------------------------- 76 | * just packaging 77 | 78 | 79 | Release 1.3 (15 Jul 2016) 80 | ------------------------- 81 | * drop support for Python < 3.3 82 | * add support Django>=1.10 83 | * change license 84 | * fixes :issue:`36`. (thanks claytondaley) 85 | * new :setting:`IGNORE_DEFAULT` to ignore default version number 86 | 87 | 88 | Release 1.2 (05 Apr 2016) 89 | ------------------------- 90 | * better support for django 1.9 ( ``TemplateDoesNotExist`` is now in ``django.template.exceptions`` 91 | * improved eror message in ``ConcurrencyListEditableMixin`` :issue:`63` :issue:`64` 92 | * fixes :issue:`61`. Error in ``ConditionalVersionField`` (thanks ticosax) 93 | * fixes ``skipif`` test in pypy 94 | 95 | 96 | Release 1.1 (13 Feb 2016) 97 | ------------------------- 98 | * drop support for django<1.7 99 | * add support for pypy 100 | * new :class:`concurrency.fields.ConditionalVersionField` 101 | * new decorator :class:`concurrency.api.concurrency_disable_increment` 102 | * :class:`concurrency.api.disable_concurrency` is now a noop if applied to a model not under concurrency management 103 | 104 | 105 | Release 1.0.1 106 | ------------- 107 | * fixes :issue:`56` "Can't upgrade django-concurrency to 1.0" (thanks oppianmatt). 108 | 109 | 110 | Release 1.0 111 | ----------- 112 | * **BACKWARD INCOMPATIBLE**:: dropped support for Django prior 1.6 113 | * code clean 114 | * fixes :issue:`54` "Incorrect default for IntegerVersionField" (thanks vmspike). 115 | * fixes :issue:`53`. updates Documentation 116 | * :ref:`disable_concurrency` can now disable concurrency in any model 117 | * :ref:`disable_concurrency` is now also a decorator 118 | * **BACKWARD INCOMPATIBLE**:: removed custom backends. ``TriggerVerionField`` can be used with standard Django 119 | * new way to create triggers (thanks Naddiseo) 120 | * new trigger code 121 | * new :attr:`TriggerVersionField.check`. 122 | * new :attr:`TriggerVersionField.trigger_name`. 123 | * new :setting:`CONCURRECY_ENABLED` to fully disable concurrency 124 | * new :setting:`CONCURRECY_MANUAL_TRIGGERS` to disable triggers auto creation fixes :issue:`41` (thanks Naddiseo) 125 | 126 | 127 | Release 0.9 128 | ----------- 129 | * Django 1.8 compatibility 130 | * python 3.4 compatibility 131 | * **BACKWARD INCOMPATIBLE** :func:`disable_concurrency` works differently if used with classes or instances 132 | * better support for external Models (models that are part of plugged-in applications) 133 | * fixes issue with TriggerVersionField and Proxy Models (thanx Richard Eames) 134 | 135 | 136 | Release 0.8.1 137 | ------------- 138 | * avoid to use concurrency when selecting all items (select_across) 139 | 140 | 141 | Release 0.8 142 | ----------- 143 | * Django 1.7 compatibility 144 | * fixes typo in ``delete_selected_confirmation.html`` template 145 | * python 3.2/3.3 compatibility 146 | 147 | 148 | Release 0.7.1 149 | ------------- 150 | * backward compatibility updates. Do not check for concurrency if `0` is passed as version value 151 | (ie. no value provided by the form) 152 | 153 | 154 | Release 0.7 155 | ----------- 156 | * new :class:`concurrency.fields.TriggerVersionField` 157 | * start using pytest 158 | * moved tests outside main package 159 | * new protocol see:ref:`protocols` 160 | * it's now possible disable concurrency in Models that extends concurrency enabled models 161 | * fixed :issue:`23` (thanks matklad) 162 | * new :setting:`USE_SELECT_FOR_UPDATE` 163 | 164 | 165 | Release 0.6.0 166 | ------------- 167 | * new :ref:`disable_concurrency` context manager 168 | * added documentation for :ref:`concurrency.middleware.ConcurrencyMiddleware ` 169 | * **BACKWARD INCOMPATIBLE** Fixed typo: ``CONCURRECY_SANITY_CHECK`` now ``CONCURRENCY_SANITY_CHECK`` 170 | * added ``disable_sanity_check`` context manager 171 | * added configuration 172 | * check admin actions for concurrent deletion 173 | * added concurrency check for admin's :ref:`list_editable` 174 | 175 | 176 | Release 0.5.0 177 | ------------- 178 | * python 3.x compatibility 179 | * new :setting:`CONCURRENCY_FIELD_SIGNER` 180 | 181 | 182 | Release 0.4.0 183 | ------------- 184 | * start deprecation of ``concurrency.core.VersionChangedError``, ``concurrency.core.RecordModifiedError``, 185 | ``concurrency.core.InconsistencyError``,moved in ``concurrency.exceptions`` 186 | * start deprecation of ``concurrency.core.apply_concurrency_check``, ``concurrency.core.concurrency_check`` moved in ``concurrency.api`` 187 | * added :setting:`CONCURRECY_SANITY_CHECK` settings entry 188 | * signing of version number to avoid tampering (:ref:`concurrentform`) 189 | * added :ref:`concurrencytestmixin` to help test on concurrency managed models 190 | * changed way to add concurrency to existing models (:ref:`apply_concurrency_check`) 191 | * fixed :issue:`4` (thanks FrankBie) 192 | * removed RandomVersionField 193 | * new `concurrency_check` 194 | * added :ref:`concurrentform` to mitigate some concurrency conflict 195 | * select_for_update now executed with ``nowait=True`` 196 | * removed some internal methods, to avoid unlikely but possible name clashes 197 | 198 | 199 | Release 0.3.2 200 | ------------- 201 | * fixed :issue:`3` (thanks pombredanne) 202 | * fixed :issue:`1` (thanks mbrochh) 203 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2010-2016, Stefano Apostolico (s.apostolico@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include AUTHORS 3 | include CHANGES 4 | include LICENSE 5 | include MANIFEST.in 6 | include setup.py 7 | include tox.ini 8 | include *.py 9 | 10 | exclude Makefile 11 | exclude Pipfile 12 | exclude .editorconfig 13 | exclude .tx 14 | exclude .pyc 15 | exclude .pre-commit-config.yaml 16 | 17 | recursive-exclude .tx * 18 | recursive-exclude __pycache__ * 19 | recursive-exclude . *.pyc 20 | 21 | recursive-include docs * 22 | recursive-include src * 23 | recursive-include tests * 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION=2.0.0 2 | BUILDDIR='~build' 3 | PYTHONPATH:=${PWD}/tests/:${PWD} 4 | DBENGINE?=pg 5 | DJANGO?='last' 6 | 7 | 8 | .mkbuilddir: 9 | mkdir -p ${BUILDDIR} 10 | 11 | develop: 12 | @pip install pipenv 13 | @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then pip install MySQL-python; fi" 14 | @sh -c "if [ '${DBENGINE}' = 'pg' ]; then pip install -q psycopg2-binary; fi" 15 | $(MAKE) .init-db 16 | @pipenv install -d --skip-lock 17 | 18 | 19 | .init-db: 20 | @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then mysql -h 127.0.0.1 -u root -e 'DROP DATABASE IF EXISTS concurrency;'; fi" 21 | @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then mysql -h 127.0.0.1 -u root -e 'CREATE DATABASE IF NOT EXISTS concurrency;'; fi" 22 | 23 | @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -h localhost -c 'DROP DATABASE IF EXISTS concurrency;' -U postgres; fi" 24 | @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -h localhost -c 'CREATE DATABASE concurrency;' -U postgres; fi" 25 | 26 | test: 27 | py.test -v --create-db 28 | 29 | lint: 30 | pre-commit run --all-files 31 | 32 | travis: 33 | docker run --privileged --name travis-debug -it -u travis travisci/ci-amethyst:packer-1512508255-986baf0 /bin/bash -l 34 | 35 | 36 | clean: 37 | rm -fr ${BUILDDIR} dist *.egg-info .coverage coverage.xml .eggs build 38 | find src -name __pycache__ -o -name "*.py?" -o -name "*.orig" -prune | xargs rm -rf 39 | find tests -name __pycache__ -o -name "*.py?" -o -name "*.orig" -prune | xargs rm -rf 40 | find src/concurrency/locale -name django.mo | xargs rm -f 41 | 42 | fullclean: 43 | rm -fr .tox .cache 44 | $(MAKE) clean 45 | 46 | 47 | docs: .mkbuilddir 48 | mkdir -p ${BUILDDIR}/docs 49 | rm -fr ${BUILDDIR}/docs/* 50 | sphinx-build -aE docs/ ${BUILDDIR}/docs 51 | ifdef BROWSE 52 | firefox ${BUILDDIR}/docs/index.html 53 | endif 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Concurrency 2 | ================== 3 | 4 | 5 | [![Pypi](https://badge.fury.io/py/django-concurrency.svg)](https://badge.fury.io/py/django-concurrency) 6 | [![coverage](https://codecov.io/github/saxix/django-concurrency/coverage.svg?branch=develop)](https://codecov.io/github/saxix/django-concurrency?branch=develop) 7 | [![Test](https://github.com/saxix/django-concurrency/actions/workflows/tests.yaml/badge.svg)](https://github.com/saxix/django-concurrency/actions/workflows/tests.yaml) 8 | [![Docs](https://readthedocs.org/projects/django-concurrency/badge/?version=stable)](http://django-concurrency.readthedocs.io/en/stable/) 9 | [![Django](https://img.shields.io/pypi/frameworkversions/django/django-concurrency)](https://pypi.org/project/django-concurrency/) 10 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/django-concurrency.svg)](https://pypi.org/project/django-concurrency/) 11 | 12 | 13 | django-concurrency is an [optimistic lock][1] implementation for Django. 14 | 15 | It prevents users from doing concurrent editing in Django both from UI and from a 16 | django command. 17 | 18 | 19 | How it works 20 | ------------ 21 | 22 | ```python 23 | 24 | from django.db import models 25 | from concurrency.fields import IntegerVersionField 26 | 27 | class ConcurrentModel( models.Model ): 28 | version = IntegerVersionField( ) 29 | name = models.CharField(max_length=100) 30 | ``` 31 | 32 | Now if you try:: 33 | 34 | ```python 35 | 36 | a = ConcurrentModel.objects.get(pk=1) 37 | a.name = '1' 38 | 39 | b = ConcurrentModel.objects.get(pk=1) 40 | b.name = '2' 41 | 42 | a.save() 43 | b.save() 44 | 45 | ``` 46 | 47 | you will get a ``RecordModifiedError`` on ``b.save()`` 48 | 49 | 50 | Similar projects 51 | ---------------- 52 | 53 | Other projects that handle concurrent editing are [django-optimistic-lock][10] 54 | and [django-locking][11] anyway concurrency is "a batteries included" optimistic 55 | lock management system, here some features not available elsewhere: 56 | 57 | * can be applied to any model; not only your code (ie. django.contrib.auth.Group) 58 | * handle [list-editable][2] ChangeList. (handle `#11313 `_) 59 | * manage concurrency conflicts in admin's actions 60 | * can intercept changes performend out of the django app (ie using pgAdmin, phpMyAdmin, Toads) (using [TriggerVersionField][6]) 61 | * can be disabled if needed (see [disable_concurrency][3]) 62 | * [ConditionalVersionField][4] to handle complex business rules 63 | 64 | 65 | 66 | Project Links 67 | ------------ 68 | 69 | - Code: https://github.com/saxix/django-concurrency 70 | - Documentation: https://django-concurrency.readthedocs.org/en/latest/ 71 | - Issue Tracker: https://github.com/saxix/django-concurrency/issues 72 | - Download Package: http://pypi.python.org/pypi/django-concurrency/ 73 | 74 | 75 | [10]:https://github.com/gavinwahl/django-optimistic-lock 76 | [11]:https://github.com/stdbrouw/django-locking 77 | [1]:http://en.wikipedia.org/wiki/Optimistic_concurrency_control 78 | [2]:https://django-concurrency.readthedocs.org/en/latest/admin.html#list-editable 79 | [3]:https://django-concurrency.readthedocs.org/en/latest/api.html?#disable-concurrency 80 | [4]:https://django-concurrency.readthedocs.org/en/latest/fields.html#conditionalversionfield 81 | [6]:https://django-concurrency.readthedocs.org/en/latest/fields.html#triggerversionfield 82 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | postgres: 4 | image: postgis/postgis:14-3.4-alpine 5 | platform: linux/amd64 6 | ports: 7 | - "5432:5432" 8 | shm_size: '1gb' 9 | environment: 10 | POSTGRES_PASSWORD: password 11 | POSTGRES_USER: postgres 12 | POSTGRES_HOST_AUTH_METHOD: trust 13 | 14 | mysql: 15 | image: mysql 16 | stdin_open: true 17 | tty: true 18 | ports: 19 | - "3306:3306" 20 | environment: 21 | MYSQL_ROOT_PASSWORD: root 22 | MYSQL_DATABASE: concurrency 23 | MYSQL_ALLOW_EMPTY_PASSWORD: true 24 | -------------------------------------------------------------------------------- /docs/_ext/version.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | # RE for option descriptions without a '--' prefix 5 | 6 | simple_option_desc_re = re.compile(r"([-_a-zA-Z0-9]+)(\s*.*?)(?=,\s+(?:/|-|--)|$)") 7 | 8 | 9 | def setup(app): 10 | app.add_crossref_type( 11 | directivename="setting", 12 | rolename="setting", 13 | indextemplate="pair: %s; setting", 14 | ) 15 | app.add_crossref_type( 16 | directivename="templatetag", 17 | rolename="ttag", 18 | indextemplate="pair: %s; template tag", 19 | ) 20 | app.add_crossref_type( 21 | directivename="templatefilter", 22 | rolename="tfilter", 23 | indextemplate="pair: %s; template filter", 24 | ) 25 | app.add_crossref_type( 26 | directivename="fieldlookup", 27 | rolename="lookup", 28 | indextemplate="pair: %s; field lookup type", 29 | ) 30 | app.add_config_value("next_version", "0.0", True) 31 | app.add_crossref_type( 32 | directivename="release", 33 | rolename="release", 34 | indextemplate="pair: %s; release", 35 | ) 36 | -------------------------------------------------------------------------------- /docs/admin.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | .. _admin: 3 | 4 | ================= 5 | Admin Integration 6 | ================= 7 | 8 | .. contents:: 9 | :local: 10 | 11 | 12 | .. _list_editable: 13 | 14 | Handle ``list_editable`` 15 | ------------------------ 16 | .. versionadded:: 0.6 17 | 18 | |concurrency| is able to handle conflicts in the admin's changelist view when 19 | :attr:`ModelAdmin.list_editable` is enabled. To enable this feature simply extend your ModelAdmin from 20 | :ref:`ConcurrentModelAdmin` or use :ref:`ConcurrencyListEditableMixin` 21 | 22 | 23 | .. seealso:: :ref:`list_editable_policies` 24 | 25 | 26 | .. _admin_action: 27 | 28 | Check admin's action execution for concurrency 29 | ---------------------------------------------- 30 | 31 | .. versionadded:: 0.6 32 | 33 | Extend your ModelAdmin with :ref:`ConcurrencyActionMixin` or use :ref:`ConcurrentModelAdmin` 34 | 35 | 36 | 37 | Update existing actions templates to be managed by concurrency 38 | -------------------------------------------------------------- 39 | 40 | .. versionadded:: 0.6 41 | 42 | You ca use the :tfilter:`identity` filter to pass both ``pk`` and ``version`` to your ModelAdmin. 43 | Each time you use ``{{ obj.pk }}`` simply change to ``{{ obj|identity }}``. 44 | So in the ``admin/delete_selected_confirmation.html`` will have: 45 | 46 | .. code-block:: html 47 | 48 | {% for obj in queryset %} 49 | 50 | {% endfor %} 51 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _api: 4 | 5 | API 6 | === 7 | 8 | .. contents:: 9 | :local: 10 | 11 | ----- 12 | Forms 13 | ----- 14 | 15 | .. _concurrentform: 16 | 17 | 18 | ConcurrentForm 19 | -------------- 20 | .. autoclass:: concurrency.forms.ConcurrentForm 21 | 22 | 23 | VersionWidget 24 | ------------- 25 | .. autoclass:: concurrency.forms.VersionWidget 26 | 27 | 28 | 29 | ---------- 30 | Exceptions 31 | ---------- 32 | 33 | .. _VersionChangedError: 34 | 35 | VersionChangedError 36 | ------------------- 37 | .. autoclass:: concurrency.exceptions.VersionChangedError 38 | 39 | 40 | 41 | .. class:: concurrency.exceptions.RecordModifiedError 42 | 43 | .. _RecordModifiedError: 44 | 45 | RecordModifiedError 46 | ------------------- 47 | .. autoclass:: concurrency.exceptions.RecordModifiedError 48 | 49 | 50 | 51 | .. _InconsistencyError: 52 | 53 | InconsistencyError 54 | ------------------ 55 | .. versionchanged:: 0.7 56 | .. warning:: removed in 0.7 57 | .. class:: concurrency.exceptions.InconsistencyError 58 | 59 | 60 | .. _VersionError: 61 | 62 | VersionError 63 | ------------------- 64 | .. autoclass:: concurrency.exceptions.VersionError 65 | 66 | 67 | 68 | ----- 69 | Admin 70 | ----- 71 | 72 | .. _ConcurrentModelAdmin: 73 | 74 | ConcurrentModelAdmin 75 | -------------------- 76 | .. autoclass:: concurrency.admin.ConcurrentModelAdmin 77 | 78 | .. warning:: If you customize ``fields`` or ``fieldsets`` remember to add version field to the list. (See issue :issue:`81`) 79 | 80 | 81 | 82 | .. _ConcurrencyActionMixin: 83 | 84 | ConcurrencyActionMixin 85 | ---------------------- 86 | .. autoclass:: concurrency.admin.ConcurrencyActionMixin 87 | 88 | 89 | .. _ConcurrencyListEditableMixin: 90 | 91 | ConcurrencyListEditableMixin 92 | ---------------------------- 93 | .. autoclass:: concurrency.admin.ConcurrencyListEditableMixin 94 | 95 | 96 | ---------- 97 | Middleware 98 | ---------- 99 | 100 | .. _concurrencymiddleware: 101 | .. class:: concurrency.middleware.ConcurrencyMiddleware 102 | 103 | ConcurrencyMiddleware 104 | --------------------- 105 | .. seealso:: :ref:`middleware` 106 | 107 | .. autoclass:: concurrency.middleware.ConcurrencyMiddleware 108 | 109 | 110 | .. _handler409: 111 | 112 | concurrency.views.conflict 113 | -------------------------- 114 | .. autofunction:: concurrency.views.conflict 115 | 116 | 117 | 118 | ------- 119 | Helpers 120 | ------- 121 | 122 | .. _apply_concurrency_check: 123 | 124 | `apply_concurrency_check()` 125 | --------------------------- 126 | 127 | .. versionadded:: 0.4 128 | 129 | .. versionchanged:: 0.8 130 | 131 | Add concurrency check to existing classes. 132 | 133 | .. note:: With Django 1.7 and the new migrations management, this utility does 134 | not work anymore. To add concurrency management to a external Model, 135 | you need to use a migration to add a `VersionField` to the desired Model. 136 | 137 | 138 | .. note:: See ``demo.auth_migrations`` for a example how to add :class:`IntegerVersionField ` to :class:`auth.Group` ) 139 | 140 | .. code-block:: python 141 | 142 | operations = [ 143 | # add version to django.contrib.auth.Group 144 | migrations.AddField( 145 | model_name='Group', 146 | name='version', 147 | field=IntegerVersionField(help_text=b'Version', default=1), 148 | ), 149 | ] 150 | 151 | and put in your settings.py 152 | 153 | .. code-block:: python 154 | 155 | MIGRATION_MODULES = { 156 | ... 157 | ... 158 | 'auth': '', 159 | } 160 | 161 | 162 | .. _disable_concurrency: 163 | 164 | `disable_concurrency()` 165 | ----------------------- 166 | 167 | .. versionadded:: 0.6 168 | 169 | 170 | Context manager to temporary disable concurrency checking. 171 | 172 | 173 | .. versionchanged:: 0.9 174 | 175 | Starting from version 0.9, `disable_concurrency` can disable both at Model 176 | level or instance level, depending on the passed object. 177 | Passing Model is useful in django commands, load data or fixtures, 178 | where instance should be used by default 179 | 180 | 181 | .. versionchanged:: 1.0 182 | 183 | Is now possible use `disable_concurrency` without any argument to disable 184 | concurrency on any Model. 185 | This features has been developed to be used in django commands 186 | 187 | 188 | .. versionchanged:: 1.1 189 | 190 | Does not raise an exception if a model not under concurrency management is passed as argument. 191 | 192 | examples 193 | ~~~~~~~~ 194 | 195 | .. code-block:: python 196 | 197 | @disable_concurrency() 198 | def recover_view(self, request, version_id, extra_context=None): 199 | return super().recover_view(request, 200 | version_id, 201 | extra_context) 202 | 203 | 204 | .. code-block:: python 205 | 206 | def test_recover(): 207 | deleted_list = revisions.get_deleted(ReversionConcurrentModel) 208 | delete_version = deleted_list.get(id=5) 209 | 210 | with disable_concurrency(ReversionConcurrentModel): 211 | deleted_version.revert() 212 | 213 | 214 | `concurrency_disable_increment()` 215 | --------------------------------- 216 | 217 | .. versionadded:: 1.1 218 | 219 | 220 | Context manager to temporary disable version increment. 221 | Concurrent save is still checked but no version increment is triggered, 222 | this creates 'shadow saves', 223 | 224 | It accepts both a Model or an instance as target. 225 | 226 | 227 | 228 | ------------ 229 | Templatetags 230 | ------------ 231 | 232 | 233 | .. templatefilter:: identity 234 | 235 | `identity` 236 | ---------- 237 | .. autofunction:: concurrency.templatetags.concurrency.identity 238 | 239 | 240 | .. templatefilter:: version 241 | 242 | `version` 243 | --------- 244 | .. autofunction:: concurrency.templatetags.concurrency.version 245 | 246 | 247 | 248 | .. templatefilter:: is_version 249 | 250 | `is_version` 251 | ------------ 252 | .. autofunction:: concurrency.templatetags.concurrency.is_version 253 | 254 | 255 | -------- 256 | Triggers 257 | -------- 258 | 259 | .. _triggerfactory: 260 | 261 | TriggerFactory 262 | -------------- 263 | 264 | .. versionadded:: 2.3 265 | 266 | .. autoclass:: concurrency.triggers.TriggerFactory 267 | 268 | .. seealso:: :setting:`TRIGGERS_FACTORY` 269 | 270 | 271 | 272 | ------------- 273 | Test Utilties 274 | ------------- 275 | 276 | .. _concurrencytestmixin: 277 | 278 | ConcurrencyTestMixin 279 | -------------------- 280 | .. autoclass:: concurrency.utils.ConcurrencyTestMixin 281 | 282 | 283 | 284 | 285 | .. _signining: 286 | 287 | --------- 288 | Signining 289 | --------- 290 | 291 | .. versionadded:: 0.5 292 | 293 | :ref:`concurrency.fields.VersionField` is 'displayed' in the Form using an :class:`django.forms.HiddenInput` widget, anyway to be sure that the version is not 294 | tampered with, its value is `signed`. The default VersionSigner is :class:`concurrency.forms.VersionFieldSigner` that simply 295 | extends :class:`django.core.signing.Signer`. If you want change your Signer you can set :setting:`CONCURRENCY_FIELD_SIGNER` in your settings 296 | 297 | :file:`mysigner.py` :: 298 | 299 | class DummySigner(): 300 | """ Dummy signer that simply returns the raw version value. (Simply do not sign it) """ 301 | def sign(self, value): 302 | return smart_str(value) 303 | 304 | def unsign(self, signed_value): 305 | return smart_str(signed_value) 306 | 307 | :file:`settings.py` :: 308 | 309 | CONCURRENCY_FIELD_SIGNER = "myapp.mysigner.DummySigner" 310 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | :tocdepth: 2 4 | 5 | .. _changes: 6 | 7 | 8 | Changelog 9 | ========= 10 | This section lists the biggest changes done on each release. 11 | 12 | .. contents:: 13 | :local: 14 | 15 | .. include:: ../CHANGES 16 | -------------------------------------------------------------------------------- /docs/cookbook.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | .. _cookbook: 3 | 4 | ======== 5 | Cookbook 6 | ======== 7 | 8 | .. contents:: 9 | :local: 10 | 11 | 12 | 13 | .. _import_data: 14 | 15 | Unable to import data ? 16 | ----------------------- 17 | 18 | Sometimes you need to temporary disable concurrency (ie during data imports) 19 | 20 | **Temporary disable per Model** 21 | 22 | .. code-block:: python 23 | 24 | from concurrency.api import disable_concurrency 25 | 26 | with disable_concurrency(instance): 27 | Model.object 28 | 29 | 30 | 31 | Add version management to new models 32 | ------------------------------------ 33 | 34 | :file:`models.py` 35 | 36 | .. code-block:: python 37 | 38 | from concurrency.fields import IntegerVersionField 39 | 40 | class ConcurrentModel( models.Model ): 41 | version = IntegerVersionField( ) 42 | 43 | :file:`tests.py` 44 | 45 | .. code-block:: python 46 | 47 | a = ConcurrentModel.objects.get(pk=1) 48 | b = ConcurrentModel.objects.get(pk=1) 49 | a.save() 50 | b.save() # this will raise ``RecordModifiedError`` 51 | 52 | 53 | Add version management to Django and/or plugged in applications models 54 | ---------------------------------------------------------------------- 55 | 56 | .. versionchanged:: 0.8 57 | 58 | 59 | Concurrency can work even with existing models, anyway if you are adding concurrency management to 60 | an existing database remember to edit the database's tables: 61 | 62 | :file:`your_app.models.py` 63 | 64 | .. code-block:: python 65 | 66 | from django.contrib.auth import User 67 | from concurrency.api import apply_concurrency_check 68 | 69 | apply_concurrency_check(User, 'version', IntegerVersionField) 70 | 71 | 72 | If used with Django>=1.7 remember to create a custom migration. 73 | 74 | 75 | 76 | Test Utilities 77 | -------------- 78 | 79 | :ref:`ConcurrencyTestMixin` offer a very simple test function for your existing models 80 | 81 | .. code-block:: python 82 | 83 | from concurrency.utils import ConcurrencyTestMixin 84 | from myproject.models import MyModel 85 | 86 | class MyModelTest(ConcurrencyTestMixin, TestCase): 87 | concurrency_model = TestModel0 88 | concurrency_kwargs = {'username': 'test'} 89 | 90 | 91 | Recover deleted record with django-reversion 92 | -------------------------------------------- 93 | 94 | Recovering deleted records with `diango-reversion`_ produces a 95 | ``RecordModifiedError``, because both `pk` and `version` are present in the 96 | object, and |concurrency| tries to load the record (that does not exist), 97 | which raises ``RecordModifiedError`` then. 98 | 99 | To avoid this simply disable concurrency, by using a mixin: 100 | 101 | .. code-block:: python 102 | 103 | class ConcurrencyVersionAdmin(reversion.admin.VersionAdmin): 104 | 105 | @disable_concurrency() 106 | def revision_view(self, request, object_id, version_id, extra_context=None): 107 | return super().revision_view(request, object_id, version_id, extra_context=None) 108 | 109 | @disable_concurrency() 110 | def recover_view(self, request, version_id, extra_context=None): 111 | return super().recover_view(request, version_id, extra_context) 112 | -------------------------------------------------------------------------------- /docs/faq.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | .. _faq: 3 | 4 | 5 | === 6 | FAQ 7 | === 8 | 9 | .. contents:: 10 | :local: 11 | 12 | 13 | 14 | I use Django-Rest-Framework and |concurrency| seems do not work 15 | --------------------------------------------------------------- 16 | Use :setting:`CONCURRENCY_IGNORE_DEFAULT` accordingly or be sure 17 | that serializer does not set `0` as initial value 18 | 19 | 20 | 21 | Just added |concurrency| to existing project and it does not work 22 | ----------------------------------------------------------------- 23 | 24 | Check that your records do not have `0` as version number 25 | and use :setting:`CONCURRENCY_IGNORE_DEFAULT` accordingly 26 | 27 | 28 | .. _south_support: 29 | 30 | South support ? 31 | --------------- 32 | South support has been removed after version 1.0 33 | when Django <1.6 support has been removed as well. 34 | 35 | If needed add these lines to your ``models.py``:: 36 | 37 | 38 | from south.modelsinspector import add_introspection_rules 39 | add_introspection_rules([], ["^concurrency\.fields\.IntegerVersionField"]) 40 | 41 | 42 | .. _update_fields: 43 | 44 | 45 | How is managed `update_fields` 46 | ------------------------------ 47 | 48 | It is possible to use `save(update_fields=...)` parameter without interfree with 49 | the concurrency check algorithm 50 | 51 | .. code-block:: python 52 | 53 | x1 = MyModel.objects.create(name='abc') 54 | x2 = MyModel.objects.get(pk=x1.pk) 55 | 56 | x1.save() 57 | x2.save(update_fields=['username']) # raise RecordModifiedError 58 | 59 | anyway this will NOT raise any error 60 | 61 | .. code-block:: python 62 | 63 | x1 = MyModel.objects.create(name='abc') 64 | x2 = MyModel.objects.get(pk=x1.pk) 65 | 66 | x1.save(update_fields=['username']) # skip update version number 67 | x2.save() # saved 68 | 69 | 70 | .. note:: `TriggerVersionField` will be always updated 71 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _fields: 4 | 5 | Fields 6 | ====== 7 | 8 | .. contents:: 9 | :local: 10 | 11 | .. _concurrency.fields.VersionField: 12 | 13 | VersionField 14 | ------------ 15 | .. autoclass:: concurrency.fields.VersionField 16 | 17 | 18 | IntegerVersionField 19 | ------------------- 20 | .. autoclass:: concurrency.fields.IntegerVersionField 21 | 22 | 23 | AutoIncVersionField 24 | ------------------- 25 | .. autoclass:: concurrency.fields.AutoIncVersionField 26 | 27 | 28 | .. _concurrency.fields.TriggerVersionField: 29 | 30 | 31 | TriggerVersionField 32 | ------------------- 33 | .. class:: concurrency.fields.TriggerVersionField 34 | 35 | 36 | This field use a database trigger to update the version field. 37 | Using this you can control external updates (ie using tools like phpMyAdmin, pgAdmin, SQLDeveloper). 38 | The trigger is automatically created during ``syncdb()`` 39 | or you can use the `triggers`_ management command. 40 | 41 | .. versionchanged:: 1.0 42 | 43 | .. warning:: Before |concurrency| 1.0 two triggers per field were created, if you are upgrading you must manually remove old triggers and recreate them using `triggers`_ management command 44 | 45 | `trigger_name` 46 | ~~~~~~~~~~~~~~ 47 | 48 | .. versionadded:: 1.0 49 | 50 | .. attribute:: TriggerVersionField.trigger_name 51 | 52 | Starting from 1.0 you can customize the name of the trigger created. 53 | Otherwise for each `TriggerVersionField` will be created two triggers named: 54 | 55 | 56 | .. code-block:: python 57 | 58 | 'concurrency_[DBTABLENAME]_[FIELDNAME]' 59 | 60 | .. warning:: Any name will be automatically prefixed with ``concurrency_`` 61 | 62 | 63 | .. _triggers: 64 | 65 | `triggers` management command 66 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 67 | 68 | Helper command to work with triggers: 69 | 70 | * ``list`` : list existing triggers for each database 71 | 72 | * ``drop`` : drop exisitng triggers 73 | 74 | * ``create`` : create required triggers 75 | 76 | example 77 | 78 | .. code-block:: bash 79 | 80 | sax@: (concurrency) django-concurrency [feature/triggers*] $ ./demo/manage.py triggers create 81 | DATABASE TRIGGERS 82 | default concurrency_concurrency_triggerconcurrentmodel_u 83 | 84 | 85 | .. _concurrency.fields.ConditionalVersionField: 86 | 87 | 88 | ConditionalVersionField 89 | ----------------------- 90 | 91 | .. versionadded:: 1.1 92 | 93 | 94 | This field allow to configure which fields trigger the version increment so to limit 95 | the scope of the concurrency checks. 96 | 97 | .. code-block:: python 98 | 99 | class User(models.Model): 100 | version = ConditionalVersionField() 101 | username = models.CharField(...) 102 | password = models.PasswordField(...) 103 | 104 | class ConcurrencyMeta: 105 | check_fields = ('username',) 106 | -------------------------------------------------------------------------------- /docs/globals.txt: -------------------------------------------------------------------------------- 1 | 2 | .. _pip: http://pip.openplans.org/ 3 | .. _PyPI: http://pypi.python.org/ 4 | .. _South: http://south.aeracode.org/ 5 | .. _diango-reversion: https://github.com/etianen/django-reversion 6 | .. _ol: http://sites.fas.harvard.edu/~cs265/papers/kung-1981.pdf 7 | .. _protocol: http://en.wikipedia.org/wiki/Optimistic_concurrency_control 8 | .. _issue_11313: https://code.djangoproject.com/ticket/11313 9 | 10 | .. |concurrency| replace:: **django-concurrency** 11 | .. |pkg| replace:: dja 12 | .. |version| replace:: 0.1 13 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | .. _index: 3 | 4 | ================== 5 | Django Concurrency 6 | ================== 7 | 8 | Overview 9 | ======== 10 | 11 | .. image:: https://img.shields.io/travis/saxix/django-concurrency/master.svg 12 | :target: http://travis-ci.org/saxix/django-concurrency/ 13 | :alt: Test status 14 | 15 | .. image:: https://codecov.io/github/saxix/django-concurrency/coverage.svg?branch=master 16 | :target: https://codecov.io/github/saxix/django-concurrency?branch=master 17 | :alt: Coverage 18 | 19 | 20 | 21 | django-concurrency is an optimistic locking library for Django Models 22 | 23 | It prevents users from doing concurrent editing in Django both from UI and from a 24 | django command. 25 | 26 | 27 | 28 | * easy to add to existing Models (just add :ref:`concurrency.fields.VersionField` ) 29 | * can be added with Django internal models (ie `auth.User` or `auth.Group`) 30 | * handle http post and standard python code (ie. django management commands) 31 | * complete test suite (:ref:`test_suite`) 32 | * Admin integration. Handle :ref:`actions ` and :ref:`list_editable ` (solves :django_issue:`11313`) 33 | * can handle external updates (see :ref:`concurrency.fields.TriggerVersionField`) 34 | 35 | How it works 36 | ============ 37 | 38 | 39 | Overview 40 | -------- 41 | 42 | |concurrency| works adding a :class:`concurrency.fields.VersionField` to each model, 43 | each time a record is saved the version number changes (the algorithm used depends 44 | on the implementation of :class:`concurrency.fields.VersionField` used (see :ref:`fields`). 45 | 46 | 47 | Each update is converted in the following SQL clause like: 48 | 49 | .. code-block:: sql 50 | 51 | UPDATE mymodel SET version=NEW_VERSION, ... WHERE id = PK AND version = VERSION_NUMBER 52 | 53 | 54 | Table Of Contents 55 | ================= 56 | 57 | .. toctree:: 58 | :maxdepth: 1 59 | 60 | install 61 | fields 62 | middleware 63 | admin 64 | api 65 | settings 66 | cookbook 67 | changes 68 | faq 69 | 70 | 71 | Links 72 | ===== 73 | 74 | * Project home page: https://github.com/saxix/django-concurrency 75 | * Issue tracker: https://github.com/saxix/django-concurrency/issues?sort 76 | * Download: http://pypi.python.org/pypi/django-concurrency/ 77 | * Docs: http://readthedocs.org/docs/django-concurrency/en/latest/ 78 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _help: 4 | 5 | Install 6 | ======= 7 | 8 | Using ``pip``:: 9 | 10 | pip install django-concurrency 11 | 12 | Go to https://github.com/saxix/django-concurrency if you need to download a package or clone the repo. 13 | 14 | 15 | |concurrency| does not need to be added into ``INSTALLED_APPS`` 16 | unless you want to run the tests or use the templatetags and/or admin integration 17 | 18 | 19 | .. _test_suite: 20 | 21 | ---------- 22 | Test suite 23 | ---------- 24 | 25 | |concurrency| comes with a set of tests that can simulate different scenarios 26 | 27 | * basic versioned model 28 | * inherited model 29 | * inherited model from abstract model 30 | * inherited model from external project model 31 | * django User model 32 | * models with custom save 33 | * proxy models 34 | * admin actions 35 | 36 | 37 | How to run the tests 38 | -------------------- 39 | 40 | .. code-block:: bash 41 | 42 | $ pip install tox 43 | $ tox 44 | -------------------------------------------------------------------------------- /docs/middleware.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _middleware: 4 | 5 | ConcurrencyMiddleware 6 | ===================== 7 | 8 | You can globally intercept :class:`RecordModifiedError ` 9 | adding :class:`ConcurrencyMiddleware ` to your :setting:`MIDDLEWARE_CLASSES`. 10 | Each time a :class:`RecordModifiedError ` is raised it goes up to the ConcurrencyMiddleware and the handler defined in 11 | :setting:`CONCURRENCY_HANDLER409` is invoked. 12 | 13 | **Example** 14 | 15 | ``settings.py`` 16 | 17 | .. code-block:: python 18 | 19 | MIDDLEWARE_CLASSES=('django.middleware.common.CommonMiddleware', 20 | 'concurrency.middleware.ConcurrencyMiddleware', 21 | 'django.contrib.sessions.middleware.SessionMiddleware', 22 | 'django.middleware.csrf.CsrfViewMiddleware', 23 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 24 | 'django.contrib.messages.middleware.MessageMiddleware') 25 | 26 | CONCURRENCY_HANDLER409 = 'demoproject.demoapp.views.conflict' 27 | CONCURRENCY_POLICY = 2 # CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL 28 | 29 | :file:`views.py` 30 | 31 | .. code-block:: python 32 | 33 | from diff_match_patch import diff_match_patch 34 | from concurrency.views import ConflictResponse 35 | from django.template import loader 36 | from django.utils.safestring import mark_safe 37 | from django.template.context import RequestContext 38 | 39 | def get_diff(current, stored): 40 | data = [] 41 | dmp = diff_match_patch() 42 | fields = current._meta.fields 43 | for field in fields: 44 | v1 = getattr(current, field.name, "") 45 | v2 = getattr(stored, field.name, "") 46 | diff = dmp.diff_main(unicode(v1), unicode(v2)) 47 | dmp.diff_cleanupSemantic(diff) 48 | html = dmp.diff_prettyHtml(diff) 49 | html = mark_safe(html) 50 | data.append((field, v1, v2, html)) 51 | return data 52 | 53 | def conflict(request, target=None, template_name='409.html'): 54 | template = loader.get_template(template_name) 55 | try: 56 | saved = target.__class__._default_manager.get(pk=target.pk) 57 | diff = get_diff(target, saved) 58 | except target.__class__.DoesNotExists: 59 | saved = None 60 | diff = None 61 | 62 | ctx = RequestContext(request, {'target': target, 63 | 'diff': diff, 64 | 'saved': saved, 65 | 'request_path': request.path}) 66 | return ConflictResponse(template.render(ctx)) 67 | 68 | 69 | 70 | :file:`409.html` 71 | 72 | .. code-block:: html 73 | 74 | {% load concurrency %} 75 | 76 | 77 | 80 | 83 | 86 | 89 | 90 | 91 | 92 | {% for field, current, stored, entry in diff %} 93 | {% if not field.primary_key and not field|is_version %} 94 | 95 | 98 | 101 | 104 | 107 | 108 | {% endif %} 109 | {% endfor %} 110 | 111 |
78 | Field 79 | 81 | Current 82 | 84 | Stored 85 | 87 | Diff 88 |
96 | {{ field.verbose_name }} 97 | 99 | {{ current }} 100 | 102 | {{ stored }} 103 | 105 | {{ entry }} 106 |
112 | 113 | If you want to use ConcurrencyMiddleware in the admin and you are using 114 | :class:`concurrency.admin.ConcurrentModelAdmin` remember to set your ModelAdmin to NOT 115 | use :class:`concurrency.forms.ConcurrentForm` 116 | 117 | .. code-block:: python 118 | 119 | from django import forms 120 | 121 | class MyModelAdmin(ConcurrentModelAdmin): 122 | form = forms.ModelForm # overrides default ConcurrentForm 123 | -------------------------------------------------------------------------------- /docs/requirements.pip: -------------------------------------------------------------------------------- 1 | sphinx 2 | django 3 | sphinx_issues 4 | -------------------------------------------------------------------------------- /docs/settings.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | .. _settings: 3 | 4 | ======== 5 | Settings 6 | ======== 7 | 8 | Here's a full list of all available settings, in alphabetical order, and their 9 | default values. 10 | 11 | .. note:: Each entry **MUST** have the prefix ``CONCURRENCY_`` when used in your settings.py 12 | 13 | 14 | .. setting:: CONCURRENCY_AUTO_CREATE_TRIGGERS 15 | 16 | AUTO_CREATE_TRIGGERS 17 | -------------------- 18 | .. versionadded:: 2.3 19 | 20 | Default: ``True`` 21 | 22 | If true automatically create triggers. 23 | To manually create triggers set `CONCURRENCY_AUTO_CREATE_TRIGGERS=False` and use :ref:`triggers` 24 | management command or create them manually using your DB client. 25 | 26 | Note:: This flag deprecates :setting:`MANUAL_TRIGGERS` 27 | 28 | 29 | .. setting:: CONCURRENCY_ENABLED 30 | 31 | ENABLED 32 | ------- 33 | .. versionadded:: 0.10 34 | 35 | Default: ``True`` 36 | 37 | enable/disable concurrency 38 | 39 | 40 | .. setting:: CONCURRENCY_CALLBACK 41 | 42 | CALLBACK 43 | -------- 44 | .. versionchanged:: 0.7 45 | 46 | Default: ``concurrency.views.callback`` 47 | 48 | Handler invoked when a conflict is raised. The default implementation 49 | simply raise :class:`RecordModifiedError ` 50 | 51 | Can be used to display the two version of the record and let the user to force 52 | the update or merge the values. 53 | 54 | .. setting:: CONCURRENCY_FIELD_SIGNER 55 | 56 | FIELD_SIGNER 57 | ------------ 58 | .. versionadded:: 0.5 59 | 60 | Default: ``concurrency.forms.VersionFieldSigner`` 61 | 62 | Class used to sign the version numbers. 63 | 64 | .. seealso:: :ref:`Signining` 65 | 66 | 67 | 68 | .. setting:: CONCURRENCY_HANDLER409 69 | 70 | HANDLER409 71 | ---------- 72 | .. versionadded:: 0.6 73 | 74 | Default: ``concurrency.views.conflict`` 75 | 76 | 77 | Handler to intercept :class:`RecordModifiedError ` 78 | into :class:`ConcurrencyMiddleware `. 79 | The default implementation (:ref:`handler409`) renders ``409.html`` 80 | while passing into the context the object that is going to be saved (``target``) 81 | 82 | .. seealso:: :ref:`middleware` 83 | 84 | 85 | 86 | .. setting:: CONCURRENCY_IGNORE_DEFAULT 87 | 88 | 89 | 90 | IGNORE_DEFAULT 91 | -------------- 92 | .. versionadded:: 1.2 93 | .. versionchanged:: 1.5 94 | 95 | Default: ``True`` 96 | 97 | .. seealso:: VERSION_FIELD_REQUIRED 98 | 99 | 100 | 101 | .. setting:: CONCURRENCY_VERSION_FIELD_REQUIRED 102 | 103 | 104 | VERSION_FIELD_REQUIRED 105 | ---------------------- 106 | .. versionadded:: 1.5 107 | 108 | Default: ``True`` 109 | 110 | Determines whether version number is mandatory in any save operation. 111 | Setting this flag to ``False`` can cause omitted version 112 | numbers to pass concurrency checks. 113 | 114 | 115 | .. setting:: CONCURRECY_MANUAL_TRIGGERS 116 | .. setting:: MANUAL_TRIGGERS 117 | 118 | MANUAL_TRIGGERS 119 | --------------- 120 | .. versionadded:: 1.0 121 | .. deprecated:: 2.3 122 | 123 | Default: ``False`` 124 | 125 | If false do not automatically create triggers, you can create them using :ref:`triggers` 126 | management command or manually using your DB client. 127 | 128 | 129 | 130 | 131 | .. setting:: CONCURRENCY_POLICY 132 | 133 | .. _list_editable_policies: 134 | 135 | POLICY 136 | ------ 137 | .. versionchanged:: 0.7 138 | 139 | Default: ``CONCURRENCY_LIST_EDITABLE_POLICY_SILENT`` 140 | 141 | 142 | ``CONCURRENCY_LIST_EDITABLE_POLICY_SILENT`` 143 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 144 | Used by admin's integrations to handle ``list_editable`` conflicts. 145 | Do not save conflicting records, continue and save all non-conflicting records, 146 | show a message to the user 147 | 148 | 149 | ``CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL`` 150 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 151 | Used by admin's integations to handle ``list_editable``. 152 | Stop at the first conflict and raise :class:`RecordModifiedError `. 153 | Note that if you want to use :class:`ConcurrencyMiddleware ` based conflict management you must set this flag. 154 | 155 | .. seealso:: :ref:`list_editable`, :ref:`middleware` 156 | 157 | 158 | 159 | .. setting:: CONCURRENCY_TRIGGERS_FACTORY 160 | .. setting:: TRIGGERS_FACTORY 161 | 162 | TRIGGERS_FACTORY 163 | ----------------- 164 | .. versionadded:: 2.3 165 | 166 | Default:: 167 | 168 | {'postgresql': "concurrency.triggers.PostgreSQL", 169 | 'mysql': "concurrency.triggers.MySQL", 170 | 'sqlite3': "concurrency.triggers.Sqlite3", 171 | 'sqlite': "concurrency.triggers.Sqlite3", 172 | } 173 | 174 | 175 | dict to customise :ref:`TriggerFactory`. Use this to customise the SQL clause to create triggers. 176 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | here = os.path.abspath(os.path.join(os.path.dirname(__file__))) 6 | 7 | 8 | def rel(*args): 9 | return os.path.join(here, *args) 10 | 11 | 12 | sys.path.insert(0, rel(os.pardir)) 13 | 14 | 15 | if __name__ == "__main__": 16 | sys.path.insert(0, "src") 17 | sys.path.insert(0, "tests/demoapp") 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 19 | 20 | from django.core.management import execute_from_command_line 21 | 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | # Regular configuration file (can be used as base in other projects, runs in CI) 2 | 3 | [mypy] 4 | # Modified in `tests.yml` 5 | incremental = true 6 | mypy_path = tests/demoapp 7 | ; 8 | ;# Strictness: 9 | ;allow_redefinition = true 10 | ;check_untyped_defs = true 11 | ;# TODO: add type args to all generics 12 | ;disallow_any_generics = false 13 | ;# TODO: fix `Any` subclassing in `typeshed/builtins.pyi` 14 | ;disallow_subclassing_any = false 15 | ;ignore_missing_imports = false 16 | strict = true 17 | ;local_partial_types = true 18 | ;warn_unreachable = true 19 | ; 20 | ;# TODO: update our output assertions to match a new syntax 21 | ;force_uppercase_builtins = true 22 | ;force_union_syntax = true 23 | ; 24 | ;disable_error_code = empty-body 25 | enable_error_code = 26 | deprecated, 27 | ignore-without-code 28 | 29 | 30 | show_traceback = true 31 | ; 32 | plugins = 33 | mypy_django_plugin.main, 34 | mypy.plugins.proper_plugin 35 | ; 36 | ;# Ignore incomplete hints in 3rd party stubs: 37 | ;[mypy-yaml.*] 38 | ;disallow_untyped_defs = false 39 | ;disallow_incomplete_defs = false 40 | ; 41 | ;[mypy-cryptography.*] 42 | ;ignore_errors = true 43 | ; 44 | ;# Our settings: 45 | [mypy.plugins.django-stubs] 46 | django_settings_module = demo.settings 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs>=0.4", 5 | "hatchling>=1.25", 6 | ] 7 | 8 | [project] 9 | name = "django-concurrency" 10 | description = "Optimistic lock implementation for Django. Prevents users from doing concurrent editing" 11 | readme = "README.md" 12 | license.file = "LICENSE" 13 | 14 | authors = [ 15 | { name = "sax", email = "s.apostolico@gmail.com" }, 16 | ] 17 | requires-python = ">=3.10" 18 | classifiers = [ 19 | "Environment :: Web Environment", 20 | "Framework :: Django", 21 | "Framework :: Django :: 3.2", 22 | "Framework :: Django :: 4.2", 23 | "Framework :: Django :: 5.2", 24 | "Intended Audience :: Developers", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | ] 32 | dynamic = [ 33 | "version", 34 | ] 35 | dependencies = [ ] 36 | urls.downloads = "https://github.com/saxix/django-concurrency" 37 | urls.homepage = "https://github.com/saxix/django-concurrency" 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "check-manifest", 42 | "django-webtest", 43 | "mock", 44 | "pre-commit", 45 | "psycopg2-binary", 46 | "pytest", 47 | "pytest-cov", 48 | "pytest-django", 49 | "pytest-echo", 50 | "pytest-pythonpath", 51 | "tox", 52 | ] 53 | 54 | dj = [ 55 | "django", 56 | ] 57 | 58 | [tool.hatch] 59 | build.hooks.vcs.version-file = "src/concurrency/version.py" 60 | version.source = "vcs" 61 | raw-options = { root = "../.." } 62 | 63 | [tool.hatch.build.targets.sdist] 64 | ignore-vcs = true 65 | include = [ 66 | "src/concurrency/**/*.*", 67 | ] 68 | 69 | [tool.hatch.build.targets.wheel] 70 | packages = [ "src/concurrency" ] 71 | 72 | [tool.ruff] 73 | target-version = "py39" 74 | line-length = 120 75 | exclude = [ 76 | "docs", 77 | "manage.py", 78 | "tests", 79 | ] 80 | format.preview = true 81 | format.docstring-code-line-length = 120 82 | format.docstring-code-format = true 83 | lint.select = [ 84 | "ALL", 85 | ] 86 | lint.ignore = [ 87 | "A", 88 | "ANN", 89 | "ARG", # Unused function argument: 90 | "B", 91 | "BLE", 92 | "COM812", 93 | "CPY", # Missing copyright notice 94 | "D", # docstring 95 | "DOC", # docstring 96 | "EM", 97 | "ERA", 98 | "EXE", 99 | "FBT", 100 | "G", 101 | "N", 102 | "PERF", 103 | "PGH", 104 | "PLC", 105 | "PLR", 106 | "PLW", 107 | "PT", 108 | "RUF", 109 | "S", 110 | "SIM", 111 | "SLF", 112 | "TRY", 113 | "UP", 114 | 115 | ] 116 | lint.per-file-ignores."docs/conf.py" = [ 117 | "A001", # 118 | "D100", # 119 | "ERA001", # 120 | "INP001", # 121 | ] 122 | lint.per-file-ignores."tests/**/*.py" = [ 123 | "A", 124 | "ANN", 125 | "ARG", 126 | "B", 127 | "BLE", 128 | "D", 129 | "DJ", 130 | "DTZ", 131 | "ERA", 132 | "F", 133 | "FBT", 134 | "FURB", 135 | "INP", 136 | "N", 137 | "PGH", 138 | "PLC", 139 | "PLR", 140 | "PLW", 141 | "PT", 142 | "PTH", 143 | "PYI", 144 | "RUF", 145 | "S", 146 | "SIM", 147 | "TC", 148 | "UP", 149 | ] 150 | lint.isort = { known-first-party = [ "concurrency" ] } 151 | lint.pylint.max-args = 7 152 | lint.pylint.max-branches = 14 153 | lint.pylint.max-nested-blocks = 8 154 | lint.preview = true 155 | 156 | [tool.pyproject-fmt] 157 | max_supported_python = "3.13" 158 | 159 | [tool.pytest.ini_options] 160 | django_find_project = false 161 | pythonpath = [ "./tests/demoapp/", "./src" ] 162 | DJANGO_SETTINGS_MODULE = "demo.settings" 163 | norecursedirs = [ "demo", ".tox" ] 164 | addopts = [ 165 | "--tb=short", 166 | "--capture=no", 167 | "--echo-version=django", 168 | "--echo-attr=django.conf.settings.DATABASES.default.ENGINE", 169 | "--cov=concurrency", 170 | "--cov-report=html", 171 | "--cov-report=xml", 172 | "--cov-config=tests/.coveragerc", 173 | ] 174 | python_files = "tests/test_*.py tests/**/test_*.py src/*.py" 175 | markers = [ 176 | "functional: mark a test as functional", 177 | "selenium: selenium test", 178 | "skip: skip test", 179 | ] 180 | 181 | [tool.coverage] 182 | run.source = [ 183 | "adminactions", 184 | ] 185 | run.dynamic_context = "test_function" 186 | run.branch = true 187 | run.parallel = true 188 | run.omit = [ 189 | "**/create_extra_permissions.py", 190 | ] 191 | run.plugins = [ 192 | "covdefaults", 193 | ] 194 | report.fail_under = 80 195 | report.show_missing = true 196 | report.exclude_lines = [ 197 | "pragma: no cover", 198 | ] 199 | html.show_contexts = true 200 | html.skip_covered = false 201 | paths.source = [ 202 | "src", 203 | ".tox*/*/lib/python*/site-packages", 204 | ".tox*/pypy*/site-packages", 205 | ".tox*\\*\\Lib\\site-packages", 206 | "*/src", 207 | "*\\src", 208 | ] 209 | 210 | [tool.uv] 211 | package = true 212 | -------------------------------------------------------------------------------- /src/concurrency/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "sax" 2 | 3 | VERSION = __version__ = "2.5.0" 4 | NAME = "django-concurrency" 5 | -------------------------------------------------------------------------------- /src/concurrency/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models import Model 4 | 5 | from concurrency.config import conf 6 | from concurrency.core import get_version_fieldname # _wrap_model_save 7 | from concurrency.exceptions import RecordModifiedError 8 | 9 | __all__ = [ 10 | "RecordModifiedError", 11 | "apply_concurrency_check", 12 | "concurrency_disable_increment", 13 | "disable_concurrency", 14 | "get_revision_of_object", 15 | "get_version", 16 | "get_version_fieldname", 17 | "get_version_fieldname", 18 | "is_changed", 19 | ] 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def get_revision_of_object(obj): 25 | """ 26 | returns teh version of the passed object 27 | 28 | @param obj: 29 | @return: 30 | """ 31 | return getattr(obj, get_version_fieldname(obj)) 32 | 33 | 34 | def is_changed(obj) -> bool: 35 | """ 36 | returns True if `obj` is changed or deleted on the database 37 | :param obj: 38 | :return: 39 | """ 40 | revision_field = get_version_fieldname(obj) 41 | version = get_revision_of_object(obj) 42 | return not obj.__class__.objects.filter(**{obj._meta.pk.name: obj.pk, revision_field: version}).exists() 43 | 44 | 45 | def get_version(model_instance, version): 46 | """ 47 | try go load from the database one object with specific version 48 | 49 | :param model_instance: instance in memory 50 | :param version: version number 51 | :return: 52 | """ 53 | version_field = get_version_fieldname(model_instance) 54 | kwargs = {"pk": model_instance.pk, version_field: version} 55 | return model_instance.__class__.objects.get(**kwargs) 56 | 57 | 58 | def apply_concurrency_check(model, fieldname, versionclass) -> None: 59 | if hasattr(model, "_concurrencymeta"): 60 | return 61 | 62 | logger.debug(f"Applying concurrency check to {model}") 63 | 64 | ver = versionclass() 65 | 66 | ver.contribute_to_class(model, fieldname) 67 | model._concurrencymeta.field = ver 68 | 69 | from concurrency.fields import class_prepared_concurrency_handler 70 | 71 | class_prepared_concurrency_handler(model) 72 | 73 | # if not model._concurrencymeta.versioned_save: 74 | # versionclass._wrap_model_save(model) 75 | 76 | 77 | class concurrency_disable_increment: 78 | def __init__(self, model) -> None: 79 | self.model = model 80 | self.old_value = model._concurrencymeta.increment 81 | 82 | def __enter__(self): 83 | if isinstance(self.model, Model): 84 | self.old_value, self.model._concurrency_disable_increment = ( 85 | getattr(self.model, "_concurrency_disable_increment", False), 86 | True, 87 | ) 88 | self.model._concurrency_disabled = True 89 | else: 90 | self.old_value, self.model._concurrencymeta.increment = ( 91 | self.model._concurrencymeta.increment, 92 | False, 93 | ) 94 | 95 | def __exit__(self, *args, **kwds): 96 | if isinstance(self.model, Model): 97 | self.model._concurrency_disable_increment = self.old_value 98 | else: 99 | self.model._concurrencymeta.increment = self.old_value 100 | 101 | def __call__(self, func): 102 | def wrapper(*args, **kwds): 103 | with self: 104 | return func(*args, **kwds) 105 | 106 | return wrapper 107 | 108 | 109 | class disable_concurrency: 110 | """ 111 | temporary disable concurrency 112 | 113 | can be used either as contextmanager or decorator. 114 | It can applied to model instances, model class of globally. 115 | 116 | :param model: model instance, model class or None 117 | """ 118 | 119 | def __init__(self, model=None) -> None: 120 | self.model = model 121 | self.old_value = conf.ENABLED 122 | self.concurrency_managed = (model is None) or hasattr(model, "_concurrencymeta") 123 | 124 | def start(self) -> None: 125 | if not self.concurrency_managed: 126 | return 127 | if self.model is None: 128 | self.old_value, conf.ENABLED = conf.ENABLED, False 129 | elif isinstance(self.model, Model): 130 | self.old_value, self.model._concurrency_disabled = ( 131 | getattr(self.model, "_concurrency_disabled", False), 132 | True, 133 | ) 134 | self.model._concurrency_disabled = True 135 | else: 136 | self.old_value, self.model._concurrencymeta.enabled = ( 137 | self.model._concurrencymeta.enabled, 138 | False, 139 | ) 140 | 141 | def finish(self) -> None: 142 | if not self.concurrency_managed: 143 | return 144 | if self.model is None: 145 | conf.ENABLED = self.old_value 146 | elif isinstance(self.model, Model): 147 | self.model._concurrency_disabled = self.old_value 148 | else: 149 | self.model._concurrencymeta.enabled = self.old_value 150 | 151 | def __enter__(self): 152 | self.start() 153 | 154 | def __exit__(self, *args, **kwds): 155 | self.finish() 156 | 157 | def __call__(self, func): 158 | def wrapper(*args, **kwds): 159 | with self: 160 | return func(*args, **kwds) 161 | 162 | return wrapper 163 | -------------------------------------------------------------------------------- /src/concurrency/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ConcurrencyConfig(AppConfig): 5 | name = "concurrency" 6 | verbose = "Django Concurrency" 7 | -------------------------------------------------------------------------------- /src/concurrency/compat.py: -------------------------------------------------------------------------------- 1 | from django.template.exceptions import TemplateDoesNotExist # noqa 2 | from django.urls.utils import get_callable # noqa 3 | 4 | concurrency_param_name = "form-_concurrency_version" 5 | -------------------------------------------------------------------------------- /src/concurrency/config.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.test.signals import setting_changed 3 | from django.utils.module_loading import import_string 4 | 5 | from .compat import get_callable 6 | 7 | # List Editable Policy 8 | # 1 do not save updated records, save others, show message to the user 9 | # 2 abort whole transaction 10 | CONCURRENCY_LIST_EDITABLE_POLICY_SILENT = 1 11 | CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL = 2 12 | CONCURRENCY_POLICY_RAISE = 4 13 | CONCURRENCY_POLICY_CALLBACK = 8 14 | 15 | LIST_EDITABLE_POLICIES = [ 16 | CONCURRENCY_LIST_EDITABLE_POLICY_SILENT, 17 | CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL, 18 | ] 19 | 20 | 21 | class AppSettings: 22 | defaults = { 23 | "ENABLED": True, 24 | "AUTO_CREATE_TRIGGERS": True, 25 | "FIELD_SIGNER": "concurrency.forms.VersionFieldSigner", 26 | "POLICY": CONCURRENCY_LIST_EDITABLE_POLICY_SILENT, 27 | "CALLBACK": "concurrency.views.callback", 28 | "HANDLER409": "concurrency.views.conflict", 29 | "VERSION_FIELD_REQUIRED": True, 30 | "TRIGGERS_FACTORY": { 31 | "postgresql": "concurrency.triggers.PostgreSQL", 32 | "mysql": "concurrency.triggers.MySQL", 33 | "sqlite3": "concurrency.triggers.Sqlite3", 34 | "sqlite": "concurrency.triggers.Sqlite3", 35 | }, 36 | } 37 | 38 | def __init__(self, prefix) -> None: 39 | """ 40 | Loads our settings from django.conf.settings, applying defaults for any 41 | that are omitted. 42 | """ 43 | self.prefix = prefix 44 | from django.conf import settings 45 | 46 | for name, default in self.defaults.items(): 47 | prefix_name = (self.prefix + "_" + name).upper() 48 | value = getattr(settings, prefix_name, default) 49 | self._set_attr(prefix_name, value) 50 | setattr(settings, prefix_name, value) 51 | setting_changed.send(self.__class__, setting=prefix_name, value=value, enter=True) 52 | 53 | setting_changed.connect(self._handler) 54 | 55 | def _set_attr(self, prefix_name, value) -> None: 56 | name = prefix_name[1 + len(self.prefix) :] 57 | if name == "IGNORE_DEFAULT": 58 | raise ImproperlyConfigured( 59 | "IGNORE_DEFAULT has been removed in django-concurrency 1.5. Use VERSION_FIELD_REQUIRED instead" 60 | ) 61 | if name == "CALLBACK": 62 | if isinstance(value, str): 63 | func = get_callable(value) 64 | elif callable(value): 65 | func = value 66 | else: 67 | msg = f"{value} is not a valid value for `CALLBACK`. It must be a callable or a fullpath to callable. " 68 | raise ImproperlyConfigured(msg) 69 | self._callback = func 70 | elif name == "TRIGGERS_FACTORY": 71 | original = dict(value) 72 | for k, v in original.items(): 73 | try: 74 | value[k] = import_string(v) 75 | except ImportError as e: 76 | raise ImproperlyConfigured(f"Unable to load {k} TriggerFactory. Invalid fqn '{v}': {e}") 77 | 78 | setattr(self, name, value) 79 | 80 | def _handler(self, sender, setting, value, **kwargs) -> None: 81 | """ 82 | handler for ``setting_changed`` signal. 83 | 84 | @see :ref:`django:setting-changed`_ 85 | """ 86 | if setting.startswith(self.prefix): 87 | self._set_attr(setting, value) 88 | 89 | 90 | conf = AppSettings("CONCURRENCY") 91 | -------------------------------------------------------------------------------- /src/concurrency/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging import NullHandler 3 | 4 | from concurrency.config import conf 5 | 6 | logging.getLogger("concurrency").addHandler(NullHandler()) 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | __all__ = [] 11 | 12 | 13 | def get_version_fieldname(obj): 14 | return obj._concurrencymeta.field.attname 15 | 16 | 17 | def _set_version(obj, version) -> None: 18 | """ 19 | Set the given version on the passed object 20 | 21 | This function should be used with 'raw' values, any type conversion should be managed in 22 | VersionField._set_version_value(). This is needed for future enhancement of concurrency. 23 | """ 24 | obj._concurrencymeta.field._set_version_value(obj, version) 25 | 26 | 27 | def _select_lock(model_instance, version_value=None) -> None: 28 | if not conf.ENABLED: 29 | return 30 | 31 | version_field = model_instance._concurrencymeta.field 32 | value = version_value or getattr(model_instance, version_field.name) 33 | is_versioned = value != version_field.get_default() 34 | 35 | if model_instance.pk is not None and is_versioned: 36 | kwargs = {"pk": model_instance.pk, version_field.name: value} 37 | entry = model_instance.__class__._base_manager.filter(**kwargs) 38 | 39 | if not entry: 40 | logger.debug( 41 | f"Conflict detected on `{model_instance}` pk:`{model_instance.pk}`, version `{value}` not found" 42 | ) 43 | conf._callback(model_instance) 44 | else: # pragma: no cover 45 | pass 46 | else: # pragma: no cover 47 | pass 48 | 49 | 50 | class ConcurrencyOptions: 51 | field = None 52 | versioned_save = False 53 | manually = False 54 | enabled = True 55 | base = None 56 | check_fields = None 57 | ignore_fields = None 58 | skip = False 59 | increment = True 60 | initial = None 61 | triggers = [] 62 | -------------------------------------------------------------------------------- /src/concurrency/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import SuspiciousOperation, ValidationError 2 | from django.db import DatabaseError 3 | from django.utils.translation import gettext as _ 4 | 5 | 6 | class VersionChangedError(ValidationError): 7 | pass 8 | 9 | 10 | class RecordModifiedError(DatabaseError): 11 | def __init__(self, *args, **kwargs) -> None: 12 | self.target = kwargs.pop("target") 13 | super().__init__(*args, **kwargs) 14 | 15 | 16 | class VersionError(SuspiciousOperation): 17 | def __init__(self, message=None, code=None, params=None, *args, **kwargs) -> None: 18 | self.message = message or _("Version number is missing or has been tampered with") 19 | -------------------------------------------------------------------------------- /src/concurrency/forms.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django import forms 4 | from django.core.exceptions import ( 5 | NON_FIELD_ERRORS, 6 | ImproperlyConfigured, 7 | ValidationError, 8 | ) 9 | from django.core.signing import BadSignature, Signer 10 | from django.forms import HiddenInput, ModelForm 11 | from django.utils.safestring import mark_safe 12 | from django.utils.translation import gettext as _ 13 | 14 | from concurrency.config import conf 15 | from concurrency.core import _select_lock 16 | from concurrency.exceptions import RecordModifiedError, VersionError 17 | 18 | 19 | class ConcurrentForm(ModelForm): 20 | """Simple wrapper to ModelForm that try to mitigate some concurrency error. 21 | Note that is always possible have a RecordModifiedError in model.save(). 22 | Statistically form.clean() should catch most of the concurrent editing, but 23 | is good to catch RecordModifiedError in the view too. 24 | """ 25 | 26 | def clean(self): 27 | try: 28 | if self.instance.pk: 29 | _select_lock( 30 | self.instance, 31 | self.cleaned_data[self.instance._concurrencymeta.field.name], 32 | ) 33 | 34 | except RecordModifiedError: 35 | self._update_errors(ValidationError({NON_FIELD_ERRORS: self.error_class([_("Record Modified")])})) 36 | 37 | return super().clean() 38 | 39 | 40 | class VersionWidget(HiddenInput): 41 | """ 42 | Widget that show the revision number using
43 | 44 | Usually VersionField use `HiddenInput` as Widget to minimize the impact on the 45 | forms, in the Admin this produce a side effect to have the label *Version* without 46 | any value, you should use this widget to display the current revision number 47 | """ 48 | 49 | def format_value(self, value): 50 | if value: 51 | value = str(value) 52 | return value 53 | 54 | _format_value = format_value 55 | 56 | def render(self, name, value, attrs=None): 57 | ret = super().render(name, value, attrs) 58 | label = "" 59 | if isinstance(value, SignedValue): 60 | label = str(value).split(":")[0] 61 | elif value is not None: 62 | label = str(value) 63 | 64 | return mark_safe(f"{ret}
{label}
") 65 | 66 | 67 | class VersionFieldSigner(Signer): 68 | def sign(self, value): 69 | if not value: 70 | return None 71 | return super().sign(value) 72 | 73 | 74 | def get_signer(): 75 | path = conf.FIELD_SIGNER 76 | i = path.rfind(".") 77 | module, attr = path[:i], path[i + 1 :] 78 | try: 79 | mod = import_module(module) 80 | except ImportError as e: 81 | msg = f'Error loading concurrency signer {module}: "{e}"' 82 | raise ImproperlyConfigured(msg) 83 | try: 84 | signer_class = getattr(mod, attr) 85 | except AttributeError: # pragma: no cover 86 | msg = f'Module "{module}" does not define a valid signer named "{attr}"' 87 | raise ImproperlyConfigured(msg) 88 | return signer_class() 89 | 90 | 91 | class SignedValue: 92 | def __init__(self, value) -> None: 93 | self.value = value 94 | 95 | def __repr__(self) -> str: 96 | if self.value: 97 | return str(self.value) 98 | return "" 99 | 100 | 101 | class VersionField(forms.IntegerField): 102 | widget = HiddenInput # Default widget to use when rendering this type of Field. 103 | hidden_widget = HiddenInput # Default widget to use when rendering this as "hidden". 104 | 105 | def __init__(self, *args, **kwargs) -> None: 106 | self._signer = kwargs.pop("signer", get_signer()) 107 | kwargs.pop("min_value", None) 108 | kwargs.pop("max_value", None) 109 | kwargs["required"] = True 110 | kwargs["initial"] = None 111 | kwargs.setdefault("widget", HiddenInput) 112 | super().__init__(*args, **kwargs) 113 | 114 | def bound_data(self, data, initial): 115 | return SignedValue(data) 116 | 117 | def prepare_value(self, value): 118 | if isinstance(value, SignedValue): 119 | return value 120 | if value is None: 121 | return "" 122 | return SignedValue(self._signer.sign(value)) 123 | 124 | def to_python(self, value): 125 | try: 126 | if value not in {None, "", "None"}: 127 | return int(self._signer.unsign(str(value))) 128 | return 0 129 | except (BadSignature, ValueError): 130 | raise VersionError(value) 131 | 132 | def widget_attrs(self, widget): 133 | return {} 134 | -------------------------------------------------------------------------------- /src/concurrency/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # django-concurrency language file 2 | # Copyright (C) 2012 3 | # This file is distributed under the same license as the django-concurrency package. 4 | # 5 | #, fuzzy 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2013-06-30 10:46+0700\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 19 | 20 | #: admin.py:211 21 | #, python-format 22 | msgid "No %(name)s were changed due conflict errors" 23 | msgstr "" 24 | 25 | #: admin.py:215 26 | msgid "Record with pk `{0}` has been modified and was not updated" 27 | msgid_plural "Records `{0}` have been modified and were not updated" 28 | msgstr[0] "" 29 | msgstr[1] "" 30 | 31 | #: admin.py:222 32 | #, python-format 33 | msgid "%(count)s %(name)s was changed successfully." 34 | msgid_plural "%(count)s %(name)s were changed successfully." 35 | msgstr[0] "" 36 | msgstr[1] "" 37 | 38 | #: core.py:53 39 | msgid "Record has been modified or no version value passed" 40 | msgstr "" 41 | 42 | #: core.py:57 43 | #, python-format 44 | msgid "Version field is set (%s) but record has not `pk`." 45 | msgstr "" 46 | 47 | #: exceptions.py:24 48 | msgid "Version number is missing or has been tampered with" 49 | msgstr "" 50 | 51 | #: fields.py:24 52 | msgid "record revision number" 53 | msgstr "" 54 | 55 | #: forms.py:27 tests/admin_edit.py:49 tests/admin_edit.py:112 56 | #: tests/forms.py:131 57 | msgid "Record Modified" 58 | msgstr "" 59 | 60 | #: templates/concurrency/delete_selected_confirmation.html:8 61 | #: tests/templates/base.html:45 62 | msgid "Home" 63 | msgstr "" 64 | 65 | #: templates/concurrency/delete_selected_confirmation.html:11 66 | msgid "Delete multiple objects" 67 | msgstr "" 68 | 69 | #: templates/concurrency/delete_selected_confirmation.html:18 70 | #, python-format 71 | msgid "" 72 | "Deleting the selected %(objects_name)s would result in deleting related " 73 | "objects, but your account doesn't have permission to delete the following " 74 | "types of objects:" 75 | msgstr "" 76 | 77 | #: templates/concurrency/delete_selected_confirmation.html:26 78 | #, python-format 79 | msgid "" 80 | "Deleting the selected %(objects_name)s would require deleting the following " 81 | "protected related objects:" 82 | msgstr "" 83 | 84 | #: templates/concurrency/delete_selected_confirmation.html:34 85 | #, python-format 86 | msgid "" 87 | "Are you sure you want to delete the selected %(objects_name)s? All of the " 88 | "following objects and their related items will be deleted:" 89 | msgstr "" 90 | 91 | #: templates/concurrency/delete_selected_confirmation.html:45 92 | msgid "Yes, I'm sure" 93 | msgstr "" 94 | 95 | #: tests/templates/base.html:28 96 | msgid "Welcome," 97 | msgstr "" 98 | 99 | #: tests/templates/base.html:33 100 | msgid "Documentation" 101 | msgstr "" 102 | 103 | #: tests/templates/base.html:35 104 | msgid "Change password" 105 | msgstr "" 106 | 107 | #: tests/templates/base.html:36 108 | msgid "Log out" 109 | msgstr "" 110 | -------------------------------------------------------------------------------- /src/concurrency/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/src/concurrency/management/__init__.py -------------------------------------------------------------------------------- /src/concurrency/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/src/concurrency/management/commands/__init__.py -------------------------------------------------------------------------------- /src/concurrency/management/commands/triggers.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import django 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.core.management.base import BaseCommand 6 | from django.db import connections 7 | from django.db.transaction import atomic 8 | 9 | from concurrency.triggers import create_triggers, drop_triggers, get_triggers 10 | 11 | 12 | def _add_subparser(subparsers, parser, name, help) -> None: 13 | if django.VERSION >= (2, 1): 14 | subparsers.add_parser(name, help=help) 15 | else: 16 | subparsers.add_parser(name, cmd=parser, help=help) 17 | 18 | 19 | class Command(BaseCommand): 20 | args = "" 21 | help = "register Report classes and create one ReportConfiguration per each" 22 | 23 | requires_system_checks = [] 24 | 25 | def add_arguments(self, parser) -> None: 26 | """ 27 | Entry point for subclassed commands to add custom arguments. 28 | """ 29 | subparsers = parser.add_subparsers(help="sub-command help", dest="command") 30 | 31 | add_parser = partial(_add_subparser, subparsers, parser) 32 | 33 | add_parser("list", help="list concurrency triggers") 34 | add_parser("drop", help="drop concurrency triggers") 35 | add_parser("create", help="create concurrency triggers") 36 | 37 | parser.add_argument( 38 | "-d", 39 | "--database", 40 | action="store", 41 | dest="database", 42 | default=None, 43 | help="limit to this database", 44 | ) 45 | 46 | parser.add_argument( 47 | "-t", 48 | "--trigger", 49 | action="store", 50 | dest="trigger", 51 | default=None, 52 | help="limit to this trigger name", 53 | ) 54 | 55 | def _list(self, databases) -> None: 56 | for alias, triggers in get_triggers(databases).items(): 57 | self.stdout.write(f"Database: {alias}") 58 | for trigger in triggers: 59 | self.stdout.write(f" {trigger}") 60 | self.stdout.write("") 61 | 62 | def handle(self, *args, **options) -> None: 63 | cmd = options["command"] 64 | database = options["database"] 65 | databases = list(connections) if database is None else [database] 66 | 67 | with atomic(): 68 | try: 69 | if cmd == "list": 70 | self._list(databases) 71 | elif cmd == "create": 72 | for alias, triggers in create_triggers(databases).items(): 73 | self.stdout.write(f"Database: {alias}") 74 | for trigger in triggers: 75 | self.stdout.write(f" Created {trigger[2]} for {trigger[1]}") 76 | self.stdout.write("") 77 | elif cmd == "drop": 78 | for alias, triggers in drop_triggers(*databases).items(): 79 | self.stdout.write(f"Database: {alias}") 80 | for trigger in triggers: 81 | self.stdout.write(f" Dropped {trigger[2]}") 82 | self.stdout.write("") 83 | else: # pragma: no cover 84 | raise Exception 85 | except ImproperlyConfigured as e: # pragma: no cover 86 | self.stdout.write(self.style.ERROR(e)) 87 | -------------------------------------------------------------------------------- /src/concurrency/middleware.py: -------------------------------------------------------------------------------- 1 | from django.core.signals import got_request_exception 2 | from django.urls.utils import get_callable 3 | 4 | from concurrency.config import conf 5 | from concurrency.exceptions import RecordModifiedError 6 | 7 | 8 | class ConcurrencyMiddleware: 9 | """Intercept :ref:`RecordModifiedError` and invoke a callable defined in 10 | :setting:`CONCURRECY_HANDLER409` passing the request and the object. 11 | 12 | """ 13 | 14 | def __init__(self, get_response=None) -> None: 15 | self.get_response = get_response 16 | 17 | def __call__(self, request): 18 | return self.get_response(request) 19 | 20 | def process_exception(self, request, exception): 21 | if isinstance(exception, RecordModifiedError): 22 | got_request_exception.send(sender=self, request=request) 23 | callback = get_callable(conf.HANDLER409) 24 | return callback(request, target=exception.target) 25 | return None 26 | # pragma: no cover 27 | -------------------------------------------------------------------------------- /src/concurrency/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/src/concurrency/models.py -------------------------------------------------------------------------------- /src/concurrency/templates/concurrency/delete_selected_confirmation.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %}{% load admin_urls concurrency i18n l10n %} 2 | 3 | {% block breadcrumbs %} 4 | 10 | {% endblock breadcrumbs %} 11 | 12 | {% block content %} 13 | {% if perms_lacking or protected %} 14 | {% if perms_lacking %} 15 |

{% blocktranslate %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}

16 |
    17 | {% for obj in perms_lacking %} 18 |
  • {{ obj }}
  • 19 | {% endfor %} 20 |
21 | {% endif %} 22 | {% if protected %} 23 |

{% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}

24 |
    25 | {% for obj in protected %} 26 |
  • {{ obj }}
  • 27 | {% endfor %} 28 |
29 | {% endif %} 30 | {% else %} 31 |

{% blocktranslate %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktranslate %}

32 | {% for deletable_object in deletable_objects %} 33 |
    {{ deletable_object|unordered_list }}
34 | {% endfor %} 35 |
{% csrf_token %} 36 |
37 | {% for obj in queryset %} 38 | 39 | {% endfor %} 40 | 41 | 42 | 43 |
44 |
45 | {% endif %} 46 | {% endblock content %} 47 | -------------------------------------------------------------------------------- /src/concurrency/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/src/concurrency/templatetags/__init__.py -------------------------------------------------------------------------------- /src/concurrency/templatetags/concurrency.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from django.templatetags.l10n import unlocalize 3 | from django.utils.safestring import mark_safe 4 | 5 | from concurrency.api import get_revision_of_object 6 | from concurrency.fields import VersionField 7 | 8 | register = Library() 9 | 10 | 11 | @register.filter 12 | def identity(obj): 13 | """ 14 | returns a string representing "," of the passed object 15 | """ 16 | if hasattr(obj, "_concurrencymeta"): 17 | return mark_safe(f"{unlocalize(obj.pk)},{get_revision_of_object(obj)}") 18 | return mark_safe(unlocalize(obj.pk)) 19 | 20 | 21 | @register.filter 22 | def version(obj): 23 | """ 24 | returns the value of the VersionField of the passed object 25 | """ 26 | return get_revision_of_object(obj) 27 | 28 | 29 | @register.filter 30 | def is_version(field): 31 | """ 32 | returns True if passed argument is a VersionField instance 33 | """ 34 | return isinstance(field, VersionField) 35 | -------------------------------------------------------------------------------- /src/concurrency/triggers.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from django.apps import apps 4 | from django.db import connections, router 5 | from django.db.utils import DatabaseError 6 | 7 | # from .fields import _TRIGGERS # noqa 8 | 9 | 10 | class TriggerRegistry: 11 | _fields = [] 12 | 13 | def append(self, field) -> None: 14 | self._fields.append([field.model._meta.app_label, field.model.__name__]) 15 | 16 | def __iter__(self): 17 | return iter(self._fields) 18 | 19 | def __contains__(self, field) -> bool: 20 | target = [field.model._meta.app_label, field.model.__name__] 21 | return target in self._fields 22 | 23 | 24 | _TRIGGERS = TriggerRegistry() 25 | 26 | 27 | def get_trigger_name(field) -> str: 28 | """ 29 | 30 | :param field: Field instance 31 | :return: unicode 32 | """ 33 | name = field._trigger_name or f"{field.model._meta.db_table}_{field.name}" 34 | return f"concurrency_{name}" 35 | 36 | 37 | def get_triggers(databases=None): 38 | if databases is None: 39 | databases = list(connections) 40 | 41 | ret = {} 42 | for alias in databases: 43 | connection = connections[alias] 44 | f = factory(connection) 45 | r = f.get_list() 46 | ret[alias] = r 47 | return ret 48 | 49 | 50 | def drop_triggers(*databases): 51 | global _TRIGGERS 52 | ret = defaultdict(list) 53 | for app_label, model_name in _TRIGGERS: 54 | model = apps.get_model(app_label, model_name) 55 | field = model._concurrencymeta.field 56 | alias = router.db_for_write(model) 57 | if alias in databases: 58 | connection = connections[alias] 59 | f = factory(connection) 60 | f.drop(field) 61 | field._trigger_exists = False 62 | ret[alias].append([model, field, field.trigger_name]) 63 | else: # pragma: no cover 64 | pass 65 | return ret 66 | 67 | 68 | def create_triggers(databases): 69 | global _TRIGGERS 70 | ret = defaultdict(list) 71 | 72 | for app_label, model_name in _TRIGGERS: 73 | model = apps.get_model(app_label, model_name) 74 | field = model._concurrencymeta.field 75 | storage = model._concurrencymeta.triggers 76 | alias = router.db_for_write(model) 77 | if (alias in databases) and field not in storage: 78 | storage.append(field) 79 | connection = connections[alias] 80 | f = factory(connection) 81 | f.create(field) 82 | ret[alias].append([model, field, field.trigger_name]) 83 | else: # pragma: no cover 84 | pass 85 | 86 | return ret 87 | 88 | 89 | class TriggerFactory: 90 | """ 91 | Abstract Factory class to create triggers. 92 | Implemementations need to set the following attributes 93 | 94 | `update_clause`, `drop_clause` and `list_clause` 95 | 96 | Those will be formatted using standard python `format()` as:: 97 | 98 | self.update_clause.format(trigger_name=field.trigger_name, 99 | opts=field.model._meta, 100 | field=field) 101 | So as example:: 102 | 103 | update_clause = \"\"\"CREATE TRIGGER {trigger_name} 104 | AFTER UPDATE ON {opts.db_table} 105 | BEGIN UPDATE {opts.db_table} 106 | SET {field.column} = {field.column}+1 107 | WHERE {opts.pk.column} = NEW.{opts.pk.column}; 108 | END; 109 | \"\"\" 110 | 111 | """ 112 | 113 | update_clause = "" 114 | drop_clause = "" 115 | list_clause = "" 116 | 117 | def __init__(self, connection) -> None: 118 | self.connection = connection 119 | 120 | def get_trigger(self, field): 121 | if field.trigger_name in self.get_list(): 122 | return field.trigger_name 123 | return None 124 | 125 | def create(self, field) -> None: 126 | if field.trigger_name not in self.get_list(): 127 | stm = self.update_clause.format(trigger_name=field.trigger_name, opts=field.model._meta, field=field) 128 | try: 129 | self.connection.cursor().execute(stm) 130 | except BaseException as exc: # pragma: no cover 131 | msg = f"""Error executing: 132 | {stm} 133 | {exc}""" 134 | raise DatabaseError(msg) 135 | else: # pragma: no cover 136 | pass 137 | field._trigger_exists = True 138 | 139 | def drop(self, field): 140 | opts = field.model._meta 141 | ret = [] 142 | stm = self.drop_clause.format(trigger_name=field.trigger_name, opts=opts, field=field) 143 | self.connection.cursor().execute(stm) 144 | ret.append(field.trigger_name) 145 | return ret 146 | 147 | def _list(self): 148 | cursor = self.connection.cursor() 149 | cursor.execute(self.list_clause) 150 | return cursor.fetchall() 151 | 152 | def get_list(self): 153 | return sorted([m[0] for m in self._list()]) 154 | 155 | 156 | class Sqlite3(TriggerFactory): 157 | drop_clause = """DROP TRIGGER IF EXISTS {trigger_name};""" 158 | 159 | update_clause = """CREATE TRIGGER {trigger_name} 160 | AFTER UPDATE ON {opts.db_table} 161 | BEGIN UPDATE {opts.db_table} SET {field.column} = {field.column}+1 WHERE {opts.pk.column} = NEW.{opts.pk.column}; 162 | END;""" 163 | 164 | list_clause = "select name from sqlite_master where type='trigger';" 165 | 166 | 167 | class PostgreSQL(TriggerFactory): 168 | drop_clause = r"""DROP TRIGGER IF EXISTS {trigger_name} ON {opts.db_table};""" 169 | 170 | update_clause = r"""CREATE OR REPLACE FUNCTION func_{trigger_name}() 171 | RETURNS TRIGGER as 172 | ' 173 | BEGIN 174 | NEW.{field.column} = OLD.{field.column} +1; 175 | RETURN NEW; 176 | END; 177 | ' language 'plpgsql'; 178 | 179 | CREATE TRIGGER {trigger_name} BEFORE UPDATE 180 | ON {opts.db_table} FOR EACH ROW 181 | EXECUTE PROCEDURE func_{trigger_name}(); 182 | """ 183 | 184 | list_clause = "select tgname from pg_trigger where tgname LIKE 'concurrency_%%'; " 185 | 186 | 187 | class MySQL(TriggerFactory): 188 | drop_clause = """DROP TRIGGER IF EXISTS {trigger_name};""" 189 | 190 | update_clause = """ 191 | CREATE TRIGGER {trigger_name} BEFORE UPDATE ON {opts.db_table} 192 | FOR EACH ROW SET NEW.{field.column} = OLD.{field.column}+1; 193 | """ 194 | 195 | list_clause = "SHOW TRIGGERS" 196 | 197 | 198 | def factory(conn): 199 | from concurrency.config import conf 200 | 201 | mapping = conf.TRIGGERS_FACTORY 202 | try: 203 | return mapping[conn.vendor](conn) 204 | except KeyError: # pragma: no cover 205 | msg = f"{conn} is not supported by TriggerVersionField" 206 | raise ValueError(msg) 207 | -------------------------------------------------------------------------------- /src/concurrency/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | import warnings 4 | 5 | from concurrency.exceptions import RecordModifiedError 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def deprecated(replacement=None, version=None): 11 | """A decorator which can be used to mark functions as deprecated. 12 | replacement is a callable that will be called with the same args 13 | as the decorated function. 14 | >>> import pytest 15 | >>> @deprecated() 16 | ... def foo1(x): 17 | ... return x 18 | >>> pytest.warns(DeprecationWarning, foo1, 1) 19 | 1 20 | >>> def newfun(x): 21 | ... return 0 22 | >>> @deprecated(newfun, "1.1") 23 | ... def foo2(x): 24 | ... return x 25 | >>> pytest.warns(DeprecationWarning, foo2, 1) 26 | 0 27 | >>> 28 | """ 29 | 30 | def outer(oldfun): 31 | def inner(*args, **kwargs): 32 | msg = f"{oldfun.__name__} is deprecated" 33 | if version is not None: 34 | msg += f"will be removed in version {version};" 35 | if replacement is not None: 36 | msg += f"; use {replacement} instead" 37 | warnings.warn(msg, DeprecationWarning, stacklevel=2) 38 | if callable(replacement): 39 | return replacement(*args, **kwargs) 40 | return oldfun(*args, **kwargs) 41 | 42 | return inner 43 | 44 | return outer 45 | 46 | 47 | class ConcurrencyTestMixin: 48 | """ 49 | Mixin class to test Models that use `VersionField` 50 | 51 | this class offer a simple test scenario. Its purpose is to discover 52 | some conflict in the `save()` inheritance:: 53 | 54 | from concurrency.utils import ConcurrencyTestMixin 55 | from myproject.models import MyModel 56 | 57 | 58 | class MyModelTest(ConcurrencyTestMixin, TestCase): 59 | concurrency_model = TestModel0 60 | concurrency_kwargs = {"username": "test"} 61 | 62 | """ 63 | 64 | concurrency_model = None 65 | concurrency_kwargs = {} 66 | 67 | def _get_concurrency_target(self, **kwargs): 68 | # WARNING this method must be idempotent. ie must returns 69 | # always a fresh copy of the record 70 | args = dict(self.concurrency_kwargs) 71 | args.update(kwargs) 72 | return self.concurrency_model.objects.get_or_create(**args)[0] 73 | 74 | def test_concurrency_conflict(self) -> None: 75 | from concurrency import api 76 | 77 | target = self._get_concurrency_target() 78 | target_copy = self._get_concurrency_target() 79 | v1 = api.get_revision_of_object(target) 80 | v2 = api.get_revision_of_object(target_copy) 81 | assert v1 == v2, f"got same row with different version ({v1}/{v2})" # noqa: S101 82 | target.save() 83 | assert target.pk is not None # sanity check 84 | self.assertRaises(RecordModifiedError, target_copy.save) 85 | 86 | def test_concurrency_safety(self) -> None: 87 | from concurrency import api 88 | 89 | target = self.concurrency_model() 90 | version = api.get_revision_of_object(target) 91 | assert not bool(version), f"version is not null {version}" # noqa: S101 92 | 93 | def test_concurrency_management(self) -> None: 94 | target = self.concurrency_model 95 | assert hasattr(target, "_concurrencymeta"), f"{self.concurrency_model} is not under concurrency management" # noqa: S101 96 | 97 | revision_field = target._concurrencymeta.field 98 | 99 | assert revision_field in target._meta.fields, f"{self.concurrency_model}: version field not in meta.fields" # noqa: S101 100 | 101 | 102 | class ConcurrencyAdminTestMixin: 103 | pass 104 | 105 | 106 | def refetch(model_instance): 107 | """ 108 | Reload model instance from the database 109 | #""" 110 | return model_instance.__class__.objects.get(pk=model_instance.pk) 111 | 112 | 113 | def get_classname(o): 114 | """Returns the classname of an object r a class 115 | 116 | :param o: 117 | :return: 118 | """ 119 | target = o if inspect.isclass(o) or callable(o) else o.__class__ 120 | try: 121 | return target.__qualname__ 122 | except AttributeError: # pragma: no cover 123 | return target.__name__ 124 | 125 | 126 | def fqn(o): 127 | """Returns the fully qualified class name of an object or a class 128 | 129 | :param o: object or class 130 | :return: class name 131 | 132 | >>> import concurrency.fields 133 | >>> fqn("str") 134 | Traceback (most recent call last): 135 | ... 136 | ValueError: Invalid argument `str` 137 | >>> class A: 138 | ... def method(self): 139 | ... pass 140 | >>> str(fqn(A)) 141 | 'concurrency.utils.A' 142 | 143 | >>> str(fqn(A())) 144 | 'concurrency.utils.A' 145 | 146 | >>> str(fqn(concurrency.fields)) 147 | 'concurrency.fields' 148 | 149 | >>> str(fqn(A.method)) 150 | 'concurrency.utils.A.method' 151 | 152 | 153 | """ 154 | parts = [] 155 | 156 | if hasattr(o, "__module__"): 157 | parts.extend((o.__module__, get_classname(o))) 158 | elif inspect.ismodule(o): 159 | return o.__name__ 160 | if not parts: 161 | msg = f"Invalid argument `{o}`" 162 | raise ValueError(msg) 163 | return ".".join(parts) 164 | 165 | 166 | def flatten(iterable): 167 | """ 168 | flatten(sequence) -> list 169 | 170 | Returns a single, flat list which contains all elements retrieved 171 | from the sequence and all recursively contained sub-sequences 172 | (iterables). 173 | 174 | :param sequence: any object that implements iterable protocol (see: :ref:`typeiter`) 175 | :return: list 176 | 177 | Examples: 178 | 179 | >>> from adminactions.utils import flatten 180 | >>> [1, 2, [3, 4], (5, 6)] 181 | [1, 2, [3, 4], (5, 6)] 182 | 183 | >>> flatten([[[1, 2, 3], (42, None)], [4, 5], [6], 7, (8, 9, 10)]) 184 | [1, 2, 3, 42, None, 4, 5, 6, 7, 8, 9, 10]""" 185 | 186 | result = [] 187 | for el in iterable: 188 | if hasattr(el, "__iter__") and not isinstance(el, str): 189 | result.extend(flatten(el)) 190 | else: 191 | result.append(el) 192 | return list(result) 193 | -------------------------------------------------------------------------------- /src/concurrency/views.py: -------------------------------------------------------------------------------- 1 | from typing import NoReturn 2 | 3 | from django.http import HttpResponse 4 | from django.template import loader 5 | from django.template.base import Template 6 | from django.utils.translation import gettext as _ 7 | 8 | from concurrency.compat import TemplateDoesNotExist 9 | from concurrency.exceptions import RecordModifiedError 10 | 11 | 12 | class ConflictResponse(HttpResponse): 13 | status_code = 409 14 | 15 | 16 | def callback(target, *args, **kwargs) -> NoReturn: 17 | raise RecordModifiedError(_("Record has been modified"), target=target) 18 | 19 | 20 | def conflict(request, target=None, template_name="409.html"): 21 | """409 error handler. 22 | 23 | :param request: Request 24 | 25 | :param template_name: `409.html` 26 | 27 | :param target: The model to save 28 | 29 | """ 30 | try: 31 | template = loader.get_template(template_name) 32 | except TemplateDoesNotExist: # pragma: no cover 33 | template = Template( 34 | "

Conflict

" 35 | "

The request was unsuccessful due to a conflict. " 36 | "The object changed during the transaction.

" 37 | ) 38 | try: 39 | saved = target.__class__._default_manager.get(pk=target.pk) 40 | except target.__class__.DoesNotExist: # pragma: no cover 41 | saved = None 42 | ctx = {"target": target, "saved": saved, "request_path": request.path} 43 | 44 | return ConflictResponse(template.render(ctx)) 45 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = concurrency 4 | 5 | omit = src/concurrency/__init__.py 6 | tests/** 7 | 8 | [report] 9 | # Regexes for lines to exclude from consideration 10 | exclude_lines = 11 | # Have to re-enable the standard pragma 12 | pragma: no cover 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | except ImportError 20 | # Don't complain if non-runnable code isn't run: 21 | #if 0: 22 | if __name__ == .__main__.: 23 | if django.VERSION[1] < 6: 24 | 25 | ignore_errors = True 26 | 27 | [html] 28 | directory = ~build/coverage 29 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import sys 4 | 5 | import pytest 6 | 7 | py_impl = getattr(platform, "python_implementation", lambda: None) 8 | PYPY = py_impl() == "PyPy" 9 | PURE_PYTHON = os.environ.get("PURE_PYTHON", False) 10 | 11 | windows = pytest.mark.skipif(sys.platform != "win32", reason="requires windows") 12 | 13 | win32only = pytest.mark.skipif("sys.platform != 'win32'") 14 | 15 | skippypy = pytest.mark.skipif(PYPY, reason="skip on pypy") 16 | 17 | # here = Path(__file__).parent.parent 18 | # sys.path.insert(0, str(here / "src")) 19 | # sys.path.insert(0, str(here / "tests" / "demoapp")) 20 | 21 | 22 | def pytest_configure(): 23 | # os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 24 | from django.conf import settings 25 | 26 | settings.SILENCED_SYSTEM_CHECKS = ["concurrency.W001"] 27 | settings.CONCURRENCY_VERSION_FIELD_REQUIRED = False 28 | settings.CONCURRENCY_AUTO_CREATE_TRIGGERS = True 29 | 30 | 31 | # 32 | 33 | 34 | @pytest.fixture(scope="session") 35 | def client(request): 36 | import django_webtest 37 | 38 | wtm = django_webtest.WebTestMixin() 39 | wtm.csrf_checks = False 40 | wtm._patch_settings() 41 | request.addfinalizer(wtm._unpatch_settings) 42 | app = django_webtest.DjangoTestApp() 43 | return app 44 | -------------------------------------------------------------------------------- /tests/demoapp/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/demoapp/demo/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.admin.sites import NotRegistered 3 | 4 | from concurrency.admin import ConcurrentModelAdmin 5 | from concurrency.api import disable_concurrency 6 | from demo.models import ( 7 | InheritedModel, 8 | ListEditableConcurrentModel, 9 | NoActionsConcurrentModel, 10 | ProxyModel, 11 | ReversionConcurrentModel, 12 | SimpleConcurrentModel, 13 | ) 14 | 15 | try: 16 | from reversion.admin import VersionAdmin 17 | except ImportError: 18 | 19 | class VersionAdmin: 20 | pass 21 | 22 | 23 | class ListEditableModelAdmin(ConcurrentModelAdmin): 24 | list_display = ("__unicode__", "version", "username") 25 | list_editable = ("username",) 26 | ordering = ("id",) 27 | 28 | 29 | class NoActionsModelAdmin(ConcurrentModelAdmin): 30 | list_display = ("__unicode__", "version", "username") 31 | list_editable = ("username",) 32 | ordering = ("id",) 33 | actions = None 34 | 35 | 36 | class ReversionConcurrentModelAdmin(VersionAdmin, ConcurrentModelAdmin): 37 | list_display = ("__unicode__", "version", "username") 38 | list_editable = ("username",) 39 | ordering = ("id",) 40 | actions = None 41 | 42 | @disable_concurrency() 43 | def recover_view(self, request, version_id, extra_context=None): 44 | return super().recover_view(request, version_id, extra_context) 45 | 46 | 47 | class ActionsModelAdmin(ConcurrentModelAdmin): 48 | list_display = ("__unicode__", "version", "username") 49 | actions = ["dummy_action"] 50 | ordering = ("id",) 51 | 52 | def dummy_action(self, request, queryset): 53 | for el in queryset: 54 | el.username = "**action_update**" 55 | el.save() 56 | 57 | 58 | def admin_register(model, modeladmin=ConcurrentModelAdmin): 59 | try: 60 | admin.site.unregister(model) 61 | except NotRegistered: # pragma: no cover 62 | pass 63 | admin.site.register(model, modeladmin) 64 | 65 | 66 | def admin_register_models(): 67 | admin_register(SimpleConcurrentModel, ActionsModelAdmin) 68 | admin_register(ProxyModel, ListEditableModelAdmin) 69 | admin_register(InheritedModel, ActionsModelAdmin) 70 | admin_register(NoActionsConcurrentModel, NoActionsModelAdmin) 71 | admin_register(ListEditableConcurrentModel, ListEditableModelAdmin) 72 | admin_register(ReversionConcurrentModel, ReversionConcurrentModelAdmin) 73 | 74 | 75 | admin_register_models() 76 | -------------------------------------------------------------------------------- /tests/demoapp/demo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ConcurrencyTestConfig(AppConfig): 5 | name = "demo" 6 | label = "demo" 7 | verbose_name = "Concurrency Tests" 8 | -------------------------------------------------------------------------------- /tests/demoapp/demo/auth_migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.6 on 2016-09-09 15:22 2 | import django.contrib.auth.models 3 | import django.core.validators 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ("contenttypes", "0002_remove_content_type_name"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="User", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("password", models.CharField(max_length=128, verbose_name="password")), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text="Designates that this user has all permissions without explicitly assigning them.", 41 | verbose_name="superuser status", 42 | ), 43 | ), 44 | ( 45 | "username", 46 | models.CharField( 47 | error_messages={ 48 | "unique": "A user with that username already exists." 49 | }, 50 | help_text="Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.", 51 | max_length=30, 52 | unique=True, 53 | validators=[ 54 | django.core.validators.RegexValidator( 55 | "^[\\w.@+-]+$", 56 | "Enter a valid username. This value may contain only letters, " 57 | "numbers and @/./+/-/_ characters.", 58 | ) 59 | ], 60 | verbose_name="username", 61 | ), 62 | ), 63 | ( 64 | "first_name", 65 | models.CharField( 66 | blank=True, max_length=30, verbose_name="first name" 67 | ), 68 | ), 69 | ( 70 | "last_name", 71 | models.CharField( 72 | blank=True, max_length=30, verbose_name="last name" 73 | ), 74 | ), 75 | ( 76 | "email", 77 | models.EmailField( 78 | blank=True, max_length=254, verbose_name="email address" 79 | ), 80 | ), 81 | ( 82 | "is_staff", 83 | models.BooleanField( 84 | default=False, 85 | help_text="Designates whether the user can log into this admin site.", 86 | verbose_name="staff status", 87 | ), 88 | ), 89 | ( 90 | "is_active", 91 | models.BooleanField( 92 | default=True, 93 | help_text="Designates whether this user should be treated as active. " 94 | "Unselect this instead of deleting accounts.", 95 | verbose_name="active", 96 | ), 97 | ), 98 | ( 99 | "date_joined", 100 | models.DateTimeField( 101 | default=django.utils.timezone.now, verbose_name="date joined" 102 | ), 103 | ), 104 | ], 105 | options={ 106 | "verbose_name_plural": "users", 107 | "abstract": False, 108 | "swappable": "AUTH_USER_MODEL", 109 | "verbose_name": "user", 110 | }, 111 | managers=[ 112 | ("objects", django.contrib.auth.models.UserManager()), 113 | ], 114 | ), 115 | migrations.CreateModel( 116 | name="Group", 117 | fields=[ 118 | ( 119 | "id", 120 | models.AutoField( 121 | auto_created=True, 122 | primary_key=True, 123 | serialize=False, 124 | verbose_name="ID", 125 | ), 126 | ), 127 | ( 128 | "name", 129 | models.CharField(max_length=80, unique=True, verbose_name="name"), 130 | ), 131 | ], 132 | options={ 133 | "verbose_name_plural": "groups", 134 | "verbose_name": "group", 135 | }, 136 | managers=[ 137 | ("objects", django.contrib.auth.models.GroupManager()), 138 | ], 139 | ), 140 | migrations.CreateModel( 141 | name="Permission", 142 | fields=[ 143 | ( 144 | "id", 145 | models.AutoField( 146 | auto_created=True, 147 | primary_key=True, 148 | serialize=False, 149 | verbose_name="ID", 150 | ), 151 | ), 152 | ("name", models.CharField(max_length=255, verbose_name="name")), 153 | ("codename", models.CharField(max_length=100, verbose_name="codename")), 154 | ( 155 | "content_type", 156 | models.ForeignKey( 157 | on_delete=django.db.models.deletion.CASCADE, 158 | to="contenttypes.ContentType", 159 | verbose_name="content type", 160 | ), 161 | ), 162 | ], 163 | options={ 164 | "verbose_name_plural": "permissions", 165 | "ordering": ( 166 | "content_type__app_label", 167 | "content_type__model", 168 | "codename", 169 | ), 170 | "verbose_name": "permission", 171 | }, 172 | managers=[ 173 | ("objects", django.contrib.auth.models.PermissionManager()), 174 | ], 175 | ), 176 | migrations.AddField( 177 | model_name="group", 178 | name="permissions", 179 | field=models.ManyToManyField( 180 | blank=True, to="auth.Permission", verbose_name="permissions" 181 | ), 182 | ), 183 | migrations.AddField( 184 | model_name="user", 185 | name="groups", 186 | field=models.ManyToManyField( 187 | blank=True, 188 | help_text="The groups this user belongs to. " 189 | "A user will get all permissions granted to each of their groups.", 190 | related_name="user_set", 191 | related_query_name="user", 192 | to="auth.Group", 193 | verbose_name="groups", 194 | ), 195 | ), 196 | migrations.AddField( 197 | model_name="user", 198 | name="user_permissions", 199 | field=models.ManyToManyField( 200 | blank=True, 201 | help_text="Specific permissions for this user.", 202 | related_name="user_set", 203 | related_query_name="user", 204 | to="auth.Permission", 205 | verbose_name="user permissions", 206 | ), 207 | ), 208 | migrations.AlterUniqueTogether( 209 | name="permission", 210 | unique_together=set([("content_type", "codename")]), 211 | ), 212 | ] 213 | -------------------------------------------------------------------------------- /tests/demoapp/demo/auth_migrations/0002_concurrency_add_version_to_group.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from concurrency.fields import IntegerVersionField 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ 8 | To enabe this migration you must add this code to your settings 9 | 10 | MIGRATION_MODULES = { 11 | ... 12 | ... 13 | 'auth': 'tests.auth_migrations', 14 | } 15 | 16 | """ 17 | 18 | dependencies = [ 19 | ("auth", "0001_initial"), 20 | ] 21 | 22 | operations = [ 23 | migrations.AddField( 24 | model_name="Group", 25 | name="version", 26 | field=IntegerVersionField(help_text=b"Version", default=1), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/demoapp/demo/auth_migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/demoapp/demo/auth_migrations/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/auth_migrations5/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.1 on 2024-01-25 17:49 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | ("contenttypes", "0002_remove_content_type_name"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Permission", 20 | fields=[ 21 | ( 22 | "id", 23 | models.AutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("name", models.CharField(max_length=255, verbose_name="name")), 31 | ("codename", models.CharField(max_length=100, verbose_name="codename")), 32 | ( 33 | "content_type", 34 | models.ForeignKey( 35 | on_delete=django.db.models.deletion.CASCADE, 36 | to="contenttypes.contenttype", 37 | verbose_name="content type", 38 | ), 39 | ), 40 | ], 41 | options={ 42 | "verbose_name": "permission", 43 | "verbose_name_plural": "permissions", 44 | "ordering": [ 45 | "content_type__app_label", 46 | "content_type__model", 47 | "codename", 48 | ], 49 | "unique_together": {("content_type", "codename")}, 50 | }, 51 | managers=[ 52 | ("objects", django.contrib.auth.models.PermissionManager()), 53 | ], 54 | ), 55 | migrations.CreateModel( 56 | name="Group", 57 | fields=[ 58 | ( 59 | "id", 60 | models.AutoField( 61 | auto_created=True, 62 | primary_key=True, 63 | serialize=False, 64 | verbose_name="ID", 65 | ), 66 | ), 67 | ( 68 | "name", 69 | models.CharField(max_length=150, unique=True, verbose_name="name"), 70 | ), 71 | ( 72 | "permissions", 73 | models.ManyToManyField( 74 | blank=True, to="auth.permission", verbose_name="permissions" 75 | ), 76 | ), 77 | ], 78 | options={ 79 | "verbose_name": "group", 80 | "verbose_name_plural": "groups", 81 | }, 82 | managers=[ 83 | ("objects", django.contrib.auth.models.GroupManager()), 84 | ], 85 | ), 86 | migrations.CreateModel( 87 | name="User", 88 | fields=[ 89 | ( 90 | "id", 91 | models.AutoField( 92 | auto_created=True, 93 | primary_key=True, 94 | serialize=False, 95 | verbose_name="ID", 96 | ), 97 | ), 98 | ("password", models.CharField(max_length=128, verbose_name="password")), 99 | ( 100 | "last_login", 101 | models.DateTimeField( 102 | blank=True, null=True, verbose_name="last login" 103 | ), 104 | ), 105 | ( 106 | "is_superuser", 107 | models.BooleanField( 108 | default=False, 109 | help_text="Designates that this user has all permissions without explicitly assigning them.", 110 | verbose_name="superuser status", 111 | ), 112 | ), 113 | ( 114 | "username", 115 | models.CharField( 116 | error_messages={ 117 | "unique": "A user with that username already exists." 118 | }, 119 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 120 | max_length=150, 121 | unique=True, 122 | validators=[ 123 | django.contrib.auth.validators.UnicodeUsernameValidator() 124 | ], 125 | verbose_name="username", 126 | ), 127 | ), 128 | ( 129 | "first_name", 130 | models.CharField( 131 | blank=True, max_length=150, verbose_name="first name" 132 | ), 133 | ), 134 | ( 135 | "last_name", 136 | models.CharField( 137 | blank=True, max_length=150, verbose_name="last name" 138 | ), 139 | ), 140 | ( 141 | "email", 142 | models.EmailField( 143 | blank=True, max_length=254, verbose_name="email address" 144 | ), 145 | ), 146 | ( 147 | "is_staff", 148 | models.BooleanField( 149 | default=False, 150 | help_text="Designates whether the user can log into this admin site.", 151 | verbose_name="staff status", 152 | ), 153 | ), 154 | ( 155 | "is_active", 156 | models.BooleanField( 157 | default=True, 158 | help_text="Designates whether this user should be treated as active. " 159 | "Unselect this instead of deleting accounts.", 160 | verbose_name="active", 161 | ), 162 | ), 163 | ( 164 | "date_joined", 165 | models.DateTimeField( 166 | default=django.utils.timezone.now, verbose_name="date joined" 167 | ), 168 | ), 169 | ( 170 | "groups", 171 | models.ManyToManyField( 172 | blank=True, 173 | help_text="The groups this user belongs to. " 174 | "A user will get all permissions granted to each of their groups.", 175 | related_name="user_set", 176 | related_query_name="user", 177 | to="auth.group", 178 | verbose_name="groups", 179 | ), 180 | ), 181 | ( 182 | "user_permissions", 183 | models.ManyToManyField( 184 | blank=True, 185 | help_text="Specific permissions for this user.", 186 | related_name="user_set", 187 | related_query_name="user", 188 | to="auth.permission", 189 | verbose_name="user permissions", 190 | ), 191 | ), 192 | ], 193 | options={ 194 | "verbose_name": "user", 195 | "verbose_name_plural": "users", 196 | "abstract": False, 197 | "swappable": "AUTH_USER_MODEL", 198 | }, 199 | managers=[ 200 | ("objects", django.contrib.auth.models.UserManager()), 201 | ], 202 | ), 203 | ] 204 | -------------------------------------------------------------------------------- /tests/demoapp/demo/auth_migrations5/0002_concurrency_add_version_to_group.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from concurrency.fields import IntegerVersionField 4 | 5 | 6 | class Migration(migrations.Migration): 7 | """ 8 | To enabe this migration you must add this code to your settings 9 | 10 | MIGRATION_MODULES = { 11 | ... 12 | ... 13 | 'auth': 'tests.auth_migrations', 14 | } 15 | 16 | """ 17 | 18 | dependencies = [ 19 | ("auth", "0001_initial"), 20 | ] 21 | 22 | operations = [ 23 | migrations.AddField( 24 | model_name="Group", 25 | name="version", 26 | field=IntegerVersionField(help_text=b"Version", default=1), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/demoapp/demo/auth_migrations5/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/demoapp/demo/auth_migrations5/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/backends.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth.backends import ModelBackend 4 | 5 | 6 | class AnyUserAuthBackend(ModelBackend): 7 | def authenticate(self, request, username=None, password=None, **kwargs): 8 | if settings.DEBUG: 9 | if username.startswith("user"): 10 | user, __ = get_user_model().objects.update_or_create( 11 | username=username, 12 | defaults=dict( 13 | is_staff=False, 14 | is_active=True, 15 | is_superuser=False, 16 | email=f"{username}@demo.org", 17 | ), 18 | ) 19 | return user 20 | elif username.startswith("admin"): 21 | user, __ = get_user_model().objects.update_or_create( 22 | username=username, 23 | defaults=dict( 24 | is_staff=True, 25 | is_active=True, 26 | is_superuser=True, 27 | email=f"{username}@demo.org", 28 | ), 29 | ) 30 | return user 31 | return None 32 | -------------------------------------------------------------------------------- /tests/demoapp/demo/base.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group, User 2 | from django.test import TransactionTestCase 3 | from django.utils import timezone 4 | from django_webtest import WebTestMixin 5 | 6 | from concurrency.api import apply_concurrency_check 7 | from concurrency.fields import IntegerVersionField 8 | from demo.admin import admin_register_models 9 | 10 | SENTINEL = "**concurrent_update**" 11 | 12 | 13 | apply_concurrency_check(Group, "version", IntegerVersionField) 14 | 15 | 16 | class AdminTestCase(WebTestMixin, TransactionTestCase): 17 | urls = "demo.urls" 18 | 19 | def setUp(self): 20 | super().setUp() 21 | 22 | self.user, __ = User.objects.get_or_create( 23 | is_superuser=True, 24 | is_staff=True, 25 | is_active=True, 26 | last_login=timezone.now(), 27 | email="sax@example.com", 28 | username="sax", 29 | ) 30 | admin_register_models() 31 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/0002_auto_20160909_1544.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.6 on 2016-09-09 15:44 2 | import django.db.models.deletion 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("demo", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="conditionalversionmodelselfrelation", 14 | name="relations", 15 | ), 16 | migrations.AddField( 17 | model_name="conditionalversionmodelselfrelation", 18 | name="parent", 19 | field=models.ForeignKey( 20 | blank=True, 21 | null=True, 22 | on_delete=django.db.models.deletion.CASCADE, 23 | to="demo.ConditionalVersionModelSelfRelation", 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/0003_auto_20171207_1254.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("demo", "0002_auto_20160909_1544"), 7 | ] 8 | 9 | operations = [ 10 | migrations.RemoveField( 11 | model_name="conditionalversionmodelselfrelation", 12 | name="parent", 13 | ), 14 | migrations.AddField( 15 | model_name="conditionalversionmodelselfrelation", 16 | name="relations", 17 | field=models.ManyToManyField( 18 | to="demo.ConditionalVersionModelSelfRelation", 19 | null=True, 20 | through="demo.ThroughRelation", 21 | blank=True, 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/demoapp/demo/migrations/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations5/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/demoapp/demo/migrations5/__init__.py -------------------------------------------------------------------------------- /tests/demoapp/demo/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from tempfile import mktemp 3 | 4 | import django 5 | 6 | try: 7 | from psycopg2cffi import compat 8 | 9 | compat.register() 10 | except ImportError: 11 | pass 12 | 13 | DEBUG = True 14 | STATIC_URL = "/static/" 15 | 16 | SITE_ID = 1 17 | ROOT_URLCONF = "demo.urls" 18 | SECRET_KEY = "abc" 19 | STATIC_ROOT = mktemp("static") 20 | MEDIA_ROOT = mktemp("media") 21 | 22 | INSTALLED_APPS = [ 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "django.contrib.sites", 27 | "django.contrib.messages", 28 | "django.contrib.staticfiles", 29 | "django.contrib.admin", 30 | "concurrency", 31 | "reversion", 32 | "demo", 33 | ] 34 | IS_DJANGO_5 = django.VERSION[0] == 5 35 | if IS_DJANGO_5: 36 | MIGRATION_MODULES = { 37 | "demo": "demo.migrations5", 38 | "auth": "demo.auth_migrations5", 39 | } 40 | else: 41 | MIGRATION_MODULES = { 42 | "demo": "demo.migrations", 43 | "auth": "demo.auth_migrations", 44 | } 45 | 46 | MIDDLEWARE_CLASSES = [] 47 | MIDDLEWARE = [ 48 | # 'concurrency.middleware.ConcurrencyMiddleware', 49 | "django.contrib.sessions.middleware.SessionMiddleware", 50 | "django.middleware.common.CommonMiddleware", 51 | "django.middleware.csrf.CsrfViewMiddleware", 52 | "django.contrib.auth.middleware.AuthenticationMiddleware", 53 | "django.contrib.messages.middleware.MessageMiddleware", 54 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 55 | ] 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.contrib.auth.context_processors.auth", 65 | "django.template.context_processors.debug", 66 | "django.template.context_processors.i18n", 67 | "django.template.context_processors.media", 68 | "django.template.context_processors.static", 69 | "django.template.context_processors.tz", 70 | "django.contrib.messages.context_processors.messages", 71 | "django.template.context_processors.request", 72 | ], 73 | # ... some options here ... 74 | }, 75 | }, 76 | ] 77 | 78 | LOGGING = { 79 | "version": 1, 80 | "disable_existing_loggers": False, 81 | "formatters": { 82 | "full": { 83 | "format": "%(levelname)-8s: %(asctime)s %(module)s %(process)d %(thread)d %(message)s" 84 | }, 85 | "verbose": {"format": "%(levelname)-8s: %(asctime)s %(name)-25s %(message)s"}, 86 | "simple": { 87 | "format": "%(levelname)-8s %(asctime)s %(name)-25s %(funcName)s %(message)s" 88 | }, 89 | "debug": { 90 | "format": "%(levelno)s:%(levelname)-8s %(name)s %(funcName)s:%(lineno)s:: %(message)s" 91 | }, 92 | }, 93 | "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, 94 | "handlers": { 95 | "null": {"level": "DEBUG", "class": "logging.NullHandler"}, 96 | "console": { 97 | "level": "DEBUG", 98 | "class": "logging.StreamHandler", 99 | "formatter": "debug", 100 | }, 101 | }, 102 | "loggers": { 103 | "concurrency": {"handlers": ["null"], "propagate": False, "level": "DEBUG"} 104 | }, 105 | } 106 | 107 | db = os.environ.get("DBENGINE", "pg") 108 | dbname = os.environ.get("DBNAME", "concurrency") 109 | if db == "pg": 110 | DATABASES = { 111 | "default": { 112 | "ENGINE": "django.db.backends.postgresql_psycopg2", 113 | "NAME": dbname, 114 | "HOST": os.environ.get("PGHOST", "127.0.0.1"), 115 | "PORT": os.environ.get("PGPORT", "5432"), 116 | "USER": "postgres", 117 | "PASSWORD": "postgres", 118 | } 119 | } 120 | elif db == "mysql": 121 | DATABASES = { 122 | "default": { 123 | "ENGINE": "django.db.backends.mysql", 124 | "NAME": dbname, 125 | "HOST": "127.0.0.1", 126 | "PORT": "3306", 127 | "USER": "root", 128 | "PASSWORD": "root", 129 | "CHARSET": "utf8", 130 | "COLLATION": "utf8_general_ci", 131 | } 132 | } 133 | else: 134 | DATABASES = { 135 | "default": { 136 | "ENGINE": "django.db.backends.sqlite3", 137 | "NAME": dbname, 138 | } 139 | } 140 | 141 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 142 | 143 | AUTHENTICATION_BACKENDS = ( 144 | "demo.backends.AnyUserAuthBackend", 145 | # os.environ.get("AUTHENTICATION_BACKEND", ""), 146 | # "django.contrib.auth.backends.ModelBackend", 147 | ) 148 | -------------------------------------------------------------------------------- /tests/demoapp/demo/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/demoapp/demo/templates/409.html: -------------------------------------------------------------------------------- 1 | {% load concurrency %} 2 | 3 | 4 | 7 | 10 | 13 | 16 | 17 | 18 | 19 | {% for field, current, stored, entry in diff %} 20 | {% if not field.primary_key and not field|is_version %} 21 | 22 | 25 | 28 | 31 | 34 | 35 | {% endif %} 36 | {% endfor %} 37 | 38 |
5 | Field 6 | 8 | Current 9 | 11 | Stored 12 | 14 | Diff 15 |
23 | {{ field.verbose_name }} 24 | 26 | {{ current }} 27 | 29 | {{ stored }} 30 | 32 | {{ entry }} 33 |
39 | -------------------------------------------------------------------------------- /tests/demoapp/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import re_path 3 | from django.views.generic.edit import UpdateView 4 | 5 | from demo.models import SimpleConcurrentModel 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = ( 10 | re_path( 11 | r"cm/(?P\d+)/", 12 | UpdateView.as_view(model=SimpleConcurrentModel), 13 | name="concurrent-edit", 14 | ), 15 | re_path(r"^admin/", admin.site.urls), 16 | ) 17 | -------------------------------------------------------------------------------- /tests/demoapp/demo/util.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from contextlib import contextmanager 3 | from functools import partial, update_wrapper 4 | from itertools import count 5 | 6 | import pytest 7 | from django import db 8 | 9 | from concurrency.config import conf 10 | from demo.models import ( 11 | AutoIncConcurrentModel, 12 | ConcreteModel, 13 | CustomSaveModel, 14 | InheritedModel, 15 | ProxyModel, 16 | SimpleConcurrentModel, 17 | TriggerConcurrentModel, 18 | ) 19 | 20 | 21 | def sequence(prefix): 22 | infinite = itertools.count() 23 | while 1: 24 | yield "{0}-{1}".format(prefix, next(infinite)) 25 | 26 | 27 | nextname = sequence("username") 28 | nextgroup = sequence("group") 29 | unique_id = count(1) 30 | 31 | 32 | def override_conf(**kwargs): 33 | for key, new_value in kwargs.items(): 34 | setattr(conf, key, new_value) 35 | 36 | 37 | def clone_instance(model_instance): 38 | """ 39 | returns a copy of the passed instance. 40 | 41 | .. warning: All fields are copied, even primary key 42 | 43 | :param instance: :py:class:`django.db.models.Model` instance 44 | :return: :py:class:`django.db.models.Model` instance 45 | """ 46 | 47 | fieldnames = [fld.name for fld in model_instance._meta.fields] 48 | 49 | new_kwargs = {name: getattr(model_instance, name) for name in fieldnames} 50 | return model_instance.__class__(**new_kwargs) 51 | 52 | 53 | def with_models(*models, **kwargs): 54 | ignore = kwargs.pop("ignore", []) 55 | if ignore: 56 | models = filter(models, lambda x: x not in ignore) 57 | 58 | ids = [m.__name__ for m in models] 59 | 60 | return pytest.mark.parametrize(("model_class,"), models, False, ids, None) 61 | 62 | 63 | MODEL_CLASSES = [ 64 | SimpleConcurrentModel, 65 | AutoIncConcurrentModel, 66 | InheritedModel, 67 | CustomSaveModel, 68 | ConcreteModel, 69 | ProxyModel, 70 | TriggerConcurrentModel, 71 | ] 72 | 73 | with_std_models = partial( 74 | with_models, 75 | SimpleConcurrentModel, 76 | AutoIncConcurrentModel, 77 | InheritedModel, 78 | CustomSaveModel, 79 | ConcreteModel, 80 | ProxyModel, 81 | )() 82 | with_all_models = partial(with_models, *MODEL_CLASSES)() 83 | 84 | # with_all_models = partial(models_parametrize, ConcreteModel)() 85 | 86 | DELETE_ATTRIBUTE = object() 87 | 88 | 89 | @pytest.fixture(params=MODEL_CLASSES) 90 | def concurrent_model(request): 91 | return request.param 92 | 93 | 94 | @contextmanager 95 | def attributes(*values): 96 | """ 97 | context manager to temporary set/delete object's attributes 98 | :param values: tulples of (target, name, value) 99 | Es. 100 | 101 | 102 | with attributes((django.contrib.admin.ModelAdmin, 'list_per_page', 200)): 103 | ... 104 | 105 | with attributes((django.contrib.admin.ModelAdmin, 'list_per_page', DELETE_ATTRIBUTE)): 106 | ... 107 | 108 | """ 109 | 110 | def _set(target, name, value): 111 | if value is DELETE_ATTRIBUTE: 112 | delattr(target, name) 113 | else: 114 | setattr(target, name, value) 115 | 116 | backups = [] 117 | 118 | for target, name, value in values: 119 | if hasattr(target, name): 120 | backups.append((target, name, getattr(target, name))) 121 | else: 122 | backups.append((target, name, getattr(target, name, DELETE_ATTRIBUTE))) 123 | _set(target, name, value) 124 | yield 125 | 126 | for target, name, value in backups: 127 | _set(target, name, value) 128 | 129 | 130 | def concurrently(times=1): 131 | # from: http://www.caktusgroup.com/blog/2009/05/26/testing-django-views-for-concurrency-issues/ 132 | """ 133 | Add this decorator to small pieces of code that you want to test 134 | concurrently to make sure they don't raise exceptions when run at the 135 | same time. E.g., some Django views that do a SELECT and then a subsequent 136 | INSERT might fail when the INSERT assumes that the data has not changed 137 | since the SELECT. 138 | """ 139 | 140 | def concurrently_decorator(test_func): 141 | def wrapper(*args, **kwargs): 142 | exceptions = [] 143 | import threading 144 | 145 | def call_test_func(): 146 | try: 147 | test_func(*args, **kwargs) 148 | except Exception as e: 149 | exceptions.append(e) 150 | raise 151 | finally: 152 | db.connection.close() 153 | 154 | threads = [] 155 | for i in range(times): 156 | threads.append(threading.Thread(target=call_test_func)) 157 | for t in threads: 158 | t.start() 159 | for t in threads: 160 | t.join() 161 | if exceptions: 162 | raise Exception( 163 | "test_concurrently intercepted %s exceptions: %s" 164 | % (len(exceptions), exceptions) 165 | ) 166 | 167 | return update_wrapper(wrapper, test_func) 168 | 169 | return concurrently_decorator 170 | -------------------------------------------------------------------------------- /tests/dumpdata.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "demo.simpleconcurrentmodel", 4 | "fields": { 5 | "username": "loaded", 6 | "version": 1446479791301679, 7 | "date_field": null 8 | }, 9 | "pk": 1 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /tests/test_admin_actions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.base import SENTINEL, AdminTestCase 3 | from demo.models import SimpleConcurrentModel 4 | from demo.util import unique_id 5 | from django.urls import reverse 6 | 7 | 8 | class TestAdminActions(AdminTestCase): 9 | def _create_conflict(self, pk): 10 | u = SimpleConcurrentModel.objects.get(pk=pk) 11 | u.username = SENTINEL 12 | u.save() 13 | 14 | def test_dummy_action(self): 15 | id = next(unique_id) 16 | SimpleConcurrentModel.objects.get_or_create(pk=id) 17 | res = self.app.get("/admin/", user="sax") 18 | 19 | res = res.click("^SimpleConcurrentModels") 20 | assert "SimpleConcurrentModel #%s" % id in res # sanity check 21 | 22 | self._create_conflict(id) 23 | 24 | form = res.forms["changelist-form"] 25 | form["action"].value = "dummy_action" 26 | sel = form.get("_selected_action", index=0) 27 | sel.checked = True 28 | res = form.submit().follow() 29 | 30 | self.assertIn("SimpleConcurrentModel #%s" % id, res) 31 | self.assertIn("**concurrent_update**", res) 32 | self.assertNotIn("**action_update**", res) 33 | 34 | def test_dummy_action_select_across(self): 35 | id = next(unique_id) 36 | SimpleConcurrentModel.objects.get_or_create(pk=id) 37 | res = self.app.get("/admin/", user="sax") 38 | 39 | res = res.click("^SimpleConcurrentModels") 40 | assert "SimpleConcurrentModel #%s" % id in res # sanity check 41 | 42 | self._create_conflict(id) 43 | 44 | form = res.forms["changelist-form"] 45 | form["action"].value = "dummy_action" 46 | form["select_across"] = "True" 47 | sel = form.get("_selected_action", index=0) # needed 48 | sel.checked = True # needed 49 | res = form.submit() 50 | res = res.follow() 51 | 52 | self.assertIn( 53 | "Selecting all records, you will avoid the concurrency check", res 54 | ) 55 | 56 | def test_delete_allowed_if_no_updates(self): 57 | id = next(unique_id) 58 | SimpleConcurrentModel.objects.get_or_create(pk=id) 59 | res = self.app.get("/admin/", user="sax") 60 | res = res.click("^SimpleConcurrentModels") 61 | assert "SimpleConcurrentModel #%s" % id in res # sanity check 62 | 63 | form = res.forms["changelist-form"] 64 | form["action"].value = "delete_selected" 65 | sel = form.get("_selected_action", index=0) 66 | sel.checked = True 67 | 68 | res = form.submit() 69 | 70 | assert "Are you sure" in res 71 | assert "SimpleConcurrentModel #%s" % id in res 72 | res = res.forms[0].submit() 73 | assert "SimpleConcurrentModel #%s" % id not in res 74 | 75 | def test_delete_not_allowed_if_updates(self): 76 | id = next(unique_id) 77 | 78 | SimpleConcurrentModel.objects.get_or_create(pk=id) 79 | res = self.app.get("/admin/", user="sax") 80 | 81 | res = res.click("^SimpleConcurrentModels") 82 | assert "SimpleConcurrentModel #%s" % id in res # sanity check 83 | 84 | self._create_conflict(id) 85 | 86 | form = res.forms["changelist-form"] 87 | form["action"].value = "delete_selected" 88 | sel = form.get("_selected_action", index=0) 89 | sel.checked = True 90 | res = form.submit().follow() 91 | self.assertIn("One or more record were updated", res) 92 | 93 | @pytest.mark.django_db 94 | def test_deleteaction(self): 95 | id = next(unique_id) 96 | 97 | SimpleConcurrentModel.objects.get_or_create(pk=id) 98 | response = self.app.get( 99 | reverse("admin:demo_simpleconcurrentmodel_changelist"), user="sax" 100 | ) 101 | form = response.forms["changelist-form"] 102 | form.get("_selected_action", index=0).checked = True 103 | form["action"] = "delete_selected" 104 | response = form.submit() 105 | expected = ( 106 | "All of the following objects and their related items will be deleted" 107 | ) 108 | assert expected in response 109 | form = response.forms[1] if len(response.forms) > 1 else response.form # dj41 110 | response = form.submit().follow() 111 | assert response.status_code == 200 112 | -------------------------------------------------------------------------------- /tests/test_admin_edit.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.base import SENTINEL, AdminTestCase 3 | from demo.models import SimpleConcurrentModel 4 | from demo.util import nextname 5 | from django.urls import reverse 6 | from django.utils.translation import gettext as _ 7 | 8 | from concurrency.forms import VersionFieldSigner 9 | 10 | 11 | @pytest.mark.django_db 12 | @pytest.mark.admin 13 | def test_creation(admin_user, client): 14 | url = reverse("admin:demo_simpleconcurrentmodel_add") 15 | res = client.get(url, user=admin_user.username) 16 | form = res.forms["simpleconcurrentmodel_form"] 17 | form["username"] = "CHAR" 18 | res = form.submit().follow() 19 | assert SimpleConcurrentModel.objects.filter(username="CHAR").exists() 20 | assert SimpleConcurrentModel.objects.get(username="CHAR").version > 0 21 | 22 | 23 | @pytest.mark.django_db 24 | @pytest.mark.functional 25 | def test_standard_update(admin_user, client): 26 | concurrentmodel = SimpleConcurrentModel.objects.create() 27 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[concurrentmodel.pk]) 28 | res = client.get(url, user=admin_user.username) 29 | 30 | target = res.context["original"] 31 | 32 | old_version = target.version 33 | form = res.forms["simpleconcurrentmodel_form"] 34 | form["username"] = "UPDATED" 35 | res = form.submit().follow() 36 | target = SimpleConcurrentModel.objects.get(pk=target.pk) 37 | new_version = target.version 38 | 39 | assert new_version > old_version 40 | 41 | 42 | @pytest.mark.django_db 43 | @pytest.mark.functional 44 | def test_conflict(admin_user, client): 45 | concurrentmodel = SimpleConcurrentModel.objects.create() 46 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[concurrentmodel.pk]) 47 | res = client.get(url, user=admin_user.username) 48 | form = res.forms["simpleconcurrentmodel_form"] 49 | concurrentmodel.save() # create conflict here 50 | 51 | res = form.submit() 52 | 53 | assert "original" in res.context 54 | assert res.context["adminform"].form.errors 55 | assert _("Record Modified") in str(res.context["adminform"].form.errors) 56 | 57 | 58 | class TestConcurrentModelAdmin(AdminTestCase): 59 | def test_standard_update(self): 60 | target, __ = SimpleConcurrentModel.objects.get_or_create(username="aaa") 61 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[target.pk]) 62 | res = self.app.get(url, user="sax") 63 | target = res.context["original"] 64 | old_version = target.version 65 | form = res.forms["simpleconcurrentmodel_form"] 66 | form["username"] = "UPDATED" 67 | res = form.submit().follow() 68 | target = SimpleConcurrentModel.objects.get(pk=target.pk) 69 | new_version = target.version 70 | self.assertGreater(new_version, old_version) 71 | 72 | def test_creation(self): 73 | url = reverse("admin:demo_simpleconcurrentmodel_add") 74 | res = self.app.get(url, user="sax") 75 | form = res.forms["simpleconcurrentmodel_form"] 76 | form["username"] = "CHAR" 77 | res = form.submit().follow() 78 | self.assertTrue(SimpleConcurrentModel.objects.filter(username="CHAR").exists()) 79 | self.assertGreater( 80 | SimpleConcurrentModel.objects.get(username="CHAR").version, 0 81 | ) 82 | 83 | def test_conflict(self): 84 | target, __ = SimpleConcurrentModel.objects.get_or_create(username="aaa") 85 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[target.pk]) 86 | res = self.app.get(url, user="sax") 87 | 88 | form = res.forms["simpleconcurrentmodel_form"] 89 | target.save() # create conflict here 90 | 91 | res = form.submit() 92 | 93 | self.assertIn("original", res.context) 94 | self.assertTrue( 95 | res.context["adminform"].form.errors, res.context["adminform"].form.errors 96 | ) 97 | self.assertIn( 98 | _("Record Modified"), 99 | str(res.context["adminform"].form.errors), 100 | res.context["adminform"].form.errors, 101 | ) 102 | 103 | 104 | class TestAdminEdit(AdminTestCase): 105 | def _create_conflict(self, pk): 106 | u = SimpleConcurrentModel.objects.get(pk=pk) 107 | u.username = SENTINEL 108 | u.save() 109 | 110 | def test_creation(self): 111 | url = reverse("admin:demo_simpleconcurrentmodel_add") 112 | res = self.app.get(url, user="sax") 113 | form = res.forms["simpleconcurrentmodel_form"] 114 | form["username"] = "CHAR" 115 | res = form.submit().follow() 116 | self.assertTrue(SimpleConcurrentModel.objects.filter(username="CHAR").exists()) 117 | self.assertGreater( 118 | SimpleConcurrentModel.objects.get(username="CHAR").version, 0 119 | ) 120 | 121 | def test_creation_with_customform(self): 122 | url = reverse("admin:demo_simpleconcurrentmodel_add") 123 | res = self.app.get(url, user="sax") 124 | form = res.forms["simpleconcurrentmodel_form"] 125 | username = next(nextname) 126 | form["username"] = username 127 | res = form.submit().follow() 128 | self.assertTrue( 129 | SimpleConcurrentModel.objects.filter(username=username).exists() 130 | ) 131 | self.assertGreater( 132 | SimpleConcurrentModel.objects.get(username=username).version, 0 133 | ) 134 | 135 | # test no other errors are raised 136 | res = form.submit() 137 | self.assertEqual(res.status_code, 200) 138 | self.assertContains( 139 | res, "SimpleConcurrentModel with this Username already exists." 140 | ) 141 | 142 | def test_standard_update(self): 143 | target, __ = SimpleConcurrentModel.objects.get_or_create(username="aaa") 144 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[target.pk]) 145 | res = self.app.get(url, user="sax") 146 | target = res.context["original"] 147 | old_version = target.version 148 | form = res.forms["simpleconcurrentmodel_form"] 149 | form["username"] = "UPDATED" 150 | res = form.submit().follow() 151 | target = SimpleConcurrentModel.objects.get(pk=target.pk) 152 | new_version = target.version 153 | self.assertGreater(new_version, old_version) 154 | 155 | def test_conflict(self): 156 | target, __ = SimpleConcurrentModel.objects.get_or_create(username="aaa") 157 | assert target.version 158 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[target.pk]) 159 | res = self.app.get(url, user="sax") 160 | form = res.forms["simpleconcurrentmodel_form"] 161 | 162 | target.save() # create conflict here 163 | res = form.submit() 164 | self.assertIn("original", res.context) 165 | self.assertTrue( 166 | res.context["adminform"].form.errors, res.context["adminform"].form.errors 167 | ) 168 | self.assertIn( 169 | _("Record Modified"), 170 | str(res.context["adminform"].form.errors), 171 | res.context["adminform"].form.errors, 172 | ) 173 | 174 | def test_sanity_signer(self): 175 | target, __ = SimpleConcurrentModel.objects.get_or_create(username="aaa") 176 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[target.pk]) 177 | res = self.app.get(url, user="sax") 178 | form = res.forms["simpleconcurrentmodel_form"] 179 | version1 = int(str(form["version"].value).split(":")[0]) 180 | form["version"] = VersionFieldSigner().sign(version1) 181 | form["date_field"] = "esss2010-09-01" 182 | response = form.submit() 183 | self.assertIn("original", response.context) 184 | self.assertTrue( 185 | response.context["adminform"].form.errors, 186 | response.context["adminform"].form.errors, 187 | ) 188 | form = response.context["adminform"].form 189 | version2 = int(str(form["version"].value()).split(":")[0]) 190 | self.assertEqual(version1, version2) 191 | -------------------------------------------------------------------------------- /tests/test_admin_list_editable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.base import SENTINEL, AdminTestCase 3 | from demo.models import ListEditableConcurrentModel 4 | from demo.util import attributes, unique_id 5 | from django.contrib.admin.models import LogEntry 6 | from django.contrib.admin.sites import site 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.db import transaction 9 | from django.utils.encoding import force_str 10 | 11 | from concurrency.compat import concurrency_param_name 12 | from concurrency.config import ( 13 | CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL, 14 | CONCURRENCY_LIST_EDITABLE_POLICY_SILENT, 15 | ) 16 | from concurrency.exceptions import RecordModifiedError 17 | 18 | 19 | class TestListEditable(AdminTestCase): 20 | TARGET = ListEditableConcurrentModel 21 | 22 | def _create_conflict(self, pk): 23 | u = self.TARGET.objects.get(pk=pk) 24 | u.username = SENTINEL 25 | u.save() 26 | 27 | def test_normal_add(self): 28 | res = self.app.get("/admin/", user="sax") 29 | # file:///admin/demo/listeditableconcurrentmodel/add/ 30 | res = res.click(self.TARGET._meta.verbose_name_plural) 31 | res = res.click( 32 | "Add", href=f"/admin/demo/{self.TARGET._meta.model_name}/add/", index=0 33 | ) 34 | form = res.forms["listeditableconcurrentmodel_form"] 35 | form["username"] = "CHAR" 36 | form.submit().follow() 37 | 38 | def test_normal_update(self): 39 | self.TARGET.objects.get_or_create(pk=next(unique_id)) 40 | res = self.app.get("/admin/", user="sax") 41 | res = res.click(self.TARGET._meta.verbose_name_plural) 42 | form = res.forms["changelist-form"] 43 | form["form-0-username"] = "CHAR" 44 | form.submit("_save").follow() 45 | self.assertTrue(self.TARGET.objects.filter(username="CHAR").exists()) 46 | 47 | def test_concurrency_policy_abort(self): 48 | id = next(unique_id) 49 | self.TARGET.objects.get_or_create(pk=id) 50 | model_admin = site._registry[self.TARGET] 51 | with attributes( 52 | ( 53 | model_admin.__class__, 54 | "list_editable_policy", 55 | CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL, 56 | ) 57 | ): 58 | res = self.app.get("/admin/", user="sax") 59 | res = res.click(self.TARGET._meta.verbose_name_plural) 60 | self._create_conflict(id) 61 | form = res.forms["changelist-form"] 62 | form["form-0-username"] = "CHAR" 63 | 64 | with pytest.raises(RecordModifiedError): 65 | res = form.submit("_save") 66 | 67 | self.assertTrue(self.TARGET.objects.filter(username=SENTINEL).exists()) 68 | self.assertFalse(self.TARGET.objects.filter(username="CHAR").exists()) 69 | 70 | def test_concurrency_policy_silent(self): 71 | id = next(unique_id) 72 | self.TARGET.objects.get_or_create(pk=id) 73 | model_admin = site._registry[self.TARGET] 74 | with attributes( 75 | ( 76 | model_admin.__class__, 77 | "list_editable_policy", 78 | CONCURRENCY_LIST_EDITABLE_POLICY_SILENT, 79 | ) 80 | ): 81 | res = self.app.get("/admin/", user="sax") 82 | res = res.click(self.TARGET._meta.verbose_name_plural) 83 | self._create_conflict(id) 84 | form = res.forms["changelist-form"] 85 | form["form-0-username"] = "CHAR" 86 | version = int(form[f"{concurrency_param_name}_{id}"].value) 87 | res = form.submit("_save").follow() 88 | changed = self.TARGET.objects.filter(username=SENTINEL).first() 89 | self.assertTrue(changed) 90 | self.assertGreater(changed.version, version) 91 | 92 | def test_message_user(self): 93 | id1 = next(unique_id) 94 | id2 = next(unique_id) 95 | self.TARGET.objects.get_or_create(pk=id1) 96 | self.TARGET.objects.get_or_create(pk=id2) 97 | res = self.app.get("/admin/", user="sax") 98 | res = res.click(self.TARGET._meta.verbose_name_plural) 99 | 100 | self._create_conflict(id1) 101 | 102 | form = res.forms["changelist-form"] 103 | form["form-0-username"] = "CHAR1" 104 | form["form-1-username"] = "CHAR2" 105 | res = form.submit("_save").follow() 106 | 107 | messages = map(str, list(res.context["messages"])) 108 | 109 | self.assertIn( 110 | "Record with pk `%s` has been modified and was not updated" % id1, messages 111 | ) 112 | self.assertIn( 113 | "1 %s was changed successfully." 114 | % force_str(self.TARGET._meta.verbose_name), 115 | messages, 116 | ) 117 | 118 | def test_message_user_no_changes(self): 119 | id = next(unique_id) 120 | self.TARGET.objects.get_or_create(pk=id) 121 | 122 | res = self.app.get("/admin/", user="sax") 123 | res = res.click(self.TARGET._meta.verbose_name_plural) 124 | 125 | self._create_conflict(id) 126 | 127 | form = res.forms["changelist-form"] 128 | form["form-0-username"] = "CHAR1" 129 | res = form.submit("_save").follow() 130 | 131 | messages = list(map(str, list(res.context["messages"]))) 132 | 133 | self.assertIn( 134 | "Record with pk `%s` has been modified and was not updated" % id, 135 | set(messages), 136 | ) 137 | self.assertEqual(len(set(messages)), 1) 138 | 139 | def test_log_change(self): 140 | id = next(unique_id) 141 | self.TARGET.objects.get_or_create(pk=id) 142 | 143 | res = self.app.get("/admin/", user="sax") 144 | res = res.click(self.TARGET._meta.verbose_name_plural) 145 | log_filter = dict( 146 | user__username="sax", 147 | content_type=ContentType.objects.get_for_model(self.TARGET), 148 | ) 149 | 150 | logs = list(LogEntry.objects.filter(**log_filter).values_list("pk", flat=True)) 151 | 152 | self._create_conflict(id) 153 | 154 | form = res.forms["changelist-form"] 155 | form["form-0-username"] = "CHAR1" 156 | res = form.submit("_save").follow() 157 | new_logs = LogEntry.objects.filter(**log_filter).exclude(id__in=logs).exists() 158 | self.assertFalse(new_logs, "LogEntry created even if conflict error") 159 | transaction.rollback() 160 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import SimpleConcurrentModel 3 | from demo.util import nextgroup, nextname 4 | from django.contrib.auth.models import Group 5 | 6 | from concurrency.api import ( 7 | apply_concurrency_check, 8 | get_revision_of_object, 9 | get_version, 10 | is_changed, 11 | ) 12 | from concurrency.exceptions import RecordModifiedError 13 | from concurrency.fields import IntegerVersionField 14 | from concurrency.utils import refetch 15 | 16 | 17 | @pytest.mark.django_db(transaction=False) 18 | @pytest.mark.skipif('os.environ.get("DBENGINE", "")=="pg"') 19 | def test_get_revision_of_object(model_class=SimpleConcurrentModel): 20 | instance = model_class(username=next(nextname)) 21 | instance.save() 22 | assert get_revision_of_object(instance) == instance.version 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_is_changed(model_class=SimpleConcurrentModel): 27 | instance = model_class(username=next(nextname)) 28 | instance.save() 29 | copy = refetch(instance) 30 | copy.save() 31 | assert is_changed(instance) 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_get_version(model_class=SimpleConcurrentModel): 36 | instance = model_class(username=next(nextname)) 37 | instance.save() 38 | copy = refetch(instance) 39 | copy.save() 40 | instance = get_version(instance, copy.version) 41 | assert instance.get_concurrency_version() == copy.get_concurrency_version() 42 | 43 | 44 | @pytest.mark.django_db(transaction=False) 45 | def test_apply_concurrency_check(): 46 | apply_concurrency_check(Group, "version", IntegerVersionField) 47 | 48 | instance, __ = Group.objects.get_or_create(name=next(nextgroup)) 49 | instance.save() 50 | 51 | copy = refetch(instance) 52 | copy.save() 53 | 54 | with pytest.raises(RecordModifiedError): 55 | instance.save() 56 | 57 | 58 | @pytest.mark.django_db(transaction=False) 59 | def test_apply_concurrency_check_ignore_multiple_call(): 60 | apply_concurrency_check(Group, "version", IntegerVersionField) 61 | apply_concurrency_check(Group, "version", IntegerVersionField) 62 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.util import concurrent_model, unique_id, with_all_models, with_std_models 3 | from django.test import override_settings 4 | 5 | from concurrency.core import _set_version 6 | from concurrency.exceptions import RecordModifiedError 7 | from concurrency.utils import refetch 8 | 9 | pytest.mark.django_db(transaction=False) 10 | 11 | 12 | @with_all_models 13 | @pytest.mark.django_db 14 | def test_standard_save(model_class): 15 | instance = model_class(username=concurrent_model.__name__) 16 | instance.save() 17 | assert instance.get_concurrency_version() > 0 18 | instance = refetch(instance) 19 | assert instance.get_concurrency_version() > 0 20 | 21 | 22 | @pytest.mark.django_db(transaction=False) 23 | @with_std_models 24 | def test_conflict(model_class): 25 | id = next(unique_id) 26 | instance = model_class.objects.get_or_create(pk=id)[0] 27 | instance.save() 28 | 29 | copy = refetch(instance) 30 | copy.save() 31 | 32 | with pytest.raises(RecordModifiedError): 33 | instance.save() 34 | assert copy.get_concurrency_version() > instance.get_concurrency_version() 35 | 36 | 37 | @pytest.mark.django_db(transaction=True) 38 | @with_std_models 39 | def test_do_not_check_if_no_version(model_class): 40 | with override_settings(CONCURRENCY_VERSION_FIELD_REQUIRED=False): 41 | id = next(unique_id) 42 | instance, __ = model_class.objects.get_or_create(pk=id) 43 | instance.save() 44 | 45 | copy = refetch(instance) 46 | copy.save() 47 | 48 | with pytest.raises(RecordModifiedError): 49 | _set_version(instance, 1) 50 | instance.version = 1 51 | instance.save() 52 | 53 | _set_version(instance, 0) 54 | instance.save() 55 | assert instance.get_concurrency_version() > 0 56 | assert instance.get_concurrency_version() != copy.get_concurrency_version() 57 | 58 | 59 | @pytest.mark.django_db(transaction=True) 60 | @with_std_models 61 | def test_conflict_no_version_and_no_skip_flag(model_class): 62 | """When VERSION_FIELD_REQUIRED is enabled, 63 | attempting to update a record with a default version number should fail.""" 64 | with override_settings(CONCURRENCY_VERSION_FIELD_REQUIRED=True): 65 | id = next(unique_id) 66 | instance, __ = model_class.objects.get_or_create(pk=id) 67 | instance.save() 68 | 69 | copy = refetch(instance) 70 | copy.version = 0 71 | 72 | with pytest.raises(RecordModifiedError): 73 | copy.save() 74 | 75 | 76 | @with_std_models 77 | @pytest.mark.django_db(transaction=False) 78 | def test_update_fields(model_class): 79 | """ 80 | Calling save with update_fields not containing version doesn't update 81 | the version. 82 | """ 83 | 84 | instance = model_class.objects.create(username="abc") 85 | copy = refetch(instance) 86 | 87 | # do not update version 88 | instance.save(update_fields=["username"]) 89 | 90 | # copy can be saved 91 | copy.username = "def" 92 | copy.save() 93 | assert refetch(instance).username, "def" 94 | assert refetch(instance).version == copy.version 95 | 96 | 97 | @with_std_models 98 | @pytest.mark.django_db(transaction=False) 99 | def test_update_fields_still_checks(model_class): 100 | """ 101 | Excluding the VersionField from update_fields should still check 102 | for conflicts. 103 | """ 104 | instance = model_class.objects.create(username="abc") 105 | copy = refetch(instance) 106 | instance.save() 107 | copy.name = "def" 108 | 109 | with pytest.raises(RecordModifiedError): 110 | copy.save(update_fields=["username"]) 111 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from demo.models import TriggerConcurrentModel 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | @pytest.fixture 10 | def obj(): 11 | return TriggerConcurrentModel.objects.create() 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_check(obj, monkeypatch): 16 | from django.core.checks import Warning 17 | 18 | monkeypatch.setattr(obj._concurrencymeta.field, "_trigger_name", "test") 19 | 20 | assert isinstance(obj._concurrencymeta.field.check()[0], Warning) 21 | -------------------------------------------------------------------------------- /tests/test_command.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | 4 | import pytest 5 | from django.core.management import call_command 6 | from mock import Mock 7 | 8 | import concurrency.management.commands.triggers as command 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_command_create(monkeypatch): 15 | out = io.StringIO() 16 | mock_create = Mock() 17 | mock_create.return_value = {"default": [["model", "field", "trigger"]]} 18 | 19 | monkeypatch.setattr(command, "create_triggers", mock_create) 20 | call_command("triggers", "create", stdout=out) 21 | 22 | out.seek(0) 23 | output = out.read() 24 | assert output.find("Created trigger for field") > 0 25 | assert mock_create.call_count == 1 26 | 27 | 28 | @pytest.mark.django_db 29 | def test_command_create_db(monkeypatch): 30 | out = io.StringIO() 31 | mock_create = Mock() 32 | mock_create.return_value = {"default": [["model", "field", "trigger"]]} 33 | 34 | monkeypatch.setattr(command, "create_triggers", mock_create) 35 | call_command("triggers", "create", database="default", stdout=out) 36 | 37 | out.seek(0) 38 | output = out.read() 39 | assert output.find("Created trigger for field") > 0 40 | assert mock_create.call_count == 1 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_command_list(): 45 | out = io.StringIO() 46 | call_command("triggers", "list", stdout=out) 47 | out.seek(0) 48 | output = out.read() 49 | assert output.find("concurrency_demo_triggerconcurrentmodel_i") 50 | assert output.find("concurrency_demo_triggerconcurrentmodel_u") 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_command_drop(monkeypatch): 55 | out = io.StringIO() 56 | mock_drop = Mock() 57 | mock_drop.return_value = {"default": [["model", "field", "trigger"]]} 58 | 59 | monkeypatch.setattr(command, "drop_triggers", mock_drop) 60 | call_command("triggers", "drop", stdout=out) 61 | 62 | out.seek(0) 63 | output = out.read() 64 | assert output.find("Dropped trigger") > 0 65 | assert mock_drop.call_count == 1 66 | -------------------------------------------------------------------------------- /tests/test_concurrencymetainfo.py: -------------------------------------------------------------------------------- 1 | from demo.models import ConcurrencyDisabledModel, SimpleConcurrentModel 2 | from django.test import TransactionTestCase 3 | 4 | from concurrency.exceptions import RecordModifiedError 5 | 6 | 7 | class TestCustomConcurrencyMeta(TransactionTestCase): 8 | concurrency_model = ConcurrencyDisabledModel 9 | concurrency_kwargs = {"username": "test"} 10 | 11 | def setUp(self): 12 | super().setUp() 13 | self.TARGET = self._get_concurrency_target() 14 | 15 | def _get_concurrency_target(self, **kwargs): 16 | # WARNING this method must be idempotent. ie must returns 17 | # always a fresh copy of the record 18 | args = dict(self.concurrency_kwargs) 19 | args.update(kwargs) 20 | return self.concurrency_model.objects.get_or_create(**args)[0] 21 | 22 | def test_enabled(self): 23 | assert not self.TARGET._concurrencymeta.enabled 24 | 25 | def test_meta_inheritance(self): 26 | # TestModelWithCustomOptions extends ConcurrentModel 27 | # but we disabled concurrency only in TestModelWithCustomOptions 28 | import concurrency.api as api 29 | 30 | concurrency_enabled1 = SimpleConcurrentModel.objects.get_or_create( 31 | **{"username": "test"} 32 | )[0] 33 | concurrency_enabled2 = SimpleConcurrentModel.objects.get_or_create( 34 | **{"username": "test"} 35 | )[0] 36 | v1 = api.get_revision_of_object(concurrency_enabled1) 37 | v2 = api.get_revision_of_object(concurrency_enabled2) 38 | assert v1 == v2, "got same row with different version (%s/%s)" % (v1, v2) 39 | concurrency_enabled1.save() 40 | assert concurrency_enabled1.pk is not None # sanity check 41 | self.assertRaises(RecordModifiedError, concurrency_enabled2.save) 42 | 43 | concurrency_disabled1 = ConcurrencyDisabledModel.objects.get_or_create( 44 | **{"username": "test"} 45 | )[0] 46 | concurrency_disabled2 = ConcurrencyDisabledModel.objects.get_or_create( 47 | **{"username": "test"} 48 | )[0] 49 | v1 = api.get_revision_of_object(concurrency_disabled1) 50 | v2 = api.get_revision_of_object(concurrency_disabled2) 51 | assert v1 == v2, "got same row with different version (%s/%s)" % (v1, v2) 52 | concurrency_disabled1.save() 53 | assert concurrency_disabled1.pk is not None # sanity check 54 | v1 = api.get_revision_of_object(concurrency_disabled1) 55 | v2 = api.get_revision_of_object(concurrency_disabled2) 56 | assert v1 != v2 57 | -------------------------------------------------------------------------------- /tests/test_conditional.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from demo.models import ( 5 | ConditionalVersionModel, 6 | ConditionalVersionModelSelfRelation, 7 | ConditionalVersionModelWithoutMeta, 8 | ThroughRelation, 9 | ) 10 | from django.contrib.auth.models import User 11 | 12 | from concurrency.exceptions import RecordModifiedError 13 | from concurrency.utils import refetch 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @pytest.fixture 19 | def user(): 20 | return User.objects.get_or_create(username="username")[0] 21 | 22 | 23 | @pytest.fixture 24 | def instance(user): 25 | return ConditionalVersionModel.objects.get_or_create( 26 | field1="1", user=user, field2="1", field3="1" 27 | )[0] 28 | 29 | 30 | @pytest.fixture 31 | def instance_no_meta(user): 32 | return ConditionalVersionModelWithoutMeta.objects.create( 33 | field1="1", user=user, field2="1", field3="1" 34 | ) 35 | 36 | 37 | @pytest.fixture 38 | def instance_self_relation(): 39 | a = ConditionalVersionModelSelfRelation.objects.create() 40 | b = ConditionalVersionModelSelfRelation.objects.create() 41 | b.relations.add(a) 42 | return b 43 | 44 | 45 | @pytest.mark.django_db 46 | def test_standard_save(instance): 47 | # only increment if checked field 48 | instance = refetch(instance) 49 | version1 = instance.get_concurrency_version() 50 | assert version1 == 1 # version2 > version1 51 | 52 | instance.field1 = "2" 53 | instance.save() 54 | version2 = instance.get_concurrency_version() 55 | assert version2 == 2 # version2 > version1 56 | 57 | instance.field3 = "3" 58 | instance.save() 59 | version3 = instance.get_concurrency_version() 60 | assert version3 == 2 # version3 == version2 61 | 62 | instance.user = None 63 | instance.save() 64 | version4 = instance.get_concurrency_version() 65 | assert version4 == 3 # version4 > version2 66 | 67 | 68 | @pytest.mark.django_db(transaction=True) 69 | def test_conflict(instance): 70 | # Scenario: batch change field present in ConcurrencyMeta.check_fields 71 | # the user is NOT ALLOWED to save 72 | batch_instance = instance.__class__.objects.get(pk=instance.pk) 73 | assert batch_instance.version == instance.version 74 | 75 | batch_instance.field1 = "aaaa" 76 | batch_instance.save() 77 | 78 | with pytest.raises(RecordModifiedError): 79 | instance.save() 80 | 81 | 82 | @pytest.mark.django_db(transaction=True) 83 | def test_save_allowed(instance): 84 | # Scenario: batch change field NOT present in ConcurrencyMeta.check_fields 85 | # the user is ALLOWED to save 86 | batch_instance = instance.__class__.objects.get(pk=instance.pk) 87 | assert batch_instance.version == instance.version 88 | instance = refetch(instance) 89 | batch_instance = refetch(instance) 90 | 91 | batch_instance.field3 = "aaaa" 92 | batch_instance.save() 93 | instance.save() 94 | 95 | 96 | @pytest.mark.django_db(transaction=True) 97 | def test_conflict_no_meta(instance_no_meta): 98 | # Scenario: batch change any field, 99 | # the user is NOT ALLOWED to save 100 | batch_instance = instance_no_meta.__class__.objects.get(pk=instance_no_meta.pk) 101 | assert batch_instance.version == instance_no_meta.version 102 | 103 | batch_instance.field1 = "aaaa" 104 | batch_instance.save() 105 | 106 | with pytest.raises(RecordModifiedError): 107 | instance_no_meta.save() 108 | 109 | 110 | @pytest.mark.django_db() 111 | def test_self_relations(): 112 | a = ConditionalVersionModelSelfRelation.objects.create(name="a") 113 | ConditionalVersionModelSelfRelation.objects.create(name="b") 114 | 115 | r = ThroughRelation.objects.create(left=a, right=a) 116 | r.save() 117 | 118 | a1 = ConditionalVersionModelSelfRelation.objects.get(pk=a.pk) 119 | a1.name = "a" 120 | a1.save() 121 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from concurrency.config import AppSettings 5 | from concurrency.utils import fqn 6 | 7 | 8 | def test_config(settings): 9 | settings.APP_OVERRIDE = "overridden" 10 | 11 | class MySettings(AppSettings): 12 | defaults = { 13 | "ENTRY1": "abc", 14 | "ENTRY2": 123, 15 | "OVERRIDE": None, 16 | "CALLBACK": fqn(fqn), 17 | } 18 | 19 | conf = MySettings("APP") 20 | assert str(conf.ENTRY1) == str(settings.APP_ENTRY1) 21 | 22 | assert str(conf.OVERRIDE) == str(settings.APP_OVERRIDE) 23 | 24 | conf = MySettings("MYAPP") 25 | assert conf.ENTRY2 == settings.MYAPP_ENTRY2 26 | 27 | settings.MYAPP_CALLBACK = fqn 28 | conf = MySettings("MYAPP") 29 | assert conf.CALLBACK == fqn 30 | 31 | with pytest.raises(ImproperlyConfigured): 32 | settings.OTHER_CALLBACK = 222 33 | conf = MySettings("OTHER") 34 | 35 | 36 | def test_IGNORE_DEFAULT(settings): 37 | with pytest.raises(ImproperlyConfigured) as excinfo: 38 | settings.CONCURRENCY_IGNORE_DEFAULT = False 39 | AppSettings("") 40 | assert ( 41 | str(excinfo.value) 42 | == "IGNORE_DEFAULT has been removed in django-concurrency 1.5. Use VERSION_FIELD_REQUIRED instead" 43 | ) 44 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import SimpleConcurrentModel 3 | 4 | from concurrency.core import _select_lock 5 | from concurrency.exceptions import RecordModifiedError 6 | from concurrency.utils import refetch 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_select_lock(settings): 11 | s1 = SimpleConcurrentModel.objects.create() 12 | s2 = refetch(s1) 13 | assert s1.version == s2.version 14 | s2.save() 15 | with pytest.raises(RecordModifiedError): 16 | _select_lock(s1) 17 | 18 | settings.CONCURRENCY_ENABLED = False 19 | _select_lock(s1) 20 | -------------------------------------------------------------------------------- /tests/test_enable_disable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import AutoIncConcurrentModel, SimpleConcurrentModel 3 | from demo.util import nextname 4 | from django.contrib.auth.models import User 5 | from django.test.utils import override_settings 6 | 7 | from concurrency.api import concurrency_disable_increment, disable_concurrency 8 | from concurrency.exceptions import RecordModifiedError 9 | from concurrency.utils import refetch 10 | 11 | 12 | @pytest.mark.django_db(transaction=False) 13 | def test_disable_concurrency_settings(settings): 14 | with override_settings(CONCURRENCY_ENABLED=False): 15 | instance1 = SimpleConcurrentModel(username=next(nextname)) 16 | instance1.save() 17 | refetch(instance1).save() 18 | 19 | 20 | @pytest.mark.django_db(transaction=False) 21 | def test_disable_concurrency_global(): 22 | instance1 = SimpleConcurrentModel(username=next(nextname)) 23 | instance2 = AutoIncConcurrentModel(username=next(nextname)) 24 | instance1.save() 25 | instance2.save() 26 | refetch(instance1).save() 27 | refetch(instance2).save() 28 | with disable_concurrency(): 29 | instance1.save() 30 | instance2.save() 31 | 32 | copy2 = refetch(instance2) 33 | refetch(instance2).save() 34 | with pytest.raises(RecordModifiedError): 35 | copy2.save() 36 | 37 | 38 | @pytest.mark.django_db(transaction=False) 39 | def test_disable_concurrency_not_managed(): 40 | u = User(username="u1") 41 | with disable_concurrency(u): 42 | u.save() 43 | 44 | 45 | @pytest.mark.django_db(transaction=False) 46 | def test_disable_concurrency_decorator(): 47 | @disable_concurrency(SimpleConcurrentModel) 48 | def test1(): 49 | instance = SimpleConcurrentModel(username=next(nextname)) 50 | instance.save() 51 | copy = refetch(instance) 52 | copy.save() 53 | instance.save() 54 | 55 | test1() 56 | 57 | 58 | @pytest.mark.django_db(transaction=False) 59 | def test_disable_concurrency_class(model_class=SimpleConcurrentModel): 60 | instance = model_class(username=next(nextname)) 61 | instance.save() 62 | copy = refetch(instance) 63 | copy.save() 64 | with disable_concurrency(SimpleConcurrentModel): 65 | instance.save() 66 | 67 | 68 | @pytest.mark.django_db(transaction=False) 69 | def test_disable_concurrency_instance(model_class=SimpleConcurrentModel): 70 | instance1 = model_class(username=next(nextname)) 71 | instance1.save() 72 | copy1 = refetch(instance1) 73 | copy1.save() 74 | 75 | instance2 = model_class(username=next(nextname)) 76 | instance2.save() 77 | copy2 = refetch(instance2) 78 | copy2.save() 79 | 80 | with disable_concurrency(instance1): 81 | instance1.save() 82 | with pytest.raises(RecordModifiedError): 83 | instance2.save() 84 | 85 | 86 | @pytest.mark.django_db(transaction=False) 87 | def test_concurrency_disable_increment(): 88 | instance1 = AutoIncConcurrentModel(username=next(nextname)) 89 | assert instance1.version == 0 90 | instance1.save() 91 | instance1.refresh_from_db() 92 | assert instance1.version == 1 93 | with concurrency_disable_increment(instance1): 94 | instance1.save() 95 | instance1.save(update_fields=("username",)) 96 | instance1.refresh_from_db() 97 | assert instance1.version == 1 98 | instance1.save() 99 | assert instance1.version == 2 100 | 101 | 102 | @pytest.mark.django_db(transaction=False) 103 | def test_concurrency_disable_increment_on_class(): 104 | instance1 = AutoIncConcurrentModel(username=next(nextname)) 105 | assert instance1.version == 0 106 | instance1.save() 107 | assert instance1.version == 1 108 | with concurrency_disable_increment(AutoIncConcurrentModel): 109 | instance1.save() 110 | instance1.save() 111 | assert instance1.version == 1 112 | instance1.save() 113 | assert instance1.version == 2 114 | 115 | 116 | @pytest.mark.django_db(transaction=False) 117 | def test_concurrency_disable_increment_as_decorator(): 118 | instance1 = AutoIncConcurrentModel(username=next(nextname)) 119 | 120 | @concurrency_disable_increment(instance1) 121 | def test(): 122 | assert instance1.version == 0 123 | instance1.save() 124 | assert instance1.version == 1 125 | instance1.save() 126 | instance1.save() 127 | assert instance1.version == 1 128 | 129 | test() 130 | instance1.save() 131 | assert instance1.version == 2 132 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import Issue3TestModel, SimpleConcurrentModel 3 | from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation 4 | from django.forms.models import modelform_factory 5 | from django.forms.widgets import HiddenInput, TextInput 6 | from django.test import TestCase, override_settings 7 | from django.test.testcases import SimpleTestCase 8 | from django.utils.encoding import smart_str 9 | from django.utils.translation import gettext as _ 10 | 11 | from concurrency.exceptions import VersionError 12 | from concurrency.forms import ( 13 | ConcurrentForm, 14 | VersionField, 15 | VersionFieldSigner, 16 | VersionWidget, 17 | ) 18 | 19 | __all__ = ["WidgetTest", "FormFieldTest", "ConcurrentFormTest"] 20 | 21 | 22 | class DummySigner: 23 | def sign(self, value): 24 | return smart_str(value) 25 | 26 | def unsign(self, signed_value): 27 | return smart_str(signed_value) 28 | 29 | 30 | class WidgetTest(TestCase): 31 | def test(self): 32 | w = VersionWidget() 33 | self.assertHTMLEqual( 34 | w.render("ver", None), '
' 35 | ) 36 | self.assertHTMLEqual( 37 | w.render("ver", 100), 38 | '
100
', 39 | ) 40 | 41 | 42 | class FormFieldTest(SimpleTestCase): 43 | def test_with_wrong_signer(self): 44 | with self.settings(CONCURRENCY_FIELD_SIGNER="invalid.Signer"): 45 | with pytest.raises(ImproperlyConfigured): 46 | VersionField() 47 | 48 | def test_with_dummy_signer(self): 49 | f = VersionField(signer=DummySigner()) 50 | self.assertEqual(1, f.clean(1)) 51 | self.assertEqual(1, f.clean("1")) 52 | self.assertEqual(0, f.clean(None)) 53 | self.assertEqual(0, f.clean("")) 54 | self.assertRaises(VersionError, f.clean, "aa:bb") 55 | self.assertRaises(VersionError, f.clean, 1.5) 56 | 57 | def test(self): 58 | f = VersionField() 59 | self.assertEqual(1, f.clean(VersionFieldSigner().sign(1))) 60 | self.assertEqual(1, f.clean(VersionFieldSigner().sign("1"))) 61 | self.assertEqual(0, f.clean(None)) 62 | self.assertEqual(0, f.clean("")) 63 | self.assertRaises(VersionError, f.clean, "100") 64 | self.assertRaises(VersionError, f.clean, VersionFieldSigner().sign(1.5)) 65 | 66 | 67 | class ConcurrentFormTest(TestCase): 68 | def test_version(self): 69 | Form = modelform_factory( 70 | SimpleConcurrentModel, ConcurrentForm, exclude=("char_field",) 71 | ) 72 | form = Form() 73 | self.assertIsInstance(form.fields["version"].widget, HiddenInput) 74 | 75 | def test_clean(self): 76 | pass 77 | 78 | def test_dummy_signer(self): 79 | obj, __ = Issue3TestModel.objects.get_or_create(username="aaa") 80 | Form = modelform_factory( 81 | Issue3TestModel, 82 | fields=("id", "revision"), 83 | form=type( 84 | "xxx", 85 | (ConcurrentForm,), 86 | {"revision": VersionField(signer=DummySigner())}, 87 | ), 88 | ) 89 | data = {"id": 1, "revision": obj.revision} 90 | form = Form(data, instance=obj) 91 | self.assertTrue(form.is_valid(), form.non_field_errors()) 92 | 93 | def test_signer(self): 94 | Form = modelform_factory( 95 | Issue3TestModel, form=ConcurrentForm, exclude=("char_field",) 96 | ) 97 | form = Form({"username": "aaa"}) 98 | self.assertTrue(form.is_valid(), form.non_field_errors()) 99 | 100 | def test_initial_value(self): 101 | Form = modelform_factory( 102 | SimpleConcurrentModel, 103 | type("xxx", (ConcurrentForm,), {}), 104 | exclude=("char_field",), 105 | ) 106 | form = Form({"username": "aaa"}) 107 | self.assertHTMLEqual( 108 | str(form["version"]), 109 | '', 110 | ) 111 | self.assertTrue(form.is_valid(), form.non_field_errors()) 112 | 113 | def test_initial_value_with_custom_signer(self): 114 | Form = modelform_factory( 115 | Issue3TestModel, 116 | exclude=("char_field",), 117 | form=type( 118 | "xxx", 119 | (ConcurrentForm,), 120 | {"version": VersionField(signer=DummySigner())}, 121 | ), 122 | ) 123 | form = Form({"username": "aaa"}) 124 | self.assertHTMLEqual( 125 | str(form["version"]), 126 | '', 127 | ) 128 | self.assertTrue(form.is_valid(), form.non_field_errors()) 129 | 130 | def test_tamperig(self): 131 | obj, __ = Issue3TestModel.objects.get_or_create(username="aaa") 132 | Form = modelform_factory( 133 | Issue3TestModel, ConcurrentForm, exclude=("char_field",) 134 | ) 135 | data = { 136 | "username": "aaa", 137 | "last_name": None, 138 | "date_field": None, 139 | "char_field": None, 140 | "version": "abc", 141 | "id": 1, 142 | "revision": obj.revision, 143 | } 144 | form = Form(data, instance=obj) 145 | self.assertRaises(SuspiciousOperation, form.is_valid) 146 | 147 | def test_custom_name(self): 148 | Form = modelform_factory( 149 | Issue3TestModel, ConcurrentForm, exclude=("char_field",) 150 | ) 151 | form = Form() 152 | self.assertIsInstance(form.fields["version"].widget, TextInput) 153 | self.assertIsInstance(form.fields["revision"].widget, HiddenInput) 154 | 155 | def test_save(self): 156 | obj, __ = Issue3TestModel.objects.get_or_create(username="aaa") 157 | 158 | obj_copy = Issue3TestModel.objects.get(pk=obj.pk) 159 | Form = modelform_factory( 160 | Issue3TestModel, 161 | ConcurrentForm, 162 | fields=( 163 | "username", 164 | "last_name", 165 | "date_field", 166 | "char_field", 167 | "version", 168 | "id", 169 | "revision", 170 | ), 171 | ) 172 | data = { 173 | "username": "aaa", 174 | "last_name": None, 175 | "date_field": None, 176 | "char_field": None, 177 | "version": "abc", 178 | "id": 1, 179 | "revision": VersionFieldSigner().sign(obj.revision), 180 | } 181 | form = Form(data, instance=obj) 182 | obj_copy.save() # save 183 | 184 | self.assertFalse(form.is_valid()) 185 | self.assertIn(_("Record Modified"), form.non_field_errors()) 186 | 187 | def test_is_valid(self): 188 | obj, __ = Issue3TestModel.objects.get_or_create(username="aaa") 189 | Form = modelform_factory( 190 | Issue3TestModel, 191 | ConcurrentForm, 192 | fields=( 193 | "username", 194 | "last_name", 195 | "date_field", 196 | "char_field", 197 | "version", 198 | "id", 199 | "revision", 200 | ), 201 | ) 202 | data = { 203 | "username": "aaa", 204 | "last_name": None, 205 | "date_field": None, 206 | "char_field": None, 207 | "version": "abc", 208 | "id": 1, 209 | "revision": VersionFieldSigner().sign(obj.revision), 210 | } 211 | form = Form(data, instance=obj) 212 | obj.save() # save again simulate concurrent editing 213 | self.assertRaises(ValueError, form.save) 214 | 215 | 216 | def test_disabled(db, settings): 217 | obj, __ = SimpleConcurrentModel.objects.get_or_create(username="aaa") 218 | Form = modelform_factory( 219 | SimpleConcurrentModel, ConcurrentForm, fields=("username", "id", "version") 220 | ) 221 | data = { 222 | "username": "aaa", 223 | "id": 1, 224 | "version": VersionFieldSigner().sign(obj.version), 225 | } 226 | form = Form(data, instance=obj) 227 | obj.save() # save again simulate concurrent editing 228 | with override_settings(CONCURRENCY_ENABLED=False): 229 | obj2 = form.save() 230 | assert obj2.version == obj.version 231 | -------------------------------------------------------------------------------- /tests/test_issues.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | from demo.admin import ActionsModelAdmin, admin_register 5 | from demo.base import AdminTestCase 6 | from demo.models import ListEditableConcurrentModel, SimpleConcurrentModel 7 | from demo.util import attributes, unique_id 8 | from django.contrib.admin.sites import site 9 | from django.contrib.auth.models import User 10 | from django.core.management import call_command 11 | from django.core.management.base import SystemCheckError 12 | from django.http import QueryDict 13 | from django.test import override_settings 14 | from django.test.client import RequestFactory 15 | from django.test.testcases import SimpleTestCase 16 | from django.utils.encoding import force_str 17 | 18 | from concurrency.admin import ConcurrentModelAdmin 19 | from concurrency.compat import concurrency_param_name 20 | from concurrency.config import CONCURRENCY_LIST_EDITABLE_POLICY_SILENT 21 | from concurrency.exceptions import RecordModifiedError 22 | from concurrency.forms import ConcurrentForm 23 | from concurrency.templatetags.concurrency import identity 24 | from concurrency.utils import refetch 25 | 26 | 27 | def get_fake_request(params): 28 | u, __ = User.objects.get_or_create(username="sax") 29 | setattr(u, "is_authenticated()", True) 30 | setattr(u, "selected_office", False) 31 | 32 | request = RequestFactory().request() 33 | request.user = u 34 | 35 | querydict = QueryDict(params) 36 | request.POST = querydict 37 | 38 | return request 39 | 40 | 41 | class TestIssue16(AdminTestCase): 42 | def test_concurrency(self): 43 | id = 1 44 | admin_register(ListEditableConcurrentModel, ActionsModelAdmin) 45 | model_admin = site._registry[ListEditableConcurrentModel] 46 | with attributes( 47 | ( 48 | ConcurrentModelAdmin, 49 | "list_editable_policy", 50 | CONCURRENCY_LIST_EDITABLE_POLICY_SILENT, 51 | ), 52 | (ConcurrentModelAdmin, "form", ConcurrentForm), 53 | ): 54 | obj, __ = ListEditableConcurrentModel.objects.get_or_create(pk=id) 55 | 56 | # post_param = 'form-_concurrency_version' if django.VERSION[:2] >= (4, 0) else '_concurrency_version' 57 | 58 | # request1 = get_fake_request('pk={}&{}_1=2'.format(id, post_param)) 59 | request1 = get_fake_request(f"pk={id}&{concurrency_param_name}_1=2") 60 | 61 | model_admin.save_model(request1, obj, None, True) 62 | 63 | self.assertIn(obj.pk, model_admin._get_conflicts(request1)) 64 | 65 | obj = refetch(obj) 66 | request2 = get_fake_request( 67 | f"pk={id}&{concurrency_param_name}_1={obj.version}" 68 | ) 69 | model_admin.save_model(request2, obj, None, True) 70 | self.assertNotIn(obj.pk, model_admin._get_conflicts(request2)) 71 | 72 | 73 | class TestIssue18(SimpleTestCase): 74 | def test_identity_tag(self): 75 | id = next(unique_id) 76 | 77 | obj = ListEditableConcurrentModel(pk=id) 78 | self.assertTrue(re.match(r"^%s,\d+$" % id, identity(obj))) 79 | 80 | g = User(username="UserTest", pk=3) 81 | self.assertEqual(identity(g), force_str(g.pk)) 82 | 83 | 84 | @pytest.mark.django_db() 85 | def test_issue_54(): 86 | with override_settings(CONCURRENCY_VERSION_FIELD_REQUIRED=False): 87 | m = SimpleConcurrentModel(version=0) 88 | m.save() 89 | SimpleConcurrentModel.objects.update(version=0) 90 | m1 = SimpleConcurrentModel.objects.get(pk=m.pk) 91 | m2 = SimpleConcurrentModel.objects.get(pk=m.pk) 92 | assert m1.version == m2.version == 0 93 | m1.save() 94 | m2.save() 95 | 96 | with override_settings(CONCURRENCY_VERSION_FIELD_REQUIRED=True): 97 | m = SimpleConcurrentModel(version=0) 98 | m.save() 99 | SimpleConcurrentModel.objects.update(version=0) 100 | m1 = SimpleConcurrentModel.objects.get(pk=m.pk) 101 | m2 = SimpleConcurrentModel.objects.get(pk=m.pk) 102 | assert m1.version == m2.version == 0 103 | m1.save() 104 | 105 | with pytest.raises(RecordModifiedError): 106 | m2.save() 107 | 108 | 109 | @pytest.mark.django_db() 110 | def test_issue_81a(monkeypatch): 111 | monkeypatch.setattr("demo.admin.ActionsModelAdmin.fields", ("id",)) 112 | with pytest.raises(SystemCheckError) as e: 113 | call_command("check") 114 | assert "concurrency.A001" in str(e.value) 115 | 116 | 117 | @pytest.mark.django_db() 118 | def test_issue_81b(monkeypatch): 119 | fieldsets = (("Standard info", {"fields": ("id",)}),) 120 | monkeypatch.setattr("demo.admin.ActionsModelAdmin.fieldsets", fieldsets) 121 | with pytest.raises(SystemCheckError) as e: 122 | call_command("check") 123 | assert "concurrency.A002" in str(e.value) 124 | -------------------------------------------------------------------------------- /tests/test_loaddata_dumpdata.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from io import StringIO 5 | 6 | import pytest 7 | from demo.models import SimpleConcurrentModel 8 | from django.core.management import call_command 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @pytest.mark.django_db() 14 | def test_dumpdata(): 15 | SimpleConcurrentModel.objects.create() 16 | out = StringIO() 17 | call_command("dumpdata", "demo", stdout=out) 18 | data = json.loads(out.getvalue()) 19 | assert data 20 | 21 | 22 | @pytest.mark.django_db(transaction=True) 23 | def test_loaddata_fail(): 24 | datafile = os.path.join(os.path.dirname(__file__), "dumpdata.json") 25 | with open(datafile, "r") as f: 26 | data = json.load(f) 27 | pk = data[0]["pk"] 28 | 29 | call_command("loaddata", datafile, stdout=StringIO()) 30 | 31 | assert SimpleConcurrentModel.objects.get(id=pk).username == "loaded" 32 | -------------------------------------------------------------------------------- /tests/test_manager.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import ( 3 | AutoIncConcurrentModel, 4 | ConcreteModel, 5 | CustomSaveModel, 6 | InheritedModel, 7 | ProxyModel, 8 | SimpleConcurrentModel, 9 | ) 10 | from demo.util import nextname, unique_id, with_models, with_std_models 11 | 12 | from concurrency.exceptions import RecordModifiedError 13 | from concurrency.utils import refetch 14 | 15 | 16 | @pytest.mark.django_db 17 | @with_std_models 18 | def test_get_or_create(model_class): 19 | instance, __ = model_class.objects.get_or_create(pk=next(unique_id)) 20 | assert instance.get_concurrency_version() 21 | instance.save() 22 | 23 | 24 | @pytest.mark.django_db 25 | @with_std_models 26 | def test_get_or_create_with_pk(model_class): 27 | instance, __ = model_class.objects.get_or_create(pk=next(unique_id)) 28 | assert instance.get_concurrency_version() 29 | instance.save() 30 | copy = refetch(instance) 31 | copy.save() 32 | with pytest.raises(RecordModifiedError): 33 | instance.save() 34 | assert copy.get_concurrency_version() > instance.get_concurrency_version() 35 | 36 | 37 | @pytest.mark.django_db(transaction=False) 38 | def test_create(model_class=SimpleConcurrentModel): 39 | instance = model_class.objects.create(pk=next(unique_id)) 40 | assert instance.get_concurrency_version() 41 | 42 | 43 | @pytest.mark.django_db 44 | @with_models( 45 | SimpleConcurrentModel, 46 | AutoIncConcurrentModel, 47 | InheritedModel, 48 | CustomSaveModel, 49 | ConcreteModel, 50 | ProxyModel, 51 | ) 52 | def test_update(model_class): 53 | # Manager.update() does not change version number 54 | instance = model_class.objects.create( 55 | pk=next(unique_id), username=next(nextname).lower() 56 | ) 57 | field_value = instance.username 58 | model_class.objects.filter(pk=instance.pk).update( 59 | username=instance.username.upper() 60 | ) 61 | 62 | instance2 = refetch(instance) 63 | assert instance2.username == field_value.upper() 64 | assert instance2.get_concurrency_version() == instance.get_concurrency_version() 65 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import mock 2 | from demo.base import AdminTestCase 3 | from demo.models import SimpleConcurrentModel 4 | from demo.util import DELETE_ATTRIBUTE, attributes, unique_id 5 | from django.conf import settings 6 | from django.contrib.admin.sites import site 7 | from django.http import HttpRequest 8 | from django.test.utils import override_settings 9 | from django.urls import reverse 10 | 11 | from concurrency.admin import ConcurrentModelAdmin 12 | from concurrency.config import CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL 13 | from concurrency.exceptions import RecordModifiedError 14 | from concurrency.middleware import ConcurrencyMiddleware 15 | 16 | 17 | def _get_request(path): 18 | request = HttpRequest() 19 | request.META = { 20 | "SERVER_NAME": "testserver", 21 | "SERVER_PORT": 80, 22 | } 23 | request.path = request.path_info = "/middleware/%s" % path 24 | return request 25 | 26 | 27 | def test_middleware(): 28 | handler = mock.Mock(status_code=409) 29 | type(handler.return_value).status_code = mock.PropertyMock(return_value=409) 30 | 31 | with override_settings(CONCURRENCY_HANDLER409=handler): 32 | request = _get_request("needsquoting#") 33 | r = ConcurrencyMiddleware().process_exception( 34 | request, RecordModifiedError(target=SimpleConcurrentModel()) 35 | ) 36 | assert r.status_code == 409 37 | 38 | 39 | class ConcurrencyMiddlewareTest1(AdminTestCase): 40 | def _get_request(self, path): 41 | request = HttpRequest() 42 | request.META = { 43 | "SERVER_NAME": "testserver", 44 | "SERVER_PORT": 80, 45 | } 46 | request.path = request.path_info = "/middleware/%s" % path 47 | return request 48 | 49 | @mock.patch("django.core.signals.got_request_exception.send", mock.Mock()) 50 | def test_process_exception(self): 51 | """ 52 | Tests that RecordModifiedError is handled correctly. 53 | """ 54 | id = next(unique_id) 55 | m, __ = SimpleConcurrentModel.objects.get_or_create(pk=id) 56 | copy = SimpleConcurrentModel.objects.get(pk=m.pk) 57 | copy.save() 58 | request = self._get_request("/") 59 | r = ConcurrencyMiddleware().process_exception( 60 | request, RecordModifiedError(target=m) 61 | ) 62 | self.assertEqual(r.status_code, 409) 63 | 64 | 65 | class ConcurrencyMiddlewareTest2(AdminTestCase): 66 | @property 67 | def settings_middleware(self): 68 | return getattr(settings, self.middleware_setting_name) + [ 69 | "concurrency.middleware.ConcurrencyMiddleware" 70 | ] 71 | 72 | @settings_middleware.setter 73 | def settings_middleware(self, value): 74 | setattr(settings, self.middleware_setting_name, value) 75 | 76 | def test_in_admin(self): 77 | id = next(unique_id) 78 | model_admin = site._registry[SimpleConcurrentModel] 79 | 80 | with attributes( 81 | ( 82 | model_admin.__class__, 83 | "list_editable_policy", 84 | CONCURRENCY_LIST_EDITABLE_POLICY_ABORT_ALL, 85 | ), 86 | (ConcurrentModelAdmin, "form", DELETE_ATTRIBUTE), 87 | ): 88 | saved, __ = SimpleConcurrentModel.objects.get_or_create(pk=id) 89 | 90 | url = reverse("admin:demo_simpleconcurrentmodel_change", args=[saved.pk]) 91 | res = self.app.get(url, user=self.user.username) 92 | form = res.forms["simpleconcurrentmodel_form"] 93 | 94 | saved.save() # create conflict here 95 | 96 | res = form.submit(expect_errors=True) 97 | 98 | self.assertEqual(res.status_code, 409) 99 | 100 | target = res.context["target"] 101 | self.assertIn("target", res.context) 102 | self.assertIn("saved", res.context) 103 | 104 | self.assertEqual(res.context["target"].version, target.version) 105 | self.assertEqual(res.context["saved"].version, saved.version) 106 | self.assertEqual(res.context["request_path"], url) 107 | -------------------------------------------------------------------------------- /tests/test_reversion.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import ReversionConcurrentModel 3 | from django.urls import reverse 4 | from reversion import add_to_revision, revisions, set_comment 5 | from reversion.models import Version 6 | 7 | 8 | @pytest.mark.django_db 9 | @pytest.mark.functional 10 | def test_recover(admin_user, client): 11 | concurrentmodel = ReversionConcurrentModel.objects.create(username="USERNAME-OLD") 12 | with revisions.create_revision(): 13 | set_comment("Initial revision") 14 | add_to_revision(concurrentmodel) 15 | 16 | ver = Version.objects.get_for_model(concurrentmodel).first() 17 | url = reverse( 18 | "admin:demo_reversionconcurrentmodel_recover", args=[concurrentmodel.pk] 19 | ) 20 | res = client.get(url, user=admin_user.username) 21 | res.forms["reversionconcurrentmodel_form"].submit().follow() 22 | 23 | concurrentmodel2 = ReversionConcurrentModel.objects.get(pk=concurrentmodel.pk) 24 | assert concurrentmodel2.username == ver.field_dict["username"] 25 | assert concurrentmodel2.version > ver.field_dict["version"] 26 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from demo.models import SimpleConcurrentModel 5 | 6 | from concurrency.templatetags.concurrency import identity, is_version, version 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @pytest.fixture 12 | def obj(): 13 | return SimpleConcurrentModel.objects.create() 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_identity(obj): 18 | assert identity(obj).split(",") == [str(obj.pk), str(obj.version)] 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_version(obj): 23 | assert version(obj) == obj.version 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_is_version(obj): 28 | assert is_version(obj._concurrencymeta.field) 29 | -------------------------------------------------------------------------------- /tests/test_threads.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from conftest import skippypy 3 | from demo.models import TriggerConcurrentModel 4 | from demo.util import concurrently 5 | from django import db 6 | from django.db import transaction 7 | 8 | from concurrency.exceptions import RecordModifiedError 9 | from concurrency.utils import refetch 10 | 11 | 12 | @skippypy 13 | @pytest.mark.django_db(transaction=True) 14 | def test_threads(): 15 | if db.connection.vendor == "sqlite": 16 | pytest.skip("in-memory sqlite db can't be used between threads") 17 | 18 | obj = TriggerConcurrentModel.objects.create() 19 | transaction.commit() 20 | 21 | @concurrently(25) 22 | def run(): 23 | for i in range(5): 24 | while True: 25 | x = refetch(obj) 26 | transaction.commit() 27 | x.count += 1 28 | try: 29 | x.save() 30 | transaction.commit() 31 | except RecordModifiedError: 32 | # retry 33 | pass 34 | else: 35 | break 36 | 37 | run() 38 | assert refetch(obj).count == 5 * 25 39 | -------------------------------------------------------------------------------- /tests/test_triggers.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/test_triggers.py -------------------------------------------------------------------------------- /tests/test_triggerversionfield.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | from demo.models import TriggerConcurrentModel 4 | 5 | # Register an event to reset saved queries when a Django request is started. 6 | from demo.util import nextname 7 | from django.core.signals import request_started 8 | from django.db import IntegrityError, connection, connections 9 | 10 | from concurrency.exceptions import RecordModifiedError 11 | from concurrency.utils import refetch 12 | 13 | 14 | def reset_queries(**kwargs): 15 | for conn in connections.all(): 16 | conn.queries = [] 17 | 18 | 19 | class CaptureQueriesContext: 20 | """ 21 | Context manager that captures queries executed by the specified connection. 22 | """ 23 | 24 | def __init__(self, connection): 25 | self.connection = connection 26 | 27 | def __iter__(self): 28 | return iter(self.captured_queries) 29 | 30 | def __getitem__(self, index): 31 | return self.captured_queries[index] 32 | 33 | def __len__(self): 34 | return len(self.captured_queries) 35 | 36 | @property 37 | def captured_queries(self): 38 | return self.connection.queries[self.initial_queries : self.final_queries] 39 | 40 | def __enter__(self): 41 | self.use_debug_cursor = self.connection.use_debug_cursor 42 | self.connection.use_debug_cursor = True 43 | self.initial_queries = len(self.connection.queries) 44 | self.final_queries = None 45 | request_started.disconnect(reset_queries) 46 | return self 47 | 48 | def __exit__(self, exc_type, exc_value, traceback): 49 | self.connection.use_debug_cursor = self.use_debug_cursor 50 | request_started.connect(reset_queries) 51 | if exc_type is not None: 52 | return 53 | self.final_queries = len(self.connection.queries) 54 | 55 | 56 | @pytest.mark.django_db 57 | def test_trigger_external_update(): 58 | instance = TriggerConcurrentModel() 59 | assert instance.pk is None 60 | assert instance.version == 0 61 | 62 | instance.save() 63 | assert instance.version == 1 64 | with connection.cursor() as c: 65 | c.execute( 66 | "UPDATE {} SET username='aaa' WHERE id='{}'".format( 67 | instance._meta.db_table, instance.pk 68 | ) 69 | ) 70 | obj = refetch(instance) 71 | assert obj.version == 2 72 | 73 | 74 | @pytest.mark.django_db 75 | def test_trigger_external_create(): 76 | with connection.cursor() as c: 77 | c.execute( 78 | "INSERT INTO {} (username, count, cm_version_id) VALUES ('abc', 1, -1)".format( 79 | TriggerConcurrentModel._meta.db_table 80 | ) 81 | ) 82 | instance = TriggerConcurrentModel.objects.get(username="abc") 83 | obj = refetch(instance) 84 | assert obj.version == -1 85 | 86 | 87 | @pytest.mark.django_db 88 | def test_trigger(): 89 | instance = TriggerConcurrentModel() 90 | assert instance.pk is None 91 | assert instance.version == 0 92 | 93 | instance.username = next(nextname) 94 | instance.save() # insert 95 | instance = refetch(instance) 96 | assert instance.version == 1 97 | 98 | instance.username = next(nextname) 99 | instance.save() # update 100 | assert instance.version == 2 101 | 102 | instance.username = next(nextname) 103 | instance.save() # update 104 | assert instance.version == 3 105 | 106 | instance.username = next(nextname) 107 | instance.save(refetch=True) # update 108 | assert instance.version == 4 109 | 110 | copy = refetch(instance) 111 | copy.save() 112 | 113 | with pytest.raises(RecordModifiedError): 114 | instance.save() 115 | 116 | 117 | @pytest.mark.django_db 118 | def test_trigger_do_not_increase_version_if_error(): 119 | instance = TriggerConcurrentModel() 120 | assert instance.pk is None 121 | assert instance.version == 0 122 | with mock.patch( 123 | "demo.models.TriggerConcurrentModel.save", side_effect=IntegrityError 124 | ): 125 | with pytest.raises(IntegrityError): 126 | instance.save() 127 | 128 | assert instance.version == 0 129 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from demo.models import SimpleConcurrentModel 5 | from django.test import TestCase 6 | 7 | import concurrency.fields 8 | from concurrency.utils import ConcurrencyTestMixin, deprecated, fqn 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @pytest.mark.django_db 14 | class TestConcurrencyTestMixin(ConcurrencyTestMixin, TestCase): 15 | concurrency_model = SimpleConcurrentModel 16 | 17 | 18 | def test_fqn(): 19 | with pytest.raises(ValueError): 20 | fqn("str") 21 | 22 | assert fqn(SimpleConcurrentModel) == "demo.models.SimpleConcurrentModel" 23 | assert fqn(SimpleConcurrentModel()) == "demo.models.SimpleConcurrentModel" 24 | assert fqn(concurrency.fields) == "concurrency.fields" 25 | 26 | 27 | def test_deprecated(): 28 | @deprecated() 29 | def foo1(x): 30 | return x 31 | 32 | with pytest.warns(DeprecationWarning): 33 | assert foo1(12) == 12 34 | 35 | def newfun(x): 36 | return 0 37 | 38 | @deprecated(newfun, "1.1") 39 | def foo2(x): 40 | return x 41 | 42 | with pytest.warns(DeprecationWarning): 43 | assert foo2(10) == 0 44 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saxix/django-concurrency/f9a6c29fdfec8e785308eecb7c0182f37f93eb3f/tests/test_views.py -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | tox-gh-actions 5 | tox-uv>=1.20.2 6 | env_list = 7 | lint 8 | pkg_meta 9 | d{52, 42, 32}-py{313}-{pg, mysql} 10 | skip_missing_interpreters = true 11 | 12 | [testenv] 13 | deps = 14 | pypy-pg: psycopg2cffi 15 | d32: django==3.2.* 16 | d32: django-reversion==3.* 17 | d42: django==4.2.* 18 | d42: django-reversion==4.* 19 | d52: django==5.2.* 20 | d52: django-reversion==5.* 21 | mysql: mysqlclient 22 | pass_env = 23 | PGHOST 24 | PGPORT 25 | PYTHONDONTWRITEBYTECODE 26 | set_env = 27 | DBNAME = concurrency 28 | PYTHONPATH = 29 | PYTHONPATH = ./src/:tests/demoapp 30 | mysql: DBENGINE = mysql 31 | pg: DBENGINE = pg 32 | sqlite: DBENGINE = sqlite 33 | change_dir = {toxinidir} 34 | commands = 35 | {posargs:pytest tests -rw --create-db} 36 | dependency_groups = dev 37 | whitelist_externals = 38 | mkdir 39 | /usr/local/bin/psql 40 | /bin/sh 41 | /usr/local/bin/mysql 42 | /usr/local/mysql/bin/mysql 43 | /usr/bin/psql 44 | 45 | [testenv:lint] 46 | description = run static analysis and style check using ruff 47 | skip_install = true 48 | deps = 49 | pre-commit-uv>=4.1.1 50 | pass_env = 51 | HOMEPATH 52 | PROGRAMDATA 53 | commands = 54 | pre-commit run --all-files --show-diff-on-failure 55 | 56 | [testenv:pkg_meta] 57 | description = check that the long description is valid 58 | skip_install = true 59 | deps = 60 | check-wheel-contents>=0.6 61 | twine>=5.1.1 62 | uv>=0.5 63 | commands = 64 | uv build --sdist --wheel --out-dir {env_tmp_dir} . 65 | twine check {env_tmp_dir}{/}* 66 | check-wheel-contents --no-config {env_tmp_dir} 67 | 68 | [testenv:mysql] 69 | commands = 70 | - mysql -u root -h 127.0.0.1 -e 'DROP DATABASE IF EXISTS concurrency;' 71 | - mysql -u root -h 127.0.0.1 -e 'CREATE DATABASE IF NOT EXISTS concurrency;' 72 | {[testenv]commands} 73 | 74 | [testenv:pg] 75 | commands = 76 | - psql -h $PGHOST -p $PGPORT -c 'DROP DATABASE "concurrency";' -U postgres 77 | - psql -h $PGHOST -p $PGPORT -c 'CREATE DATABASE "concurrency";' -U postgres 78 | {[testenv]commands} 79 | 80 | [testenv:clean] 81 | commands = 82 | mysql: - mysql -u root -e 'DROP DATABASE IF EXISTS concurrency;' 83 | pg: - psql -c 'DROP DATABASE "concurrency";' -U postgres 84 | 85 | [testenv:docs] 86 | skip_install = false 87 | commands = 88 | mkdocs build -d ./~build/docs 89 | dependency_groups = docs, type-check, dj 90 | 91 | [testenv:type] 92 | description = run type check on code base 93 | commands = 94 | mypy --strict src/ 95 | dependency_groups = type-check, dev 96 | --------------------------------------------------------------------------------