├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ ├── linting.yml │ ├── publish-to-pypi.yml │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── ChangeLog.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── docs ├── Makefile ├── _ext │ └── djangodocs.py ├── _templates │ └── dirtyfields-links.html ├── advanced.rst ├── conf.py ├── contributing.rst ├── credits.rst ├── customisation.rst ├── description.rst ├── index.rst ├── quickstart.rst ├── requirements.in └── requirements.txt ├── pyproject.toml ├── src └── dirtyfields │ ├── __init__.py │ ├── compare.py │ └── dirtyfields.py ├── tests-requirements.txt ├── tests ├── __init__.py ├── django_settings.py ├── files │ ├── bar.txt │ ├── blank1.png │ ├── blank2.png │ └── foo.txt ├── models.py ├── test_core.py ├── test_json_field_third_party.py ├── test_m2m_fields.py ├── test_memory_leak.py ├── test_non_regression.py ├── test_postgresql_specific.py ├── test_save_fields.py ├── test_specified_fields.py ├── test_timezone_aware_fields.py └── utils.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E133,W503 3 | max-line-length = 120 4 | extend-exclude = docs/_build,docs/.venv-docs 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug or regression 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Minimal code to reproduce the issue. If possible, write a test case to demonstrate your issue. 15 | 16 | ```python 17 | your code here... 18 | ``` 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Actual Behavior** 24 | What actually happens, include your stack trace if an exception is occurring. 25 | 26 | **Environment (please complete the following information):** 27 | - OS: 28 | - Python version: 29 | - Django version: 30 | - django-dirtyfields version: 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: Feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ develop ] 9 | schedule: 10 | - cron: '30 22 15 * *' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | language: [ 'python' ] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@v3 28 | with: 29 | languages: ${{ matrix.language }} 30 | # If you wish to specify custom queries, you can do so here or in a config file. 31 | # By default, queries listed here will override any specified in a config file. 32 | # Prefix the list here with "+" to use these queries and those in the config file. 33 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v3 37 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ develop ] 9 | schedule: 10 | - cron: '30 22 15 * *' 11 | 12 | jobs: 13 | flake8: 14 | name: flake8 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.9 25 | 26 | - name: Install flake8 27 | run: | 28 | python -m pip install --upgrade pip wheel 29 | python -m pip install flake8 30 | python --version 31 | pip list 32 | 33 | - name: Run flake8 34 | run: flake8 -v src tests docs 35 | 36 | bandit: 37 | name: bandit 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | - name: Set up Python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: 3.9 48 | 49 | - name: Install bandit 50 | run: | 51 | python -m pip install --upgrade pip wheel 52 | python -m pip install bandit 53 | 54 | - name: Run bandit 55 | # "B101:assert_used" is allowed in tests. 56 | run: | 57 | bandit -r src docs 58 | bandit -r tests --skip B101 59 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: "Build and Publish" 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | tags: [ '*' ] 7 | pull_request: 8 | # The branches below must be a subset of the branches above 9 | branches: [ develop ] 10 | schedule: 11 | - cron: '30 22 15 * *' 12 | 13 | jobs: 14 | build: 15 | name: Build Python distributions 16 | runs-on: ubuntu-latest 17 | strategy: 18 | # Matrix to exercise the build backend on all versions of python supported 19 | matrix: 20 | python: ['3.9', '3.10', '3.11', '3.12', '3.13'] 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python }} 27 | 28 | - name: Install pypa/build 29 | run: | 30 | pip install build 31 | python --version 32 | pip list 33 | 34 | - name: Build a binary wheel and a source tarball 35 | run: python -m build 36 | 37 | - uses: actions/upload-artifact@v4 38 | with: 39 | name: distributions-built-with-py${{ matrix.python }} 40 | path: dist/ 41 | 42 | publish: 43 | name: "Publish to PyPI" 44 | # Only upload for an actual tag 45 | if: ${{ startsWith(github.ref, 'refs/tags') }} 46 | runs-on: ubuntu-latest 47 | needs: 48 | - build 49 | steps: 50 | - uses: actions/download-artifact@v4 51 | with: 52 | # Download distributions from one job of the matrix 53 | name: "distributions-built-with-py3.12" 54 | path: "dist" 55 | 56 | - name: Publish distribution to PyPI 57 | uses: pypa/gh-action-pypi-publish@release/v1 58 | with: 59 | password: ${{ secrets.PYPI_API_TOKEN }} 60 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ develop ] 9 | schedule: 10 | - cron: '30 22 15 * *' 11 | 12 | jobs: 13 | pytest: 14 | name: pytest 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python: ['3.9', '3.10', '3.11', '3.12', '3.13'] 19 | django: ['2.2', '3.0', '3.1', '3.2', '4.0', '4.1', '4.2', '5.0', '5.1', '5.2'] 20 | database: ['postgres', 'sqlite'] 21 | exclude: 22 | - python: '3.9' 23 | django: '5.0' 24 | - python: '3.9' 25 | django: '5.1' 26 | - python: '3.9' 27 | django: '5.2' 28 | - python: '3.10' 29 | django: '2.2' 30 | - python: '3.10' 31 | django: '3.0' 32 | - python: '3.10' 33 | django: '3.1' 34 | - python: '3.11' 35 | django: '2.0' 36 | - python: '3.11' 37 | django: '2.1' 38 | - python: '3.11' 39 | django: '2.2' 40 | - python: '3.11' 41 | django: '3.0' 42 | - python: '3.11' 43 | django: '3.1' 44 | - python: '3.11' 45 | django: '3.2' 46 | - python: '3.11' 47 | django: '4.0' 48 | - python: '3.12' 49 | django: '2.2' 50 | - python: '3.12' 51 | django: '3.0' 52 | - python: '3.12' 53 | django: '3.1' 54 | - python: '3.12' 55 | django: '3.2' 56 | - python: '3.12' 57 | django: '4.0' 58 | - python: '3.12' 59 | django: '4.1' 60 | - python: '3.13' 61 | django: '2.2' 62 | - python: '3.13' 63 | django: '3.0' 64 | - python: '3.13' 65 | django: '3.1' 66 | - python: '3.13' 67 | django: '3.2' 68 | - python: '3.13' 69 | django: '4.0' 70 | - python: '3.13' 71 | django: '4.1' 72 | - python: '3.13' 73 | django: '4.2' 74 | - python: '3.13' 75 | django: '5.0' 76 | 77 | services: 78 | # as far as I can see, no way to make this conditional on the matrix database 79 | postgres: 80 | image: postgres:14 81 | env: 82 | POSTGRES_USER: postgres 83 | POSTGRES_PASSWORD: postgres 84 | POSTGRES_DB: dirtyfields_test 85 | ports: 86 | - 5432:5432 87 | 88 | steps: 89 | - name: Checkout repository 90 | uses: actions/checkout@v4 91 | 92 | - name: Set up Python 93 | uses: actions/setup-python@v5 94 | with: 95 | python-version: ${{ matrix.python }} 96 | 97 | - name: Install test requirements 98 | run: | 99 | python -m pip install --upgrade pip wheel 100 | pip install django~=${{ matrix.django }}.0 coverage[toml]~=7.0 -r tests-requirements.txt 101 | python --version 102 | pip list 103 | 104 | - name: Run unit tests 105 | run: coverage run -m pytest -v 106 | env: 107 | TEST_DATABASE: ${{ matrix.database }} 108 | # Run tests on original source code, rather than installing the package into the python environment. 109 | # This ensures coverage report has files listed with paths relative to the repository root. 110 | PYTHONPATH: './src' 111 | 112 | - name: Report coverage to console 113 | run: coverage report 114 | 115 | - name: Create XML coverage report for coveralls.io 116 | if: ${{ matrix.django == '4.2' && matrix.python == '3.10' && matrix.database == 'postgres' }} 117 | run: coverage xml 118 | 119 | - name: Upload coverage report to coveralls.io 120 | if: ${{ matrix.django == '4.2' && matrix.python == '3.10' && matrix.database == 'postgres' }} 121 | uses: coverallsapp/github-action@v2 122 | with: 123 | file: coverage.xml 124 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | ve 3 | .venv 4 | *.egg-info 5 | *.db 6 | docs/_build/* 7 | docs/_template/* 8 | docs/_static/* 9 | docs/.venv-docs/* 10 | .tox 11 | .cache 12 | .coverage 13 | coverage.xml 14 | htmlcov 15 | .idea 16 | dist/* 17 | build/* 18 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build all formats (pdf, epub, etc) 9 | formats: all 10 | 11 | # Set the version of Python and other tools you might need 12 | build: 13 | os: ubuntu-24.04 14 | tools: 15 | python: "3.12" 16 | 17 | # specify Sphinx version in requirements.txt 18 | python: 19 | install: 20 | - requirements: docs/requirements.txt 21 | 22 | # Build documentation in the docs/ directory with Sphinx 23 | sphinx: 24 | configuration: docs/conf.py 25 | -------------------------------------------------------------------------------- /ChangeLog.rst: -------------------------------------------------------------------------------- 1 | ChangeLog 2 | ========= 3 | 4 | .. _unreleased: 5 | 6 | unreleased 7 | ---------- 8 | 9 | *New:* 10 | - Confirm support for Django 5.2 11 | 12 | 1.9.7 (2025-03-22) 13 | ------------------ 14 | 15 | *Bugfix* 16 | - Fix :code:`Model.refresh_from_db()` so calling it with 3 positional args works. 17 | 18 | 19 | .. _v1.9.6: 20 | 21 | 1.9.6 (2025-02-07) 22 | ------------------ 23 | 24 | *Bugfix* 25 | - Allow passing :code:`from_queryset` argument to :code:`Model.refresh_from_db()` in Django 5.1+ 26 | 27 | 28 | .. _v1.9.5: 29 | 30 | 1.9.5 (2024-11-09) 31 | ------------------ 32 | 33 | *Bugfix* 34 | - Fixed error in PyPI publish process 35 | 36 | 37 | .. _v1.9.4: 38 | 39 | 1.9.4 (2024-11-09) 40 | ------------------ 41 | 42 | *New:* 43 | - Confirm support for Python 3.13 44 | - Confirm support for Django 5.1 45 | - Drop support for Python 3.8 46 | 47 | 48 | .. _v1.9.3: 49 | 50 | 1.9.3 (2024-05-24) 51 | ------------------ 52 | 53 | *New:* 54 | - Confirm support for Python 3.12 55 | - Confirm support for Django 5.0 56 | - Drop support for Python 3.7 57 | - Drop support for Django 2.0 58 | - Drop support for Django 2.1 59 | 60 | 61 | .. _v1.9.2: 62 | 63 | 1.9.2 (2023-04-12) 64 | ------------------ 65 | 66 | *New:* 67 | - Confirm support for Django 4.2 68 | 69 | 70 | .. _v1.9.1: 71 | 72 | 1.9.1 (2023-01-14) 73 | ------------------ 74 | 75 | *Bugfix:* 76 | - Fixed a :code:`KeyError` that would occur when updating a field two times in a row when 77 | the field value is set to an :code:`F` object and the field is specified in the 78 | :code:`update_fields` argument to :code:`save()`. (#209) 79 | 80 | 81 | .. _v1.9.0: 82 | 83 | 1.9.0 (2022-11-07) 84 | ------------------ 85 | 86 | *New:* 87 | - Confirm support for Python 3.11 88 | - Confirm support for Django 4.1 89 | - Drop support for Django 1.11 90 | 91 | *Changed:* 92 | - The method :code:`get_dirty_fields()` now returns only the file name for FileFields. 93 | This is to improve performance, since the entire :code:`FieldFile` object will no longer 94 | be copied when Model instances are initialized and saved. (#203) 95 | 96 | *Bugfix:* 97 | - The method :code:`save_dirty_fields()` can now be called on Model instances that have not been 98 | saved to the Database yet. In this case all fields will be considered dirty, and all will be 99 | saved to the Database. Previously doing this would result in an Exception being raised. (#200) 100 | 101 | 102 | .. _v1.8.2: 103 | 104 | 1.8.2 (2022-07-16) 105 | ------------------ 106 | 107 | *Documentation:* 108 | - General improvements to content and generation of Documentation (#197). 109 | 110 | 111 | .. _v1.8.1: 112 | 113 | 1.8.1 (2022-03-07) 114 | ------------------ 115 | 116 | *Documentation:* 117 | - Document limitations when using dirtyfields and database transactions (#148). 118 | - Document how to use a Proxy Model to avoid performance impact (#132). 119 | 120 | 121 | .. _v1.8.0: 122 | 123 | 1.8.0 (2022-01-22) 124 | ------------------ 125 | 126 | *New:* 127 | - Confirm support of Python 3.10 128 | - Confirm support of Django 4.0 129 | - Drop support for Python 3.6 130 | 131 | *Tests* 132 | - Run CI tests on Github Actions since travis-ci.org has been shutdown. 133 | 134 | 135 | .. _v1.7.0: 136 | 137 | 1.7.0 (2021-05-02) 138 | ------------------ 139 | 140 | *New:* 141 | - Provide programmatically accessible package version number. Use :code:`dirtyfields.__version__` for a string, 142 | :code:`dirtyfields.VERSION` for a tuple. 143 | - Build and publish a wheel to PyPI. 144 | 145 | *Changed:* 146 | - Only look at concrete fields when determining dirty fields. 147 | - Migrate package metadata from setup.py to setup.cfg and specify the PEP-517 build-backend to use with the project. 148 | 149 | *Bugfix:* 150 | - Fixed a :code:`KeyError` that happened when saving a Model with :code:`update_fields` specified after updating a 151 | field value with an :code:`F` object (#118). 152 | 153 | .. _v1.6.0: 154 | 155 | 1.6.0 (2021-04-07) 156 | ------------------ 157 | 158 | *New:* 159 | - Confirm support of Django 3.2 160 | 161 | *Changed:* 162 | - Remove pytz as a dependency. 163 | 164 | .. _v1.5.0: 165 | 166 | 1.5.0 (2021-01-15) 167 | ------------------ 168 | 169 | *New:* 170 | - Drop support of Python 2.7 171 | - Drop support of Python 3.5 172 | - Confirm support of Python 3.8 173 | - Confirm support of Python 3.9 174 | - Confirm support of Django 3.0 175 | - Confirm support of Django 3.1 176 | 177 | .. _v1.4.1: 178 | 179 | 1.4.1 (2020-11-28) 180 | ------------------ 181 | 182 | *Bugfix:* 183 | - Fixes an issue when :code:`refresh_from_db` was called with the :code:`fields` argument, the dirty state for all 184 | fields would be reset, even though only the fields specified are reloaded from the database. Now only the reloaded 185 | fields will have their dirty state reset (#154). 186 | - Fixes an issue where accessing a deferred field would reset the dirty state for all fields (#154). 187 | 188 | .. _v1.4: 189 | 190 | 1.4 (2020-04-11) 191 | ---------------- 192 | 193 | *New:* 194 | - Drop support of Python 3.4 195 | - Drop support of Django 1.8 196 | - Drop support of Django 1.9 197 | - Drop support of Django 1.10 198 | - Confirm support of Python 3.7 199 | - Confirm support of Django 2.0 200 | - Confirm support of Django 2.1 201 | - Confirm support of Django 2.2 202 | 203 | *Bugfix:* 204 | - Fixes tests for Django 2.0 205 | - :code:`refresh_from_db` is now properly resetting dirty fields. 206 | - Adds :code:`normalise_function` to provide control on how dirty values are stored 207 | 208 | .. _v1.3.1: 209 | 210 | 1.3.1 (2018-02-28) 211 | ------------------ 212 | 213 | *New:* 214 | 215 | - Updates python classifier in setup file (#116). Thanks amureki. 216 | - Adds PEP8 validation in travisCI run (#123). Thanks hsmett. 217 | 218 | *Bugfix:* 219 | 220 | - Avoids :code:`get_deferred_fields` to be called too many times on :code:`_as_dict` (#115). Thanks benjaminrigaud. 221 | - Respects :code:`FIELDS_TO_CHECK` in `reset_state` (#114). Thanks bparker98. 222 | 223 | .. _v1.3: 224 | 225 | 1.3 (2017-08-23) 226 | ---------------- 227 | 228 | *New:* 229 | 230 | - Drop support for unsupported Django versions: 1.4, 1.5, 1.6 and 1.7 series. 231 | - Fixes issue with verbose mode when the object has not been yet saved in the database (MR #99). Thanks vapkarian. 232 | - Add test coverage for Django 1.11. 233 | - A new attribute :code:`FIELDS_TO_CHECK` has been added to :code:`DirtyFieldsMixin` to specify a limited set of fields to check. 234 | 235 | *Bugfix:* 236 | 237 | - Correctly handle :code:`ForeignKey.db_column` :code:`{}_id` in :code:`update_fields`. Thanks Hugo Smett. 238 | - Fixes #111: Eliminate a memory leak. 239 | - Handle deferred fields in :code:`update_fields` 240 | 241 | 242 | .. _v1.2.1: 243 | 244 | 1.2.1 (2016-11-16) 245 | ------------------ 246 | 247 | *New:* 248 | 249 | - :code:`django-dirtyfields` is now tested with PostgreSQL, especially with specific fields 250 | 251 | *Bugfix:* 252 | 253 | - Fixes #80: Use of :code:`Field.rel` raises warnings from Django 1.9+ 254 | - Fixes #84: Use :code:`only()` in conjunction with 2 foreign keys triggers a recursion error 255 | - Fixes #77: Shallow copy does not work with Django 1.9's JSONField 256 | - Fixes #88: :code:`get_dirty_fields` on a newly-created model does not work if pk is specified 257 | - Fixes #90: Unmark dirty fields only listed in :code:`update_fields` 258 | 259 | 260 | .. _v1.2: 261 | 262 | 1.2 (2016-08-11) 263 | ---------------- 264 | 265 | *New:* 266 | 267 | - :code:`django-dirtyfields` is now compatible with Django 1.10 series (deferred field handling has been updated). 268 | 269 | 270 | .. _v1.1: 271 | 272 | 1.1 (2016-08-04) 273 | ---------------- 274 | 275 | *New:* 276 | 277 | - A new attribute :code:`ENABLE_M2M_CHECK` has been added to :code:`DirtyFieldsMixin` to enable/disable m2m check 278 | functionality. This parameter is set to :code:`False` by default. 279 | IMPORTANT: backward incompatibility with v1.0.x series. If you were using :code:`check_m2m` parameter to 280 | check m2m relations, you should now add :code:`ENABLE_M2M_CHECK = True` to these models inheriting from 281 | :code:`DirtyFieldsMixin`. Check the documentation to see more details/examples. 282 | 283 | 284 | .. _v1.0.1: 285 | 286 | 1.0.1 (2016-07-25) 287 | ------------------ 288 | 289 | *Bugfix:* 290 | 291 | - Fixing a bug preventing :code:`django-dirtyfields` to work properly on models with custom primary keys. 292 | 293 | 294 | .. _v1.0: 295 | 296 | 1.0 (2016-06-26) 297 | ---------------- 298 | 299 | After several years of existence, django-dirty-fields is mature enough to switch to 1.X version. 300 | There is a backward-incompatibility on this version. Please read careful below. 301 | 302 | *New:* 303 | 304 | - IMPORTANT: :code:`get_dirty_fields` is now more consistent for models not yet saved in the database. 305 | :code:`get_dirty_fields` is, in that situation, always returning ALL fields, where it was before returning 306 | various results depending on how you initialised your model. 307 | It may affect you specially if you are using :code:`get_dirty_fields` in a :code:`pre_save` receiver. 308 | See more details at https://github.com/romgar/django-dirtyfields/issues/65. 309 | - Adding compatibility for old _meta API, deprecated in Django `1.10` version and now replaced by an official API. 310 | - General test cleaning. 311 | 312 | 313 | .. _v0.9: 314 | 315 | 0.9 (2016-06-18) 316 | ---------------- 317 | 318 | *New:* 319 | 320 | - Adding Many-to-Many fields comparison method :code:`check_m2m` in :code:`DirtyFieldsMixin`. 321 | - Adding :code:`verbose` parameter in :code:`get_dirty_fields` method to get old AND new field values. 322 | 323 | 324 | .. _v0.8.2: 325 | 326 | 0.8.2 (2016-03-19) 327 | ------------------ 328 | 329 | *New:* 330 | 331 | - Adding field comparison method :code:`compare_function` in :code:`DirtyFieldsMixin`. 332 | - Also adding a specific comparison function :code:`timezone_support_compare` to handle different Datetime situations. 333 | 334 | 335 | .. _v0.8.1: 336 | 337 | 0.8.1 (2015-12-08) 338 | ------------------ 339 | 340 | *Bugfix:* 341 | 342 | - Not comparing fields that are deferred (:code:`only` method on :code:`QuerySet`). 343 | - Being more tolerant when comparing values that can be on another type than expected. 344 | 345 | 346 | 347 | .. _v0.8: 348 | 349 | 0.8 (2015-10-30) 350 | ---------------- 351 | 352 | *New:* 353 | 354 | - Adding :code:`save_dirty_fields` method to save only dirty fields in the database. 355 | 356 | 357 | .. _v0.7: 358 | 359 | 0.7 (2015-06-18) 360 | ---------------- 361 | 362 | *New:* 363 | 364 | - Using :code:`copy` to properly track dirty fields on complex fields. 365 | - Using :code:`py.test` for tests launching. 366 | 367 | 368 | .. _v0.6.1: 369 | 370 | 0.6.1 (2015-06-14) 371 | ------------------ 372 | 373 | *Bugfix:* 374 | 375 | - Preventing django db expressions to be evaluated when testing dirty fields (#39). 376 | 377 | 378 | .. _v0.6: 379 | 380 | 0.6 (2015-06-11) 381 | ---------------- 382 | 383 | *New:* 384 | 385 | - Using :code:`to_python` to avoid false positives when dealing with model fields that internally convert values (#4) 386 | 387 | *Bugfix:* 388 | 389 | - Using :code:`attname` instead of :code:`name` on fields to avoid massive useless queries on ForeignKey fields (#34). For this kind of field, :code:`get_dirty_fields()` is now returning instance id, instead of instance itself. 390 | 391 | 392 | .. _v0.5: 393 | 394 | 0.5 (2015-05-06) 395 | ---------------- 396 | 397 | *New:* 398 | 399 | - Adding code compatibility for python3, 400 | - Launching travis-ci tests on python3, 401 | - Using :code:`tox` to launch tests on Django 1.5, 1.6, 1.7 and 1.8 versions, 402 | - Updating :code:`runtests.py` test script to run properly on every Django version. 403 | 404 | *Bugfix:* 405 | 406 | - Catching :code:`Error` when trying to get foreign key object if not existing (#32). 407 | 408 | 409 | .. _v0.4.1: 410 | 411 | 0.4.1 (2015-04-08) 412 | ------------------ 413 | 414 | *Bugfix:* 415 | 416 | - Removing :code:`model_to_form` to avoid bug when using models that have :code:`editable=False` fields. 417 | 418 | 419 | .. _v0.4: 420 | 421 | 0.4 (2015-03-31) 422 | ---------------- 423 | 424 | *New:* 425 | 426 | - Adding :code:`check_relationship` parameter on :code:`is_dirty` and :code:`get_dirty_field` methods to also check foreign key values. 427 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Praekelt Foundation and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, 7 | this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the Praekelt Foundation nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include ChangeLog.rst 2 | graft tests 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Django Dirty Fields 3 | =================== 4 | 5 | .. image:: https://badges.gitter.im/Join%20Chat.svg 6 | :alt: Join the chat at https://gitter.im/romgar/django-dirtyfields 7 | :target: https://gitter.im/romgar/django-dirtyfields?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 8 | .. image:: https://img.shields.io/pypi/v/django-dirtyfields.svg 9 | :alt: Published PyPI version 10 | :target: https://pypi.org/project/django-dirtyfields/ 11 | .. image:: https://github.com/romgar/django-dirtyfields/actions/workflows/tests.yml/badge.svg 12 | :alt: Github Actions Test status 13 | :target: https://github.com/romgar/django-dirtyfields/actions/workflows/tests.yml 14 | .. image:: https://coveralls.io/repos/github/romgar/django-dirtyfields/badge.svg?branch=develop 15 | :alt: Coveralls code coverage status 16 | :target: https://coveralls.io/github/romgar/django-dirtyfields?branch=develop 17 | .. image:: https://readthedocs.org/projects/django-dirtyfields/badge/?version=latest 18 | :alt: Read the Docs documentation status 19 | :target: https://django-dirtyfields.readthedocs.io/en/latest/ 20 | 21 | Tracking dirty fields on a Django model instance. 22 | Dirty means that field in-memory and database values are different. 23 | 24 | This package is compatible and tested with the following Python & Django versions: 25 | 26 | 27 | +------------------------+-----------------------------------+ 28 | | Django | Python | 29 | +========================+===================================+ 30 | | 2.2, 3.0, 3.1 | 3.9 | 31 | +------------------------+-----------------------------------+ 32 | | 3.2, 4.0 | 3.9, 3.10 | 33 | +------------------------+-----------------------------------+ 34 | | 4.1 | 3.9, 3.10, 3.11 | 35 | +------------------------+-----------------------------------+ 36 | | 4.2 | 3.9, 3.10, 3.11, 3.12 | 37 | +------------------------+-----------------------------------+ 38 | | 5.0 | 3.10, 3.11, 3.12 | 39 | +------------------------+-----------------------------------+ 40 | | 5.1 | 3.10, 3.11, 3.12, 3.13 | 41 | +------------------------+-----------------------------------+ 42 | | 5.2 | 3.10, 3.11, 3.12, 3.13 | 43 | +------------------------+-----------------------------------+ 44 | 45 | 46 | 47 | Install 48 | ======= 49 | 50 | .. code-block:: bash 51 | 52 | $ pip install django-dirtyfields 53 | 54 | 55 | Usage 56 | ===== 57 | 58 | To use ``django-dirtyfields``, you need to: 59 | 60 | - Inherit from ``DirtyFieldsMixin`` in the Django model you want to track. 61 | 62 | .. code-block:: python 63 | 64 | from django.db import models 65 | from dirtyfields import DirtyFieldsMixin 66 | 67 | class ExampleModel(DirtyFieldsMixin, models.Model): 68 | """A simple example model to test dirty fields mixin with""" 69 | boolean = models.BooleanField(default=True) 70 | characters = models.CharField(blank=True, max_length=80) 71 | 72 | - Use one of these 2 functions on a model instance to know if this instance is dirty, and get the dirty fields: 73 | 74 | * ``is_dirty()`` 75 | * ``get_dirty_fields()`` 76 | 77 | 78 | Example 79 | ------- 80 | 81 | .. code-block:: python 82 | 83 | >>> model = ExampleModel.objects.create(boolean=True,characters="first value") 84 | >>> model.is_dirty() 85 | False 86 | >>> model.get_dirty_fields() 87 | {} 88 | 89 | >>> model.boolean = False 90 | >>> model.characters = "second value" 91 | 92 | >>> model.is_dirty() 93 | True 94 | >>> model.get_dirty_fields() 95 | {'boolean': True, "characters": "first_value"} 96 | 97 | 98 | Consult the `full documentation `_ for more information. 99 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you have found a vulnerability in the django-dirtyfields library, please open a Github issue. 6 | 7 | So we can address it as soon as possible and others can be aware of the vulnerability. 8 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://www.sphinx-doc.org) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-dirtyfields.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-dirtyfields.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-dirtyfields" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-dirtyfields" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/_ext/djangodocs.py: -------------------------------------------------------------------------------- 1 | 2 | def setup(app): 3 | """ 4 | Mandatory to cross ref any non-default sphinx behaviours defined in Django 5 | Thanks https://reinout.vanrees.org/weblog/2012/12/01/django-intersphinx.html 6 | We can then use :django:settings:`ROOT_URLCONF` for example 7 | (We then avoid ERROR: Unknown interpreted text role "django:settings") 8 | """ 9 | app.add_crossref_type( 10 | directivename="setting", 11 | rolename="setting", 12 | indextemplate="pair: %s; setting", 13 | ) 14 | app.add_crossref_type( 15 | directivename="templatetag", 16 | rolename="ttag", 17 | indextemplate="pair: %s; template tag" 18 | ) 19 | app.add_crossref_type( 20 | directivename="templatefilter", 21 | rolename="tfilter", 22 | indextemplate="pair: %s; template filter" 23 | ) 24 | app.add_crossref_type( 25 | directivename="fieldlookup", 26 | rolename="lookup", 27 | indextemplate="pair: %s; field lookup type", 28 | ) 29 | -------------------------------------------------------------------------------- /docs/_templates/dirtyfields-links.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced Usage 2 | ============== 3 | 4 | 5 | Verbose mode 6 | ------------ 7 | By default, when you use ``get_dirty_fields()`` function, if there are dirty fields, only the saved value is returned. 8 | You can use the ``verbose`` option to return the saved and current in-memory value: 9 | 10 | .. code-block:: pycon 11 | 12 | >>> model = ExampleModel.objects.create(characters="first value") 13 | >>> model.characters = "second value" 14 | >>> model.get_dirty_fields() 15 | {'characters': 'first_value'} 16 | >>> model.get_dirty_fields(verbose=True) 17 | {'characters': {'saved': 'first value', 'current': 'second value'}} 18 | 19 | 20 | Checking foreign key fields. 21 | ---------------------------- 22 | By default, dirty functions are not checking foreign keys. If you want to also take these relationships into account, 23 | use ``check_relationship`` parameter: 24 | 25 | .. code-block:: python 26 | 27 | class ForeignKeyModel(DirtyFieldsMixin, models.Model): 28 | fkey = models.ForeignKey(AnotherModel, on_delete=models.CASCADE) 29 | 30 | .. code-block:: pycon 31 | 32 | >>> model = ForeignKeyModel.objects.create(fkey=obj1) 33 | >>> model.is_dirty() 34 | False 35 | >>> model.fkey = obj2 36 | >>> model.is_dirty() 37 | False 38 | >>> model.is_dirty(check_relationship=True) 39 | True 40 | >>> model.get_dirty_fields() 41 | {} 42 | >>> model.get_dirty_fields(check_relationship=True) 43 | {'fkey': 1} 44 | 45 | 46 | Saving dirty fields. 47 | -------------------- 48 | If you want to only save dirty fields from an instance in the database (only these fields will be involved in SQL query), 49 | you can use ``save_dirty_fields()`` method. If the model instance has not been persisted yet, it will be saved in full. 50 | 51 | Warning: This calls the ``save()`` method internally so will trigger the same signals as normally calling the ``save()`` method. 52 | 53 | .. code-block:: pycon 54 | 55 | >>> model.is_dirty() 56 | True 57 | >>> model.save_dirty_fields() 58 | >>> model.is_dirty() 59 | False 60 | 61 | 62 | Performance Impact 63 | ------------------ 64 | 65 | Using ``DirtyFieldsMixin`` in your Model will have a (normally small) performance impact even when you don't call 66 | any of ``DirtyFieldsMixin``'s methods. This is because ``DirtyFieldsMixin`` needs to capture the state of the Model 67 | when it is initialized and when it is saved, so that ``DirtyFieldsMixin`` can later determine if the fields are dirty. 68 | 69 | Using a Proxy Model to reduce Performance Impact 70 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 71 | 72 | If you only use ``DirtyFieldsMixin``'s methods in some places of you project but not all, you can eliminate the 73 | performance impact in the places you don't use them by inheriting from ``DirtyFieldsMixin`` in a `Proxy Model`_. 74 | 75 | .. _Proxy Model: https://docs.djangoproject.com/en/dev/topics/db/models/#proxy-models 76 | 77 | For example define your Model without ``DirtyFieldsMixin``: 78 | 79 | .. code-block:: python 80 | 81 | class FooModel(models.Model): 82 | ... 83 | 84 | Use this Model class when you don't need to track dirty fields. It is a regular Model so there will be no performance 85 | impact, but ``is_dirty()`` and ``get_dirty_fields()`` can't be used. 86 | 87 | Then define a Proxy Model for that Model which includes ``DirtyFieldsMixin``: 88 | 89 | .. code-block:: python 90 | 91 | class FooModelWithDirtyFields(DirtyFieldsMixin, FooModel): 92 | class Meta: 93 | proxy = True 94 | 95 | Use this Model class when you do want dirty fields to be tracked. There will be a performance impact but 96 | ``is_dirty()`` and ``get_dirty_fields()`` can be used. 97 | 98 | 99 | Database Transactions Limitations 100 | --------------------------------- 101 | There is currently a limitation when using dirtyfields and database transactions. 102 | If your code saves Model instances inside a ``transaction.atomic()`` block, and the transaction is rolled back, 103 | then the Model instance's ``is_dirty()`` method will return ``False`` when it should return ``True``. 104 | The ``get_dirty_fields()`` method will also return the wrong thing in the same way. 105 | 106 | This is because after the ``save()`` method is called, the instance's dirty state is reset because it thinks it has 107 | successfully saved to the database. Then when the transaction rolls back, the database is reset back to the original value. 108 | At this point this Model instance thinks it is not dirty when it actually is. 109 | Here is a code example to illustrate the problem: 110 | 111 | .. code-block:: python 112 | 113 | # first create a model 114 | model = ExampleModel.objects.create(characters="first") 115 | # then make an edit in-memory, model becomes dirty 116 | model.characters = "second" 117 | assert model.is_dirty() 118 | # then attempt to save the model in a transaction 119 | try: 120 | with transaction.atomic(): 121 | model.save() 122 | # no longer dirty because save() has been called, 123 | # BUT we are still in a transaction ... 124 | assert not model.is_dirty() 125 | # force a transaction rollback 126 | raise DatabaseError("pretend something went wrong") 127 | except DatabaseError: 128 | pass 129 | 130 | # Here is the problem: 131 | # value in DB is still "first" but model does not think its dirty, 132 | # because in-memory value is still "second" 133 | assert model.characters == "second" 134 | assert not model.is_dirty() 135 | 136 | 137 | This simplest workaround to this issue is to call ``model.refresh_from_db()`` if the transaction is rolled back. 138 | Or you can manually restore the fields that were edited in-memory. 139 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-dirtyfields documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Nov 18 20:17:31 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import ast 16 | import os 17 | import sys 18 | from datetime import date 19 | from os import path 20 | 21 | _docs_directory = path.dirname(__file__) 22 | _root_directory = path.dirname(_docs_directory) 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | 28 | # sys.path.insert(0, path.join(_root_directory, "src")) 29 | 30 | 31 | # -- Helper functions ----------------------------------------------------- 32 | 33 | def get_dirtyfields_version(): 34 | """ 35 | This helper gets the __version__ string from __init__.py by parsing the AST. 36 | 37 | This avoids importing the module which requires django to be installed 38 | """ 39 | with open(path.join(_root_directory, "src", "dirtyfields", "__init__.py"), "r") as f: 40 | source = f.read() 41 | root_node = ast.parse(source, "__init__.py") 42 | for node in root_node.body: 43 | if isinstance(node, ast.Assign) and node.targets[0].id == "__version__": 44 | return ast.literal_eval(node.value) 45 | raise RuntimeError("__version__ not found in __init__.py") 46 | 47 | 48 | # -- General configuration ------------------------------------------------ 49 | 50 | # If your documentation needs a minimal Sphinx version, state it here. 51 | # needs_sphinx = '1.0' 52 | 53 | # Add any Sphinx extension module names here, as strings. They can be 54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 55 | # ones. 56 | 57 | sys.path.append(path.join(_docs_directory, "_ext")) 58 | 59 | extensions = [ 60 | 'djangodocs', 61 | 'sphinx.ext.intersphinx' 62 | ] 63 | 64 | # Added manually to reference other sphinx documentations 65 | intersphinx_mapping = { 66 | 'python': ('https://docs.python.org/3', None), 67 | 'django': ('https://docs.djangoproject.com/en/dev/', 68 | 'https://docs.djangoproject.com/en/dev/_objects/'), 69 | } 70 | 71 | # Add any paths that contain templates here, relative to this directory. 72 | templates_path = ['_templates'] 73 | 74 | # The suffix(es) of source filenames. 75 | # You can specify multiple suffix as a list of string: 76 | # source_suffix = ['.rst', '.md'] 77 | source_suffix = '.rst' 78 | 79 | # The encoding of source files. 80 | # source_encoding = 'utf-8-sig' 81 | 82 | # The root toctree document. 83 | root_doc = 'index' 84 | 85 | # General information about the project. 86 | project = u'django-dirtyfields' 87 | copyright = f'{date.today().year}, Romain Garrigues' 88 | author = u'Romain Garrigues' 89 | 90 | # The version info for the project you're documenting, acts as replacement for 91 | # |version| and |release|, also used in various other places throughout the 92 | # built documents. 93 | 94 | # The full version, including alpha/beta/rc tags. 95 | release = get_dirtyfields_version() 96 | # The short X.Y version. 97 | version = ".".join(release.split(".")[0:2]) 98 | 99 | # The language for content autogenerated by Sphinx. Refer to documentation 100 | # for a list of supported languages. 101 | # 102 | # This is also used if you do content translation via gettext catalogs. 103 | # Usually you set "language" from the command line for these cases. 104 | language = "en" 105 | 106 | # There are two options for replacing |today|: either, you set today to some 107 | # non-false value, then it is used: 108 | # today = '' 109 | # Else, today_fmt is used as the format for a strftime call. 110 | # today_fmt = '%B %d, %Y' 111 | 112 | # List of patterns, relative to source directory, that match files and 113 | # directories to ignore when looking for source files. 114 | exclude_patterns = ["_build", ".venv-docs"] 115 | 116 | # The reST default role (used for this markup: `text`) to use for all 117 | # documents. 118 | # default_role = None 119 | 120 | # If true, '()' will be appended to :func: etc. cross-reference text. 121 | # add_function_parentheses = True 122 | 123 | # If true, the current module name will be prepended to all description 124 | # unit titles (such as .. function::). 125 | # add_module_names = True 126 | 127 | # If true, sectionauthor and moduleauthor directives will be shown in the 128 | # output. They are ignored by default. 129 | # show_authors = False 130 | 131 | # The name of the Pygments (syntax highlighting) style to use. 132 | pygments_style = 'sphinx' 133 | 134 | # A list of ignored prefixes for module index sorting. 135 | # modindex_common_prefix = [] 136 | 137 | # If true, keep warnings as "system message" paragraphs in the built documents. 138 | # keep_warnings = False 139 | 140 | # If true, `todo` and `todoList` produce output, else they produce nothing. 141 | todo_include_todos = False 142 | 143 | 144 | # -- Options for HTML output ---------------------------------------------- 145 | 146 | # The theme to use for HTML and HTML Help pages. See the documentation for 147 | # a list of builtin themes. 148 | html_theme = 'alabaster' 149 | 150 | # Theme options are theme-specific and customize the look and feel of a theme 151 | # further. For a list of options available for each theme, see the 152 | # documentation. 153 | # html_theme_options = {} 154 | 155 | # Add any paths that contain custom themes here, relative to this directory. 156 | # html_theme_path = [] 157 | 158 | # The name for this set of Sphinx documents. If None, it defaults to 159 | # " v documentation". 160 | # html_title = None 161 | 162 | # A shorter title for the navigation bar. Default is the same as html_title. 163 | # html_short_title = None 164 | 165 | # The name of an image file (relative to this directory) to place at the top 166 | # of the sidebar. 167 | # html_logo = None 168 | 169 | # The name of an image file (within the static path) to use as favicon of the 170 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 171 | # pixels large. 172 | # html_favicon = None 173 | 174 | # Add any paths that contain custom static files (such as style sheets) here, 175 | # relative to this directory. They are copied after the builtin static files, 176 | # so a file named "default.css" will overwrite the builtin "default.css". 177 | # html_static_path = ['_static'] 178 | 179 | # Add any extra paths that contain custom files (such as robots.txt or 180 | # .htaccess) here, relative to this directory. These files are copied 181 | # directly to the root of the documentation. 182 | # html_extra_path = [] 183 | 184 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 185 | # using the given strftime format. 186 | # html_last_updated_fmt = '%b %d, %Y' 187 | 188 | # If true, SmartyPants will be used to convert quotes and dashes to 189 | # typographically correct entities. 190 | # html_use_smartypants = True 191 | 192 | # Custom sidebar templates, maps document names to template names. 193 | html_sidebars = { 194 | '**': [ 195 | 'about.html', 196 | 'dirtyfields-links.html', 197 | 'navigation.html', 198 | 'relations.html', 199 | 'searchbox.html', 200 | ] 201 | } 202 | 203 | # Additional templates that should be rendered to pages, maps page names to 204 | # template names. 205 | # html_additional_pages = {} 206 | 207 | # If false, no module index is generated. 208 | # html_domain_indices = True 209 | 210 | # If false, no index is generated. 211 | # html_use_index = True 212 | 213 | # If true, the index is split into individual pages for each letter. 214 | # html_split_index = False 215 | 216 | # If true, links to the reST sources are added to the pages. 217 | # html_show_sourcelink = True 218 | 219 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 220 | # html_show_sphinx = True 221 | 222 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 223 | # html_show_copyright = True 224 | 225 | # If true, an OpenSearch description file will be output, and all pages will 226 | # contain a tag referring to it. The value of this option must be the 227 | # base URL from which the finished HTML is served. 228 | # html_use_opensearch = '' 229 | 230 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 231 | # html_file_suffix = None 232 | 233 | # Language to be used for generating the HTML full-text search index. 234 | # Sphinx supports the following languages: 235 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 236 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 237 | # html_search_language = 'en' 238 | 239 | # A dictionary with options for the search language support, empty by default. 240 | # Now only 'ja' uses this config value 241 | # html_search_options = {'type': 'default'} 242 | 243 | # The name of a javascript file (relative to the configuration directory) that 244 | # implements a search results scorer. If empty, the default will be used. 245 | # html_search_scorer = 'scorer.js' 246 | 247 | # Output file base name for HTML help builder. 248 | htmlhelp_basename = 'django-dirtyfieldsdoc' 249 | 250 | # Define the canonical URL if you are using a custom domain on Read the Docs 251 | html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") 252 | 253 | html_context = {} 254 | 255 | # Tell Jinja2 templates the build is running on Read the Docs 256 | if os.environ.get("READTHEDOCS", "") == "True": 257 | html_context["READTHEDOCS"] = True 258 | 259 | # -- Options for LaTeX output --------------------------------------------- 260 | 261 | latex_elements = { 262 | # The paper size ('letterpaper' or 'a4paper'). 263 | 'papersize': 'a4paper', 264 | 265 | # The font size ('10pt', '11pt' or '12pt'). 266 | 'pointsize': '12pt', 267 | 268 | # Additional stuff for the LaTeX preamble. 269 | # 'preamble': '', 270 | 271 | # Latex figure (float) alignment 272 | # 'figure_align': 'htbp', 273 | } 274 | 275 | # Grouping the document tree into LaTeX files. List of tuples 276 | # (source start file, target name, title, author, theme, toctree_only). 277 | latex_documents = [ 278 | (root_doc, 'django-dirtyfields.tex', 'django-dirtyfields Documentation', 279 | 'Romain Garrigues', 'howto', True), 280 | ] 281 | 282 | # The name of an image file (relative to this directory) to place at the top of 283 | # the title page. 284 | # latex_logo = None 285 | 286 | # This value determines the topmost sectioning unit. 287 | # It should be chosen from 'part', 'chapter' or 'section'. 288 | latex_toplevel_sectioning = "section" 289 | 290 | # If true, show page references after internal links. 291 | # latex_show_pagerefs = False 292 | 293 | # If true, show URL addresses after external links. 294 | # latex_show_urls = False 295 | 296 | # Documents to append as an appendix to all manuals. 297 | # latex_appendices = [] 298 | 299 | # If false, no module index is generated. 300 | # latex_domain_indices = True 301 | 302 | 303 | # -- Options for manual page output --------------------------------------- 304 | 305 | # One entry per manual page. List of tuples 306 | # (source start file, name, description, authors, manual section). 307 | man_pages = [ 308 | (root_doc, 'django-dirtyfields', u'django-dirtyfields Documentation', 309 | [author], 1) 310 | ] 311 | 312 | # If true, show URL addresses after external links. 313 | # man_show_urls = False 314 | 315 | 316 | # -- Options for Texinfo output ------------------------------------------- 317 | 318 | # Grouping the document tree into Texinfo files. List of tuples 319 | # (source start file, target name, title, author, 320 | # dir menu entry, description, category) 321 | texinfo_documents = [ 322 | (root_doc, 'django-dirtyfields', 'django-dirtyfields Documentation', 323 | author, 'django-dirtyfields', 'One line description of project.', 324 | 'Miscellaneous'), 325 | ] 326 | 327 | # Documents to append as an appendix to all manuals. 328 | # texinfo_appendices = [] 329 | 330 | # If false, no module index is generated. 331 | # texinfo_domain_indices = True 332 | 333 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 334 | # texinfo_show_urls = 'footnote' 335 | 336 | # If true, do not generate a @detailmenu in the "Top" node's menu. 337 | # texinfo_no_detailmenu = False 338 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | If you're interested in developing the project, the unit tests can be run locally using ``tox``: 4 | 5 | .. code-block:: bash 6 | 7 | $ pip install tox 8 | $ tox -e py310-django32-sqlite 9 | 10 | If you want to run the tests against PostgreSQL you will need to set the POSTGRES_USER and POSTGRES_PASSWORD 11 | environment variables: 12 | 13 | .. code-block:: bash 14 | 15 | $ export POSTGRES_USER=user 16 | $ export POSTGRES_PASSWORD=password 17 | $ tox -e py310-django32-postgresql 18 | 19 | You can also run the entire test matrix (WARNING: this will run the test suite a large number of times): 20 | 21 | .. code-block:: bash 22 | 23 | $ tox -e ALL 24 | -------------------------------------------------------------------------------- /docs/credits.rst: -------------------------------------------------------------------------------- 1 | Credits 2 | ======= 3 | 4 | This code has largely be adapted from what was made available at `Stack Overflow`_. 5 | 6 | .. _Stack Overflow: https://stackoverflow.com/questions/110803/dirty-fields-in-django 7 | -------------------------------------------------------------------------------- /docs/customisation.rst: -------------------------------------------------------------------------------- 1 | Customisation 2 | ============= 3 | 4 | Ways to customise the behavior of django-dirtyfields 5 | 6 | 7 | Checking many-to-many fields. 8 | ----------------------------- 9 | **WARNING**: this m2m mode will generate extra queries to get m2m relation values each time you will save your objects. 10 | It can have serious performance issues depending on your project. 11 | 12 | By default, dirty functions are not checking many-to-many fields. 13 | They are also a bit special, as a call to ``.add()`` method is directly 14 | saving the related object to the database, thus the instance is never dirty. 15 | If you want to check these relations, you should set ``ENABLE_M2M_CHECK`` to ``True`` in your model inheriting from 16 | ``DirtyFieldsMixin``, use ``check_m2m`` parameter and provide the values you want to test against: 17 | 18 | .. code-block:: python 19 | 20 | class Many2ManyModel(DirtyFieldsMixin, models.Model): 21 | ENABLE_M2M_CHECK = True 22 | m2m_field = models.ManyToManyField(AnotherModel) 23 | 24 | .. code-block:: pycon 25 | 26 | >>> model = Many2ManyModel.objects.create() 27 | >>> related_model = AnotherModel.objects.create() 28 | >>> model.is_dirty() 29 | False 30 | >>> model.m2m_field.add(related_model) 31 | >>> model.is_dirty() 32 | False 33 | >>> model.get_dirty_fields(check_m2m={"m2m_field": {related_model.id}}) 34 | {} 35 | >>> model.get_dirty_fields(check_m2m={"m2m_field": set()}) 36 | {'m2m_field': set([related_model.id])} 37 | 38 | 39 | This can be useful when validating forms with m2m relations, where you receive some ids and want to know if your object 40 | in the database needs to be updated with these form values. 41 | 42 | 43 | Checking a limited set of model fields. 44 | --------------------------------------- 45 | If you want to check a limited set of model fields, you should set ``FIELDS_TO_CHECK`` in your model inheriting from ``DirtyFieldsMixin``: 46 | 47 | .. code-block:: python 48 | 49 | class ModelWithSpecifiedFields(DirtyFieldsMixin, models.Model): 50 | boolean1 = models.BooleanField(default=True) 51 | boolean2 = models.BooleanField(default=True) 52 | FIELDS_TO_CHECK = ["boolean1"] 53 | 54 | .. code-block:: pycon 55 | 56 | >>> model = ModelWithSpecifiedFields.objects.create() 57 | 58 | >>> model.boolean1 = False 59 | >>> model.boolean2 = False 60 | 61 | >>> model.get_dirty_fields() 62 | {'boolean1': True} 63 | 64 | 65 | This can be used in order to increase performance. 66 | 67 | 68 | Custom comparison function 69 | ---------------------------- 70 | By default, ``dirtyfields`` compare the value between the database and the memory on a naive way (``==``). 71 | After some issues (with timezones for example), a customisable comparison logic has been added. 72 | You can now define how you want to compare 2 values by passing a function on DirtyFieldsMixin: 73 | 74 | .. code-block:: python 75 | 76 | from django.db import models 77 | from dirtyfields import DirtyFieldsMixin 78 | 79 | def your_function((new_value, old_value, param1): 80 | # Your custom comparison code here 81 | return new_value == old_value 82 | 83 | class YourModel(DirtyFieldsMixin, models.Model): 84 | compare_function = (your_function, {"param1": 5}) 85 | 86 | 87 | Have a look at ``dirtyfields.compare`` module to get some examples. 88 | 89 | 90 | Custom value normalisation function 91 | ----------------------------------- 92 | By default, ``dirtyfields`` reports on the dirty fields as is. If a date field was changed 93 | the result of ``get_dirty_fields()`` will return the current and saved datetime object. 94 | In some cases it is useful to normalise those values, e.g., when wanting ot save the diff data as a json dataset in a database. 95 | The default behaviour of using values as is can be changed by providing a ``normalise_function`` 96 | in your model. That function can evaluate the value's type and rewrite it accordingly. 97 | 98 | This example shows the usage of the normalise function, with an extra paramter of a user's timezone 99 | being passed as well: 100 | 101 | .. code-block:: python 102 | 103 | import pytz 104 | import datetime 105 | from django.db import models 106 | from dirtyfields import DirtyFieldsMixin 107 | 108 | def your_normalise_function(value, timezone=None): 109 | if isinstance(value, (datetime.datetime, datetime.date)): 110 | if timezone: 111 | return pytz.timezone(timezone).localize(value).isoformat() 112 | else: 113 | return value.isoformat() 114 | else: 115 | return value 116 | 117 | def get_user_timezone(): 118 | return "Europe/London" 119 | 120 | class YourModel(DirtyFieldsMixin, models.Model): 121 | normalise_function = (your_normalise_function, 122 | {"timezone": get_user_timezone()}) 123 | -------------------------------------------------------------------------------- /docs/description.rst: -------------------------------------------------------------------------------- 1 | 2 | ``django-dirtyfields`` is a small library for tracking dirty fields on a Django model instance. 3 | Dirty means that a field's in-memory value is different to the saved value in the database. 4 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to django-dirtyfields's documentation! 3 | ============================================== 4 | 5 | .. include:: description.rst 6 | 7 | 8 | Table of Contents: 9 | ------------------ 10 | 11 | .. toctree:: 12 | :maxdepth: 3 13 | 14 | quickstart 15 | advanced 16 | customisation 17 | contributing 18 | credits 19 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | 5 | .. include:: description.rst 6 | 7 | 8 | Installation 9 | ------------ 10 | 11 | .. code-block:: bash 12 | 13 | $ pip install django-dirtyfields 14 | 15 | 16 | Usage 17 | ----- 18 | 19 | To use ``django-dirtyfields``, you need to: 20 | 21 | - Inherit from the ``DirtyFieldsMixin`` class in the Django model you want to track dirty fields. 22 | 23 | .. code-block:: python 24 | 25 | from django.db import models 26 | from dirtyfields import DirtyFieldsMixin 27 | 28 | class ExampleModel(DirtyFieldsMixin, models.Model): 29 | """A simple example model to test dirtyfields with""" 30 | characters = models.CharField(max_length=80) 31 | 32 | - Use one of these 2 functions on a model instance to know if this instance is dirty, and get the dirty fields: 33 | 34 | * ``is_dirty()`` 35 | * ``get_dirty_fields()`` 36 | 37 | 38 | Example 39 | ------- 40 | 41 | .. code-block:: pycon 42 | 43 | >>> model = ExampleModel.objects.create(characters="first value") 44 | >>> model.is_dirty() 45 | False 46 | >>> model.get_dirty_fields() 47 | {} 48 | >>> model.characters = "second value" 49 | >>> model.is_dirty() 50 | True 51 | >>> model.get_dirty_fields() 52 | {'characters': 'first_value'} 53 | 54 | 55 | Why would you want this? 56 | ------------------------ 57 | 58 | When using :mod:`django:django.db.models.signals` (:data:`django.db.models.signals.pre_save` especially), 59 | it is useful to be able to see what fields have changed or not. A signal could change its behaviour 60 | depending on whether a specific field has changed, whereas otherwise, you only could work on the event 61 | that the model's :meth:`~django.db.models.Model.save` method had been called. 62 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | sphinx 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements.in 6 | # 7 | alabaster==1.0.0 8 | # via sphinx 9 | babel==2.16.0 10 | # via sphinx 11 | certifi==2024.8.30 12 | # via requests 13 | charset-normalizer==3.3.2 14 | # via requests 15 | docutils==0.21.2 16 | # via sphinx 17 | idna==3.8 18 | # via requests 19 | imagesize==1.4.1 20 | # via sphinx 21 | jinja2==3.1.6 22 | # via sphinx 23 | markupsafe==2.1.5 24 | # via jinja2 25 | packaging==24.1 26 | # via sphinx 27 | pygments==2.18.0 28 | # via sphinx 29 | requests==2.32.3 30 | # via sphinx 31 | snowballstemmer==2.2.0 32 | # via sphinx 33 | sphinx==8.0.2 34 | # via -r requirements.in 35 | sphinxcontrib-applehelp==2.0.0 36 | # via sphinx 37 | sphinxcontrib-devhelp==2.0.0 38 | # via sphinx 39 | sphinxcontrib-htmlhelp==2.1.0 40 | # via sphinx 41 | sphinxcontrib-jsmath==1.0.1 42 | # via sphinx 43 | sphinxcontrib-qthelp==2.0.0 44 | # via sphinx 45 | sphinxcontrib-serializinghtml==2.0.0 46 | # via sphinx 47 | urllib3==2.2.2 48 | # via requests 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools ~= 75.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-dirtyfields" 7 | dynamic = ["version"] 8 | description = "Tracking dirty fields on a Django model instance." 9 | keywords = ["django", "dirtyfields", "track", "model", "changes"] 10 | readme = {file = "README.rst", content-type = "text/x-rst"} 11 | license = {file = "LICENSE"} 12 | authors = [{name = "Romain Garrigues"}] 13 | maintainers = [{name = "Lincoln Puzey"}] 14 | requires-python = ">=3.9" 15 | dependencies = [ 16 | "Django>=2.2", 17 | ] 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Intended Audience :: Developers", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "License :: OSI Approved :: BSD License", 23 | "Natural Language :: English", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3.13", 33 | "Framework :: Django", 34 | "Framework :: Django :: 2.2", 35 | "Framework :: Django :: 3.0", 36 | "Framework :: Django :: 3.1", 37 | "Framework :: Django :: 3.2", 38 | "Framework :: Django :: 4.0", 39 | "Framework :: Django :: 4.1", 40 | "Framework :: Django :: 4.2", 41 | "Framework :: Django :: 5.0", 42 | "Framework :: Django :: 5.1", 43 | "Framework :: Django :: 5.2", 44 | ] 45 | 46 | [project.urls] 47 | Homepage = "https://github.com/romgar/django-dirtyfields" 48 | Repository = "https://github.com/romgar/django-dirtyfields.git" 49 | Changelog = "https://github.com/romgar/django-dirtyfields/blob/develop/ChangeLog.rst" 50 | Documentation = "https://django-dirtyfields.readthedocs.io" 51 | 52 | [tool.setuptools.dynamic] 53 | version = {attr = "dirtyfields.__version__"} 54 | 55 | [tool.pytest.ini_options] 56 | django_find_project = false 57 | DJANGO_SETTINGS_MODULE = 'tests.django_settings' 58 | filterwarnings = [ 59 | 'error', 60 | ] 61 | 62 | [tool.coverage.run] 63 | branch = true 64 | source = ['dirtyfields'] 65 | 66 | [tool.coverage.report] 67 | show_missing = true 68 | precision = 2 69 | -------------------------------------------------------------------------------- /src/dirtyfields/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-dirtyfields library for tracking dirty fields on a Model instance. 3 | 4 | Adapted from https://stackoverflow.com/questions/110803/dirty-fields-in-django 5 | """ 6 | 7 | __all__ = ['DirtyFieldsMixin'] 8 | __version__ = "1.9.7" 9 | from dirtyfields.dirtyfields import DirtyFieldsMixin 10 | 11 | VERSION = tuple(map(int, __version__.split(".")[0:3])) 12 | -------------------------------------------------------------------------------- /src/dirtyfields/compare.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from datetime import datetime, timezone 3 | 4 | from django.utils import timezone as django_timezone 5 | 6 | 7 | def compare_states(new_state, original_state, compare_function, normalise_function): 8 | modified_field = {} 9 | 10 | for key, value in new_state.items(): 11 | try: 12 | original_value = original_state[key] 13 | except KeyError: 14 | # In some situation, like deferred fields, it can happen that we try to compare the current 15 | # state that has some fields not present in original state because of being initially deferred. 16 | # We should not include them in the comparison. 17 | continue 18 | 19 | is_identical = compare_function[0](value, original_value, **compare_function[1]) 20 | if is_identical: 21 | continue 22 | 23 | modified_field[key] = { 24 | 'saved': normalise_function[0](original_value, **normalise_function[1]), 25 | 'current': normalise_function[0](value, **normalise_function[1]) 26 | } 27 | 28 | return modified_field 29 | 30 | 31 | def raw_compare(new_value, old_value): 32 | return new_value == old_value 33 | 34 | 35 | def timezone_support_compare(new_value, old_value, timezone_to_set=timezone.utc): 36 | 37 | if not (isinstance(new_value, datetime) and isinstance(old_value, datetime)): 38 | return raw_compare(new_value, old_value) 39 | 40 | db_value_is_aware = django_timezone.is_aware(old_value) 41 | in_memory_value_is_aware = django_timezone.is_aware(new_value) 42 | 43 | if db_value_is_aware == in_memory_value_is_aware: 44 | return raw_compare(new_value, old_value) 45 | 46 | if db_value_is_aware: 47 | # If db value is aware, it means that settings.USE_TZ=True, so we need to convert in-memory one 48 | warnings.warn(u"DateTimeField received a naive datetime (%s)" 49 | u" while time zone support is active." % new_value, 50 | RuntimeWarning) 51 | new_value = django_timezone.make_aware(new_value, timezone_to_set).astimezone(timezone.utc) 52 | else: 53 | # The db is not timezone aware, but the value we are passing for comparison is aware. 54 | warnings.warn(u"Time zone support is not active (settings.USE_TZ=False), " 55 | u"and you pass a time zone aware value (%s)" 56 | u" Converting database value before comparison." % new_value, 57 | RuntimeWarning) 58 | old_value = django_timezone.make_aware(old_value, timezone.utc).astimezone(timezone_to_set) 59 | 60 | return raw_compare(new_value, old_value) 61 | 62 | 63 | def normalise_value(value): 64 | """ 65 | Default normalisation of value simply returns the value as is. 66 | Custom implementations can normalise the value for various storage schemes. 67 | For example, converting datetime objects to iso datetime strings in order to 68 | comply with JSON standard. 69 | """ 70 | return value 71 | -------------------------------------------------------------------------------- /src/dirtyfields/dirtyfields.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from django.core.exceptions import ValidationError 3 | from django.core.files import File 4 | from django.db.models.expressions import BaseExpression 5 | from django.db.models.expressions import Combinable 6 | from django.db.models.signals import post_save, m2m_changed 7 | 8 | from .compare import raw_compare, compare_states, normalise_value 9 | 10 | 11 | def get_m2m_with_model(given_model): 12 | return [ 13 | (f, f.model if f.model != given_model else None) 14 | for f in given_model._meta.get_fields() 15 | if f.many_to_many and not f.auto_created 16 | ] 17 | 18 | 19 | class DirtyFieldsMixin(object): 20 | compare_function = (raw_compare, {}) 21 | normalise_function = (normalise_value, {}) 22 | 23 | # This mode has been introduced to handle some situations like this one: 24 | # https://github.com/romgar/django-dirtyfields/issues/73 25 | ENABLE_M2M_CHECK = False 26 | 27 | FIELDS_TO_CHECK = None 28 | 29 | def __init__(self, *args, **kwargs): 30 | super(DirtyFieldsMixin, self).__init__(*args, **kwargs) 31 | post_save.connect( 32 | reset_state, sender=self.__class__, weak=False, 33 | dispatch_uid='{name}-DirtyFieldsMixin-sweeper'.format( 34 | name=self.__class__.__name__)) 35 | if self.ENABLE_M2M_CHECK: 36 | self._connect_m2m_relations() 37 | reset_state(sender=self.__class__, instance=self) 38 | 39 | def refresh_from_db(self, using=None, fields=None, *args, **kwargs): 40 | super().refresh_from_db(using, fields, *args, **kwargs) 41 | reset_state(sender=self.__class__, instance=self, update_fields=fields) 42 | 43 | def _connect_m2m_relations(self): 44 | for m2m_field, model in get_m2m_with_model(self.__class__): 45 | m2m_changed.connect( 46 | reset_state, sender=m2m_field.remote_field.through, weak=False, 47 | dispatch_uid='{name}-DirtyFieldsMixin-sweeper-m2m'.format( 48 | name=self.__class__.__name__)) 49 | 50 | def _as_dict(self, check_relationship, include_primary_key=True): 51 | """ 52 | Capture the model fields' state as a dictionary. 53 | 54 | Only capture values we are confident are in the database, or would be 55 | saved to the database if self.save() is called. 56 | """ 57 | all_field = {} 58 | 59 | deferred_fields = self.get_deferred_fields() 60 | 61 | for field in self._meta.concrete_fields: 62 | 63 | # For backward compatibility reasons, in particular for fkey fields, we check both 64 | # the real name and the wrapped name (it means that we can specify either the field 65 | # name with or without the "_id" suffix. 66 | field_names_to_check = [field.name, field.get_attname()] 67 | if self.FIELDS_TO_CHECK and (not any(name in self.FIELDS_TO_CHECK for name in field_names_to_check)): 68 | continue 69 | 70 | if field.primary_key and not include_primary_key: 71 | continue 72 | 73 | if field.remote_field: 74 | if not check_relationship: 75 | continue 76 | 77 | if field.get_attname() in deferred_fields: 78 | continue 79 | 80 | field_value = getattr(self, field.attname) 81 | 82 | if isinstance(field_value, File): 83 | # Uses the name for files due to a perfomance regression caused by Django 3.1. 84 | # For more info see: https://github.com/romgar/django-dirtyfields/issues/165 85 | field_value = field_value.name 86 | 87 | # If current field value is an expression, we are not evaluating it 88 | if isinstance(field_value, (BaseExpression, Combinable)): 89 | continue 90 | 91 | try: 92 | # Store the converted value for fields with conversion 93 | field_value = field.to_python(field_value) 94 | except ValidationError: 95 | # The current value is not valid so we cannot convert it 96 | pass 97 | 98 | if isinstance(field_value, memoryview): 99 | # psycopg2 returns uncopyable type buffer for bytea 100 | field_value = bytes(field_value) 101 | 102 | # Explanation of copy usage here : 103 | # https://github.com/romgar/django-dirtyfields/commit/efd0286db8b874b5d6bd06c9e903b1a0c9cc6b00 104 | all_field[field.name] = deepcopy(field_value) 105 | 106 | return all_field 107 | 108 | def _as_dict_m2m(self): 109 | m2m_fields = {} 110 | 111 | if self.pk: 112 | for f, model in get_m2m_with_model(self.__class__): 113 | if self.FIELDS_TO_CHECK and (f.attname not in self.FIELDS_TO_CHECK): 114 | continue 115 | 116 | m2m_fields[f.attname] = set([obj.pk for obj in getattr(self, f.attname).all()]) 117 | 118 | return m2m_fields 119 | 120 | def get_dirty_fields(self, check_relationship=False, check_m2m=None, verbose=False): 121 | if self._state.adding: 122 | # If the object has not yet been saved in the database, all fields are considered dirty 123 | # for consistency (see https://github.com/romgar/django-dirtyfields/issues/65 for more details) 124 | pk_specified = self.pk is not None 125 | initial_dict = self._as_dict(check_relationship, include_primary_key=pk_specified) 126 | if verbose: 127 | initial_dict = {key: {'saved': None, 'current': self.normalise_function[0](value)} 128 | for key, value in initial_dict.items()} 129 | return initial_dict 130 | 131 | if check_m2m is not None and not self.ENABLE_M2M_CHECK: 132 | raise ValueError("You can't check m2m fields if ENABLE_M2M_CHECK is set to False") 133 | 134 | modified_fields = compare_states(self._as_dict(check_relationship), 135 | self._original_state, 136 | self.compare_function, 137 | self.normalise_function) 138 | 139 | if check_m2m: 140 | modified_m2m_fields = compare_states(check_m2m, 141 | self._original_m2m_state, 142 | self.compare_function, 143 | self.normalise_function) 144 | modified_fields.update(modified_m2m_fields) 145 | 146 | if not verbose: 147 | # Keeps backward compatibility with previous function return 148 | modified_fields = { 149 | key: self.normalise_function[0](value['saved']) 150 | for key, value in modified_fields.items() 151 | } 152 | 153 | return modified_fields 154 | 155 | def is_dirty(self, check_relationship=False, check_m2m=None): 156 | return {} != self.get_dirty_fields(check_relationship=check_relationship, 157 | check_m2m=check_m2m) 158 | 159 | def save_dirty_fields(self): 160 | if self._state.adding: 161 | self.save() 162 | else: 163 | dirty_fields = self.get_dirty_fields(check_relationship=True) 164 | self.save(update_fields=dirty_fields.keys()) 165 | 166 | 167 | def reset_state(sender, instance, **kwargs): 168 | # original state should hold all possible dirty fields to avoid 169 | # getting a `KeyError` when checking if a field is dirty or not 170 | update_fields = kwargs.pop('update_fields', None) 171 | new_state = instance._as_dict(check_relationship=True) 172 | FIELDS_TO_CHECK = getattr(instance, "FIELDS_TO_CHECK", None) 173 | 174 | if update_fields is not None: 175 | for field_name in update_fields: 176 | field = sender._meta.get_field(field_name) 177 | if not FIELDS_TO_CHECK or (field.name in FIELDS_TO_CHECK): 178 | 179 | if field.get_attname() in instance.get_deferred_fields(): 180 | continue 181 | 182 | if field.name in new_state: 183 | instance._original_state[field.name] = ( 184 | new_state[field.name] 185 | ) 186 | elif field.name in instance._original_state: 187 | # If we are here it means the field was updated in the DB, 188 | # and we don't know the new value in the database. 189 | # e.g it was updated with an F() expression. 190 | # Because we now don't know the value in the DB, 191 | # we remove it from _original_state, because we can't tell 192 | # if its dirty or not. 193 | del instance._original_state[field.name] 194 | else: 195 | instance._original_state = new_state 196 | 197 | if instance.ENABLE_M2M_CHECK: 198 | instance._original_m2m_state = instance._as_dict_m2m() 199 | -------------------------------------------------------------------------------- /tests-requirements.txt: -------------------------------------------------------------------------------- 1 | # let pip choose the latest version compatible with the python/django version being tested. 2 | psycopg2 3 | pytest 4 | pytest-django 5 | jsonfield 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romgar/django-dirtyfields/bea132213d36313064103dac385ae7d576792ee0/tests/__init__.py -------------------------------------------------------------------------------- /tests/django_settings.py: -------------------------------------------------------------------------------- 1 | # Minimum settings that are needed to run django test suite 2 | import os 3 | import secrets 4 | import tempfile 5 | 6 | USE_TZ = True 7 | SECRET_KEY = secrets.token_hex() 8 | 9 | if "postgresql" in os.getenv("TOX_ENV_NAME", "") or os.getenv("TEST_DATABASE") == "postgres": 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.postgresql', 13 | 'NAME': 'dirtyfields_test', 14 | 'USER': os.getenv('POSTGRES_USER', 'postgres'), 15 | 'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'postgres'), 16 | 'HOST': 'localhost', 17 | 'PORT': '5432', # default postgresql port 18 | } 19 | } 20 | else: 21 | DATABASES = { 22 | 'default': { 23 | 'ENGINE': 'django.db.backends.sqlite3', 24 | 'NAME': 'dirtyfields.db', 25 | } 26 | } 27 | 28 | INSTALLED_APPS = ('tests', ) 29 | 30 | MEDIA_ROOT = tempfile.mkdtemp(prefix="django-dirtyfields-test-media-root-") 31 | -------------------------------------------------------------------------------- /tests/files/bar.txt: -------------------------------------------------------------------------------- 1 | bar-content 2 | -------------------------------------------------------------------------------- /tests/files/blank1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romgar/django-dirtyfields/bea132213d36313064103dac385ae7d576792ee0/tests/files/blank1.png -------------------------------------------------------------------------------- /tests/files/blank2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romgar/django-dirtyfields/bea132213d36313064103dac385ae7d576792ee0/tests/files/blank2.png -------------------------------------------------------------------------------- /tests/files/foo.txt: -------------------------------------------------------------------------------- 1 | foo-content 2 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.signals import pre_save 3 | from django.utils import timezone as django_timezone 4 | from jsonfield import JSONField as JSONFieldThirdParty 5 | 6 | from dirtyfields import DirtyFieldsMixin 7 | from dirtyfields.compare import timezone_support_compare 8 | from tests.utils import is_postgresql_env_with_jsonb_field 9 | 10 | 11 | class ModelTest(DirtyFieldsMixin, models.Model): 12 | """A simple test model to test dirty fields mixin with""" 13 | boolean = models.BooleanField(default=True) 14 | characters = models.CharField(blank=True, max_length=80) 15 | 16 | 17 | class ModelWithDecimalFieldTest(DirtyFieldsMixin, models.Model): 18 | decimal_field = models.DecimalField(decimal_places=2, max_digits=10) 19 | 20 | 21 | class ModelWithForeignKeyTest(DirtyFieldsMixin, models.Model): 22 | fkey = models.ForeignKey(ModelTest, on_delete=models.CASCADE) 23 | 24 | 25 | class MixedFieldsModelTest(DirtyFieldsMixin, models.Model): 26 | fkey = models.ForeignKey(ModelTest, on_delete=models.CASCADE) 27 | characters = models.CharField(blank=True, max_length=80) 28 | 29 | 30 | class ModelWithOneToOneFieldTest(DirtyFieldsMixin, models.Model): 31 | o2o = models.OneToOneField(ModelTest, on_delete=models.CASCADE) 32 | 33 | 34 | class ModelWithNonEditableFieldsTest(DirtyFieldsMixin, models.Model): 35 | dt = models.DateTimeField(auto_now_add=True) 36 | characters = models.CharField(blank=True, max_length=80, 37 | editable=False) 38 | boolean = models.BooleanField(default=True) 39 | 40 | 41 | class ModelWithSelfForeignKeyTest(DirtyFieldsMixin, models.Model): 42 | fkey = models.ForeignKey("self", blank=True, null=True, on_delete=models.CASCADE) 43 | 44 | 45 | class OrdinaryModelTest(models.Model): 46 | boolean = models.BooleanField(default=True) 47 | characters = models.CharField(blank=True, max_length=80) 48 | 49 | 50 | class OrdinaryWithDirtyFieldsProxy(DirtyFieldsMixin, OrdinaryModelTest): 51 | class Meta: 52 | proxy = True 53 | 54 | 55 | class OrdinaryModelWithForeignKeyTest(models.Model): 56 | fkey = models.ForeignKey(OrdinaryModelTest, on_delete=models.CASCADE) 57 | 58 | 59 | class SubclassModelTest(ModelTest): 60 | pass 61 | 62 | 63 | class ExpressionModelTest(DirtyFieldsMixin, models.Model): 64 | counter = models.IntegerField(default=0) 65 | 66 | 67 | class DatetimeModelTest(DirtyFieldsMixin, models.Model): 68 | compare_function = (timezone_support_compare, {}) 69 | datetime_field = models.DateTimeField(default=django_timezone.now) 70 | 71 | 72 | class CurrentDatetimeModelTest(DirtyFieldsMixin, models.Model): 73 | compare_function = ( 74 | timezone_support_compare, 75 | {'timezone_to_set': django_timezone.get_current_timezone()}, 76 | ) 77 | datetime_field = models.DateTimeField(default=django_timezone.now) 78 | 79 | 80 | class Many2ManyModelTest(DirtyFieldsMixin, models.Model): 81 | m2m_field = models.ManyToManyField(ModelTest) 82 | ENABLE_M2M_CHECK = True 83 | 84 | 85 | class Many2ManyWithoutMany2ManyModeEnabledModelTest(DirtyFieldsMixin, models.Model): 86 | m2m_field = models.ManyToManyField(ModelTest) 87 | 88 | 89 | class ModelWithCustomPKTest(DirtyFieldsMixin, models.Model): 90 | custom_primary_key = models.CharField(max_length=80, primary_key=True) 91 | 92 | 93 | class M2MModelWithCustomPKOnM2MTest(DirtyFieldsMixin, models.Model): 94 | m2m_field = models.ManyToManyField(ModelWithCustomPKTest) 95 | 96 | 97 | class WithPreSaveSignalModelTest(DirtyFieldsMixin, models.Model): 98 | data = models.CharField(max_length=255) 99 | data_updated_on_presave = models.CharField(max_length=255, blank=True, null=True) 100 | 101 | @staticmethod 102 | def pre_save(instance, *args, **kwargs): 103 | dirty_fields = instance.get_dirty_fields() 104 | # only works for case2 105 | if 'data' in dirty_fields: 106 | if 'specific_value' in instance.data: 107 | instance.data_updated_on_presave = 'presave_value' 108 | 109 | 110 | pre_save.connect( 111 | WithPreSaveSignalModelTest.pre_save, 112 | sender=WithPreSaveSignalModelTest, 113 | dispatch_uid="WithPreSaveSignalModelTest__pre_save", 114 | ) 115 | 116 | 117 | class ModelWithoutM2MCheckTest(DirtyFieldsMixin, models.Model): 118 | characters = models.CharField(blank=True, max_length=80) 119 | ENABLE_M2M_CHECK = False 120 | 121 | 122 | class DoubleForeignKeyModelTest(DirtyFieldsMixin, models.Model): 123 | fkey1 = models.ForeignKey(ModelTest, on_delete=models.CASCADE) 124 | fkey2 = models.ForeignKey(ModelTest, null=True, related_name='fkey2', 125 | on_delete=models.CASCADE) 126 | 127 | 128 | if is_postgresql_env_with_jsonb_field(): 129 | from django.contrib.postgres.fields import JSONField as JSONBField 130 | 131 | class ModelWithJSONBFieldTest(DirtyFieldsMixin, models.Model): 132 | jsonb_field = JSONBField() 133 | 134 | 135 | class ModelWithJSONFieldThirdPartyTest(DirtyFieldsMixin, models.Model): 136 | json_field_third_party = JSONFieldThirdParty() 137 | 138 | 139 | class ModelWithSpecifiedFieldsTest(DirtyFieldsMixin, models.Model): 140 | boolean1 = models.BooleanField(default=True) 141 | boolean2 = models.BooleanField(default=True) 142 | FIELDS_TO_CHECK = ['boolean1'] 143 | 144 | 145 | class ModelWithSpecifiedFieldsAndForeignKeyTest(DirtyFieldsMixin, models.Model): 146 | boolean1 = models.BooleanField(default=True) 147 | boolean2 = models.BooleanField(default=True) 148 | fk_field = models.OneToOneField(ModelTest, null=True, 149 | on_delete=models.CASCADE) 150 | FIELDS_TO_CHECK = ['fk_field'] 151 | 152 | 153 | class ModelWithSpecifiedFieldsAndForeignKeyTest2(ModelWithSpecifiedFieldsAndForeignKeyTest): 154 | FIELDS_TO_CHECK = ['fk_field_id'] 155 | 156 | 157 | class ModelWithM2MAndSpecifiedFieldsTest(DirtyFieldsMixin, models.Model): 158 | m2m1 = models.ManyToManyField(ModelTest) 159 | m2m2 = models.ManyToManyField(ModelTest) 160 | ENABLE_M2M_CHECK = True 161 | FIELDS_TO_CHECK = ['m2m1'] 162 | 163 | 164 | class BinaryModelTest(DirtyFieldsMixin, models.Model): 165 | bytea = models.BinaryField() 166 | 167 | 168 | class FileFieldModel(DirtyFieldsMixin, models.Model): 169 | file1 = models.FileField(upload_to="file1/") 170 | 171 | 172 | class ImageFieldModel(DirtyFieldsMixin, models.Model): 173 | image1 = models.ImageField(upload_to="image1/") 174 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from os.path import dirname, join 3 | 4 | import pytest 5 | import django 6 | from django.core.files.base import ContentFile, File 7 | from django.db import DatabaseError, transaction 8 | from django.db.models.fields.files import ImageFile 9 | 10 | import dirtyfields 11 | from .models import (ModelTest, ModelWithForeignKeyTest, 12 | ModelWithOneToOneFieldTest, 13 | SubclassModelTest, ModelWithDecimalFieldTest, 14 | FileFieldModel, ImageFieldModel, 15 | OrdinaryModelTest, OrdinaryWithDirtyFieldsProxy) 16 | 17 | 18 | def test_version_numbers(): 19 | assert isinstance(dirtyfields.__version__, str) 20 | assert isinstance(dirtyfields.VERSION, tuple) 21 | assert all(isinstance(number, int) for number in dirtyfields.VERSION) 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_is_dirty_function(): 26 | tm = ModelTest.objects.create() 27 | 28 | # If the object has just been saved in the db, fields are not dirty 29 | assert tm.get_dirty_fields() == {} 30 | assert not tm.is_dirty() 31 | 32 | # As soon as we change a field, it becomes dirty 33 | tm.boolean = False 34 | 35 | assert tm.get_dirty_fields() == {'boolean': True} 36 | assert tm.is_dirty() 37 | 38 | 39 | @pytest.mark.django_db 40 | def test_dirty_fields(): 41 | tm = ModelTest() 42 | 43 | # Initial state is dirty, so should return all fields 44 | assert tm.get_dirty_fields() == {'boolean': True, 'characters': ''} 45 | 46 | tm.save() 47 | 48 | # Saving them make them not dirty anymore 49 | assert tm.get_dirty_fields() == {} 50 | 51 | # Changing values should flag them as dirty again 52 | tm.boolean = False 53 | tm.characters = 'testing' 54 | 55 | assert tm.get_dirty_fields() == { 56 | 'boolean': True, 57 | 'characters': '' 58 | } 59 | 60 | # Resetting them to original values should unflag 61 | tm.boolean = True 62 | assert tm.get_dirty_fields() == { 63 | 'characters': '' 64 | } 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_dirty_fields_for_notsaved_pk(): 69 | tm = ModelTest(id=1) 70 | 71 | # Initial state is dirty, so should return all fields 72 | assert tm.get_dirty_fields() == {'id': 1, 'boolean': True, 'characters': ''} 73 | 74 | tm.save() 75 | 76 | # Saving them make them not dirty anymore 77 | assert tm.get_dirty_fields() == {} 78 | 79 | 80 | @pytest.mark.django_db 81 | def test_relationship_option_for_foreign_key(): 82 | tm1 = ModelTest.objects.create() 83 | tm2 = ModelTest.objects.create() 84 | tm = ModelWithForeignKeyTest.objects.create(fkey=tm1) 85 | 86 | # Let's change the foreign key value and see what happens 87 | tm.fkey = tm2 88 | 89 | # Default dirty check is not taking foreign keys into account 90 | assert tm.get_dirty_fields() == {} 91 | 92 | # But if we use 'check_relationships' param, then foreign keys are compared 93 | assert tm.get_dirty_fields(check_relationship=True) == { 94 | 'fkey': tm1.pk 95 | } 96 | 97 | 98 | @pytest.mark.django_db 99 | def test_relationship_option_for_one_to_one_field(): 100 | tm1 = ModelTest.objects.create() 101 | tm2 = ModelTest.objects.create() 102 | tm = ModelWithOneToOneFieldTest.objects.create(o2o=tm1) 103 | 104 | # Let's change the one to one field and see what happens 105 | tm.o2o = tm2 106 | 107 | # Default dirty check is not taking onetoone fields into account 108 | assert tm.get_dirty_fields() == {} 109 | 110 | # But if we use 'check_relationships' param, then one to one fields are compared 111 | assert tm.get_dirty_fields(check_relationship=True) == { 112 | 'o2o': tm1.pk 113 | } 114 | 115 | 116 | @pytest.mark.django_db 117 | def test_non_local_fields(): 118 | subclass = SubclassModelTest.objects.create(characters='foo') 119 | subclass.characters = 'spam' 120 | 121 | assert subclass.get_dirty_fields() == {'characters': 'foo'} 122 | 123 | 124 | @pytest.mark.django_db 125 | def test_decimal_field_correctly_managed(): 126 | # Non regression test case for bug: 127 | # https://github.com/romgar/django-dirtyfields/issues/4 128 | tm = ModelWithDecimalFieldTest.objects.create(decimal_field=Decimal(2.00)) 129 | 130 | tm.decimal_field = 2.0 131 | assert tm.get_dirty_fields() == {} 132 | 133 | tm.decimal_field = u"2.00" 134 | assert tm.get_dirty_fields() == {} 135 | 136 | 137 | @pytest.mark.django_db 138 | def test_deferred_fields(): 139 | ModelTest.objects.create() 140 | 141 | qs = ModelTest.objects.only('boolean') 142 | 143 | tm = qs[0] 144 | tm.boolean = False 145 | assert tm.get_dirty_fields() == {'boolean': True} 146 | 147 | tm.characters = 'foo' 148 | # 'characters' is not tracked as it is deferred 149 | assert tm.get_dirty_fields() == {'boolean': True} 150 | 151 | 152 | def test_validationerror(): 153 | # Initialize the model with an invalid value 154 | tm = ModelTest(boolean=None) 155 | 156 | # Should not raise ValidationError 157 | assert tm.get_dirty_fields() == {'boolean': None, 'characters': ''} 158 | 159 | tm.boolean = False 160 | assert tm.get_dirty_fields() == {'boolean': False, 'characters': ''} 161 | 162 | 163 | @pytest.mark.django_db 164 | def test_verbose_mode(): 165 | tm = ModelTest.objects.create() 166 | tm.boolean = False 167 | 168 | assert tm.get_dirty_fields(verbose=True) == { 169 | 'boolean': {'saved': True, 'current': False} 170 | } 171 | 172 | 173 | @pytest.mark.django_db 174 | def test_verbose_mode_on_adding(): 175 | tm = ModelTest() 176 | 177 | assert tm.get_dirty_fields(verbose=True) == { 178 | 'boolean': {'saved': None, 'current': True}, 179 | 'characters': {'saved': None, 'current': u''} 180 | } 181 | 182 | 183 | @pytest.mark.django_db 184 | def test_refresh_from_db(): 185 | tm = ModelTest.objects.create() 186 | alias = ModelTest.objects.get(pk=tm.pk) 187 | alias.boolean = False 188 | alias.save() 189 | 190 | tm.refresh_from_db() 191 | assert tm.get_dirty_fields() == {} 192 | 193 | 194 | @pytest.mark.django_db 195 | def test_refresh_from_db_particular_fields(): 196 | tm = ModelTest.objects.create(characters="old value") 197 | tm.boolean = False 198 | tm.characters = "new value" 199 | assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"} 200 | 201 | tm.refresh_from_db(fields={"characters"}) 202 | assert tm.boolean is False 203 | assert tm.characters == "old value" 204 | assert tm.get_dirty_fields() == {"boolean": True} 205 | 206 | 207 | @pytest.mark.django_db 208 | def test_refresh_from_db_no_fields(): 209 | tm = ModelTest.objects.create(characters="old value") 210 | tm.boolean = False 211 | tm.characters = "new value" 212 | assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"} 213 | 214 | tm.refresh_from_db(fields=set()) 215 | assert tm.boolean is False 216 | assert tm.characters == "new value" 217 | assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"} 218 | 219 | 220 | @pytest.mark.django_db 221 | def test_refresh_from_db_position_args(): 222 | tm = ModelTest.objects.create(characters="old value") 223 | tm.boolean = False 224 | tm.characters = "new value" 225 | assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"} 226 | 227 | tm.refresh_from_db("default", {"boolean", "characters"}) 228 | assert tm.boolean is True 229 | assert tm.characters == "old value" 230 | assert tm.get_dirty_fields() == {} 231 | 232 | 233 | @pytest.mark.skipif(django.VERSION < (5, 1), reason="requires django 5.1 or higher") 234 | @pytest.mark.django_db 235 | def test_refresh_from_db_with_from_queryset(): 236 | """Tests passthrough of `from_queryset` field in refresh_from_db 237 | this field was introduced in django 5.1. more details in this PR: 238 | https://github.com/romgar/django-dirtyfields/pull/235 239 | """ 240 | tm = ModelTest.objects.create(characters="old value") 241 | tm.boolean = False 242 | tm.characters = "new value" 243 | assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"} 244 | 245 | tm.refresh_from_db(fields={"characters"}, from_queryset=ModelTest.objects.all()) 246 | assert tm.boolean is False 247 | assert tm.characters == "old value" 248 | assert tm.get_dirty_fields() == {"boolean": True} 249 | 250 | 251 | @pytest.mark.skipif(django.VERSION < (5, 1), reason="requires django 5.1 or higher") 252 | @pytest.mark.django_db 253 | def test_refresh_from_db_position_args_with_queryset(): 254 | tm = ModelTest.objects.create(characters="old value") 255 | tm.boolean = False 256 | tm.characters = "new value" 257 | assert tm.get_dirty_fields() == {"boolean": True, "characters": "old value"} 258 | 259 | tm.refresh_from_db("default", {"characters"}, ModelTest.objects.all()) 260 | assert tm.boolean is False 261 | assert tm.characters == "old value" 262 | assert tm.get_dirty_fields() == {"boolean": True} 263 | 264 | 265 | @pytest.mark.django_db 266 | def test_file_fields_content_file(): 267 | tm = FileFieldModel() 268 | # field is dirty because model is unsaved 269 | assert tm.get_dirty_fields(verbose=True) == { 270 | "file1": {"current": "", "saved": None}, 271 | } 272 | tm.save() 273 | assert tm.get_dirty_fields() == {} 274 | 275 | # set file makes field dirty 276 | tm.file1.save("test-file-1.txt", ContentFile(b"Test file content"), save=False) 277 | assert tm.get_dirty_fields(verbose=True) == { 278 | "file1": {"current": "file1/test-file-1.txt", "saved": ""}, 279 | } 280 | tm.save() 281 | assert tm.get_dirty_fields() == {} 282 | 283 | # change field to new file makes field dirty 284 | tm.file1.save("test-file-2.txt", ContentFile(b"Test file content"), save=False) 285 | assert tm.get_dirty_fields(verbose=True) == { 286 | "file1": {"current": "file1/test-file-2.txt", "saved": "file1/test-file-1.txt"}, 287 | } 288 | tm.save() 289 | assert tm.get_dirty_fields() == {} 290 | 291 | # change field to new file and new content makes field dirty 292 | tm.file1.save("test-file-3.txt", ContentFile(b"Test file content 3"), save=False) 293 | assert tm.get_dirty_fields(verbose=True) == { 294 | "file1": {"current": "file1/test-file-3.txt", "saved": "file1/test-file-2.txt"}, 295 | } 296 | tm.save() 297 | assert tm.get_dirty_fields() == {} 298 | 299 | # change file content without changing file name does not make field dirty 300 | tm.file1.open("w") 301 | tm.file1.write("Test file content edited") 302 | tm.file1.close() 303 | assert tm.file1.name == "file1/test-file-3.txt" 304 | assert tm.get_dirty_fields() == {} 305 | tm.file1.open("r") 306 | assert tm.file1.read() == "Test file content edited" 307 | tm.file1.close() 308 | 309 | 310 | @pytest.mark.django_db 311 | def test_file_fields_real_file(): 312 | tm = FileFieldModel() 313 | # field is dirty because model is unsaved 314 | assert tm.get_dirty_fields(verbose=True) == { 315 | "file1": {"current": "", "saved": None}, 316 | } 317 | tm.save() 318 | assert tm.get_dirty_fields() == {} 319 | 320 | # set file makes field dirty 321 | with open(join(dirname(__file__), "files", "foo.txt"), "rb") as f: 322 | tm.file1.save("test-file-4.txt", File(f), save=False) 323 | assert tm.get_dirty_fields(verbose=True) == { 324 | "file1": {"current": "file1/test-file-4.txt", "saved": ""}, 325 | } 326 | tm.save() 327 | assert tm.get_dirty_fields() == {} 328 | 329 | # change field to new file makes field dirty 330 | with open(join(dirname(__file__), "files", "bar.txt"), "rb") as f: 331 | tm.file1.save("test-file-5.txt", File(f), save=False) 332 | assert tm.get_dirty_fields(verbose=True) == { 333 | "file1": {"current": "file1/test-file-5.txt", "saved": "file1/test-file-4.txt"}, 334 | } 335 | tm.save() 336 | assert tm.get_dirty_fields() == {} 337 | 338 | # change field to new file with same content makes field dirty 339 | with open(join(dirname(__file__), "files", "bar.txt"), "rb") as f: 340 | tm.file1.save("test-file-6.txt", File(f), save=False) 341 | assert tm.get_dirty_fields(verbose=True) == { 342 | "file1": {"current": "file1/test-file-6.txt", "saved": "file1/test-file-5.txt"}, 343 | } 344 | tm.save() 345 | assert tm.get_dirty_fields() == {} 346 | 347 | # change file content without changing file name does not make field dirty 348 | tm.file1.open("w") 349 | tm.file1.write("Test file content edited") 350 | tm.file1.close() 351 | assert tm.file1.name == "file1/test-file-6.txt" 352 | assert tm.get_dirty_fields() == {} 353 | tm.file1.open("r") 354 | assert tm.file1.read() == "Test file content edited" 355 | tm.file1.close() 356 | 357 | 358 | @pytest.mark.django_db 359 | def test_file_fields_real_images(): 360 | tm = ImageFieldModel() 361 | # field is dirty because model is unsaved 362 | assert tm.get_dirty_fields() == {"image1": ""} 363 | tm.save() 364 | assert tm.get_dirty_fields() == {} 365 | 366 | # set file makes field dirty 367 | with open(join(dirname(__file__), "files", "blank1.png"), "rb") as f: 368 | tm.image1.save("test-image-1.png", ImageFile(f), save=False) 369 | assert tm.get_dirty_fields() == {"image1": ""} 370 | tm.save() 371 | assert tm.get_dirty_fields() == {} 372 | 373 | # change file makes field dirty 374 | with open(join(dirname(__file__), "files", "blank2.png"), "rb") as f: 375 | tm.image1.save("test-image-2.png", ImageFile(f), save=False) 376 | assert tm.get_dirty_fields() == {"image1": "image1/test-image-1.png"} 377 | tm.save() 378 | assert tm.get_dirty_fields() == {} 379 | 380 | 381 | @pytest.mark.django_db 382 | def test_transaction_behavior(): 383 | """This test is to document the behavior in transactions""" 384 | # first create a model 385 | tm = ModelTest.objects.create(boolean=True, characters="first") 386 | assert not tm.is_dirty() 387 | 388 | # make an edit in-memory, model becomes dirty 389 | tm.characters = "second" 390 | assert tm.get_dirty_fields() == {"characters": "first"} 391 | 392 | # attempt to save the model in a transaction 393 | try: 394 | with transaction.atomic(): 395 | tm.save() 396 | # no longer dirty because save() has been called, BUT we are still in a transaction ... 397 | assert not tm.is_dirty() 398 | assert tm.get_dirty_fields() == {} 399 | # force a transaction rollback 400 | raise DatabaseError("pretend something went wrong") 401 | except DatabaseError: 402 | pass 403 | 404 | # Here is the problem: 405 | # value in DB is still "first" but model does not think its dirty. 406 | assert tm.characters == "second" 407 | assert not tm.is_dirty() # <---- In an ideal world this would be dirty 408 | assert tm.get_dirty_fields() == {} 409 | 410 | # here is a workaround, after failed transaction call refresh_from_db() 411 | tm.refresh_from_db() 412 | assert tm.characters == "first" 413 | assert tm.get_dirty_fields() == {} 414 | # test can become dirty again 415 | tm.characters = "third" 416 | assert tm.is_dirty() 417 | assert tm.get_dirty_fields() == {"characters": "first"} 418 | 419 | 420 | @pytest.mark.django_db 421 | def test_proxy_model_behavior(): 422 | tm = OrdinaryModelTest.objects.create() 423 | 424 | dirty_tm = OrdinaryWithDirtyFieldsProxy.objects.get(id=tm.id) 425 | assert not dirty_tm.is_dirty() 426 | assert dirty_tm.get_dirty_fields() == {} 427 | 428 | dirty_tm.boolean = False 429 | dirty_tm.characters = "hello" 430 | assert dirty_tm.is_dirty() 431 | assert dirty_tm.get_dirty_fields() == {"characters": "", "boolean": True} 432 | 433 | dirty_tm.save() 434 | assert not dirty_tm.is_dirty() 435 | assert dirty_tm.get_dirty_fields() == {} 436 | 437 | tm.refresh_from_db() 438 | assert tm.boolean is False 439 | assert tm.characters == "hello" 440 | -------------------------------------------------------------------------------- /tests/test_json_field_third_party.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.models import ModelWithJSONFieldThirdPartyTest 4 | 5 | 6 | @pytest.mark.django_db 7 | def test_json_field_third_party(): 8 | tm = ModelWithJSONFieldThirdPartyTest.objects.create(json_field_third_party={'data': [1, 2, 3]}) 9 | 10 | data = tm.json_field_third_party['data'] 11 | data.append(4) 12 | 13 | assert tm.get_dirty_fields() == { 14 | 'json_field_third_party': {'data': [1, 2, 3]} 15 | } 16 | -------------------------------------------------------------------------------- /tests/test_m2m_fields.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .models import ModelTest, Many2ManyModelTest, ModelWithCustomPKTest, M2MModelWithCustomPKOnM2MTest, \ 4 | ModelWithoutM2MCheckTest, Many2ManyWithoutMany2ManyModeEnabledModelTest 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_dirty_fields_on_m2m(): 9 | tm = Many2ManyModelTest.objects.create() 10 | tm2 = ModelTest.objects.create() 11 | tm.m2m_field.add(tm2) 12 | 13 | assert tm._as_dict_m2m() == {'m2m_field': set([tm2.id])} 14 | 15 | # m2m check should be explicit: you have to give the values you want to compare with db state. 16 | # This first assertion means that m2m_field has one element of id tm2 in the database. 17 | assert tm.get_dirty_fields(check_m2m={'m2m_field': set([tm2.id])}) == {} 18 | 19 | # This second assertion means that I'm expecting a m2m_field that is related to an element with id 0 20 | # As it differs, we return the previous saved elements. 21 | assert tm.get_dirty_fields(check_m2m={'m2m_field': set([0])}) == {'m2m_field': set([tm2.id])} 22 | 23 | assert tm.get_dirty_fields(check_m2m={'m2m_field': set([0, tm2.id])}) == {'m2m_field': set([tm2.id])} 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_dirty_fields_on_m2m_not_possible_if_not_enabled(): 28 | tm = Many2ManyWithoutMany2ManyModeEnabledModelTest.objects.create() 29 | tm2 = ModelTest.objects.create() 30 | tm.m2m_field.add(tm2) 31 | 32 | with pytest.raises(ValueError): 33 | assert tm.get_dirty_fields(check_m2m={'m2m_field': set([tm2.id])}) == {} 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_m2m_check_with_custom_primary_key(): 38 | # test for bug: https://github.com/romgar/django-dirtyfields/issues/74 39 | 40 | tm = ModelWithCustomPKTest.objects.create(custom_primary_key='pk1') 41 | m2m_model = M2MModelWithCustomPKOnM2MTest.objects.create() 42 | 43 | # This line was triggering this error: 44 | # AttributeError: 'ModelWithCustomPKTest' object has no attribute 'id' 45 | m2m_model.m2m_field.add(tm) 46 | 47 | 48 | @pytest.mark.django_db 49 | def test_m2m_disabled_does_not_allow_to_check_m2m_fields(): 50 | tm = ModelWithoutM2MCheckTest.objects.create() 51 | 52 | with pytest.raises(Exception): 53 | assert tm.get_dirty_fields(check_m2m={'dummy': True}) 54 | -------------------------------------------------------------------------------- /tests/test_memory_leak.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import resource 3 | 4 | import pytest 5 | 6 | from .models import ModelTest as DirtyMixinModel 7 | 8 | pytestmark = pytest.mark.django_db 9 | 10 | 11 | def test_rss_usage(): 12 | DirtyMixinModel() 13 | gc.collect() 14 | rss_1 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss 15 | for _ in range(1000): 16 | DirtyMixinModel() 17 | gc.collect() 18 | rss_2 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss 19 | assert rss_2 == rss_1, 'There is a memory leak!' 20 | -------------------------------------------------------------------------------- /tests/test_non_regression.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db import IntegrityError 3 | from django.db.models import F 4 | from django.test.utils import override_settings 5 | 6 | from .models import (ModelTest, ModelWithForeignKeyTest, ModelWithNonEditableFieldsTest, 7 | OrdinaryModelTest, OrdinaryModelWithForeignKeyTest, ModelWithSelfForeignKeyTest, 8 | ExpressionModelTest, WithPreSaveSignalModelTest, DoubleForeignKeyModelTest, 9 | BinaryModelTest) 10 | from .utils import assert_select_number_queries_on_model 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_slicing_and_only(): 15 | # test for bug: https://github.com/depop/django-dirtyfields/issues/1 16 | for _ in range(10): 17 | ModelWithNonEditableFieldsTest.objects.create() 18 | 19 | qs_ = ModelWithNonEditableFieldsTest.objects.only('pk').filter() 20 | [o for o in qs_.filter().order_by('pk')] 21 | 22 | 23 | @pytest.mark.django_db 24 | def test_dirty_fields_ignores_the_editable_property_of_fields(): 25 | # Non regression test case for bug: 26 | # https://github.com/romgar/django-dirtyfields/issues/17 27 | tm = ModelWithNonEditableFieldsTest.objects.create() 28 | 29 | # Changing values should flag them as dirty 30 | tm.boolean = False 31 | tm.characters = 'testing' 32 | assert tm.get_dirty_fields() == { 33 | 'boolean': True, 34 | 'characters': '' 35 | } 36 | 37 | 38 | @pytest.mark.django_db 39 | def test_mandatory_foreign_key_field_not_initialized_is_not_raising_related_object_exception(): 40 | # Non regression test case for bug: 41 | # https://github.com/romgar/django-dirtyfields/issues/26 42 | with pytest.raises(IntegrityError): 43 | ModelWithForeignKeyTest.objects.create() 44 | 45 | 46 | @pytest.mark.django_db 47 | @override_settings(DEBUG=True) # The test runner sets DEBUG to False. Set to True to enable SQL logging. 48 | def test_relationship_model_loading_issue(): 49 | # Non regression test case for bug: 50 | # https://github.com/romgar/django-dirtyfields/issues/34 51 | 52 | # Query tests with models that are not using django-dirtyfields 53 | tm1 = OrdinaryModelTest.objects.create() 54 | tm2 = OrdinaryModelTest.objects.create() 55 | OrdinaryModelWithForeignKeyTest.objects.create(fkey=tm1) 56 | OrdinaryModelWithForeignKeyTest.objects.create(fkey=tm2) 57 | 58 | with assert_select_number_queries_on_model(OrdinaryModelWithForeignKeyTest, 1): 59 | # should be 0 since we don't access the relationship for now 60 | with assert_select_number_queries_on_model(OrdinaryModelTest, 0): 61 | for tmf in OrdinaryModelWithForeignKeyTest.objects.all(): 62 | tmf.pk 63 | 64 | with assert_select_number_queries_on_model(OrdinaryModelWithForeignKeyTest, 1): 65 | with assert_select_number_queries_on_model(OrdinaryModelTest, 2): 66 | for tmf in OrdinaryModelWithForeignKeyTest.objects.all(): 67 | tmf.fkey # access the relationship here 68 | 69 | with assert_select_number_queries_on_model(OrdinaryModelWithForeignKeyTest, 1): 70 | with assert_select_number_queries_on_model(OrdinaryModelTest, 0): # should be 0 since we use `select_related` 71 | for tmf in OrdinaryModelWithForeignKeyTest.objects.select_related('fkey').all(): 72 | tmf.fkey # access the relationship here 73 | 74 | # Query tests with models that are using django-dirtyfields 75 | tm1 = ModelTest.objects.create() 76 | tm2 = ModelTest.objects.create() 77 | ModelWithForeignKeyTest.objects.create(fkey=tm1) 78 | ModelWithForeignKeyTest.objects.create(fkey=tm2) 79 | 80 | with assert_select_number_queries_on_model(ModelWithForeignKeyTest, 1): 81 | with assert_select_number_queries_on_model(ModelTest, 0): # should be 0, was 2 before bug fixing 82 | for tmf in ModelWithForeignKeyTest.objects.all(): 83 | tmf.pk # we don't need the relationship here 84 | 85 | with assert_select_number_queries_on_model(ModelWithForeignKeyTest, 1): 86 | with assert_select_number_queries_on_model(ModelTest, 2): 87 | for tmf in ModelWithForeignKeyTest.objects.all(): 88 | tmf.fkey # access the relationship here 89 | 90 | with assert_select_number_queries_on_model(ModelWithForeignKeyTest, 1): 91 | # should be 0 since we use `selected_related` (was 2 before) 92 | with assert_select_number_queries_on_model(ModelTest, 0): 93 | for tmf in ModelWithForeignKeyTest.objects.select_related('fkey').all(): 94 | tmf.fkey # access the relationship here 95 | 96 | 97 | @pytest.mark.django_db 98 | def test_relationship_option_for_foreign_key_to_self(): 99 | # Non regression test case for bug: 100 | # https://github.com/romgar/django-dirtyfields/issues/22 101 | tm = ModelWithSelfForeignKeyTest.objects.create() 102 | tm1 = ModelWithSelfForeignKeyTest.objects.create(fkey=tm) 103 | 104 | tm.fkey = tm1 105 | tm.save() 106 | 107 | # Trying to access an instance was triggering a "RuntimeError: maximum recursion depth exceeded" 108 | ModelWithSelfForeignKeyTest.objects.all()[0] 109 | 110 | 111 | @pytest.mark.django_db 112 | def test_expressions_not_taken_into_account_for_dirty_check(): 113 | # Non regression test case for bug: 114 | # https://github.com/romgar/django-dirtyfields/issues/39 115 | from django.db.models import F 116 | tm = ExpressionModelTest.objects.create() 117 | tm.counter = F('counter') + 1 118 | 119 | # This save() was raising a ValidationError: [u"'F(counter) + Value(1)' value must be an integer."] 120 | # caused by a call to_python() on an expression node 121 | tm.save() 122 | 123 | 124 | @pytest.mark.django_db 125 | def test_pre_save_signal_make_dirty_checking_not_consistent(): 126 | # first case 127 | model = WithPreSaveSignalModelTest.objects.create(data='specific_value') 128 | assert model.data_updated_on_presave == 'presave_value' 129 | 130 | # second case 131 | model = WithPreSaveSignalModelTest(data='specific_value') 132 | model.save() 133 | assert model.data_updated_on_presave == 'presave_value' 134 | 135 | # third case 136 | model = WithPreSaveSignalModelTest() 137 | model.data = 'specific_value' 138 | model.save() 139 | assert model.data_updated_on_presave == 'presave_value' 140 | 141 | 142 | @pytest.mark.django_db 143 | def test_foreign_key_deferred_field(): 144 | # Non regression test case for bug: 145 | # https://github.com/romgar/django-dirtyfields/issues/84 146 | tm = ModelTest.objects.create() 147 | DoubleForeignKeyModelTest.objects.create(fkey1=tm) 148 | 149 | list(DoubleForeignKeyModelTest.objects.only('fkey1')) # RuntimeError was raised here! 150 | 151 | 152 | @pytest.mark.django_db 153 | def test_bytea(): 154 | BinaryModelTest.objects.create(bytea=b'^H\xc3\xabllo') 155 | tbm = BinaryModelTest.objects.get() 156 | tbm.bytea = b'W\xc3\xb6rlD' 157 | assert tbm.get_dirty_fields() == { 158 | 'bytea': b'^H\xc3\xabllo', 159 | } 160 | 161 | 162 | @pytest.mark.django_db 163 | def test_access_deferred_field_doesnt_reset_state(): 164 | # Non regression test case for bug: 165 | # https://github.com/romgar/django-dirtyfields/issues/154 166 | tm = ModelTest.objects.create(characters="old value") 167 | tm_deferred = ModelTest.objects.defer("characters").get(id=tm.id) 168 | assert tm_deferred.get_deferred_fields() == {"characters"} 169 | tm_deferred.boolean = False 170 | assert tm_deferred.get_dirty_fields() == {"boolean": True} 171 | 172 | tm_deferred.characters # access deferred field 173 | assert tm_deferred.get_deferred_fields() == set() 174 | # previously accessing the deferred field would reset the dirty state. 175 | assert tm_deferred.get_dirty_fields() == {"boolean": True} 176 | 177 | 178 | @pytest.mark.django_db 179 | def test_f_objects_and_save_update_fields_works(): 180 | # Non regression test case for bug: 181 | # https://github.com/romgar/django-dirtyfields/issues/118 182 | tm = ExpressionModelTest.objects.create(counter=0) 183 | assert tm.counter == 0 184 | 185 | tm.counter = F("counter") + 1 186 | tm.save() 187 | tm.refresh_from_db() 188 | assert tm.counter == 1 189 | 190 | tm.counter = F("counter") + 1 191 | tm.save(update_fields=["counter"]) 192 | tm.refresh_from_db() 193 | assert tm.counter == 2 194 | 195 | 196 | @pytest.mark.django_db 197 | def test_f_objects_and_save_update_fields_works_multiple_times(): 198 | # Non regression test case for bug: 199 | # https://github.com/romgar/django-dirtyfields/issues/208 200 | tm = ExpressionModelTest.objects.create(counter=0) 201 | assert tm.counter == 0 202 | 203 | tm.counter = F("counter") + 1 204 | tm.save(update_fields=["counter"]) 205 | tm_from_db = ExpressionModelTest.objects.get(id=tm.id) 206 | assert tm_from_db.counter == 1 207 | 208 | tm.counter = F("counter") + 1 209 | tm.save(update_fields=["counter"]) 210 | tm_from_db = ExpressionModelTest.objects.get(id=tm.id) 211 | assert tm_from_db.counter == 2 212 | -------------------------------------------------------------------------------- /tests/test_postgresql_specific.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.utils import is_postgresql_env_with_jsonb_field 4 | 5 | 6 | @pytest.mark.skipif(not is_postgresql_env_with_jsonb_field(), 7 | reason="requires postgresql >= 9.4.0 with jsonb field") 8 | @pytest.mark.django_db 9 | def test_dirty_jsonb_field(): 10 | from tests.models import ModelWithJSONBFieldTest 11 | 12 | tm = ModelWithJSONBFieldTest.objects.create(jsonb_field={'data': [1, 2, 3]}) 13 | 14 | data = tm.jsonb_field['data'] 15 | data.append(4) 16 | 17 | assert tm.get_dirty_fields(verbose=True) == { 18 | 'jsonb_field': { 19 | 'current': {'data': [1, 2, 3, 4]}, 20 | 'saved': {'data': [1, 2, 3]} 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/test_save_fields.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.db.models import F 4 | 5 | from .models import ( 6 | ExpressionModelTest, 7 | MixedFieldsModelTest, 8 | ModelTest, 9 | ModelWithForeignKeyTest, 10 | ) 11 | from .utils import assert_number_of_queries_on_regex 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_save_dirty_simple_field(): 16 | tm = ModelTest.objects.create() 17 | 18 | tm.characters = 'new_character' 19 | 20 | with assert_number_of_queries_on_regex(r'.*characters.*', 1): 21 | with assert_number_of_queries_on_regex(r'.*boolean.*', 1): 22 | tm.save() 23 | 24 | tm.characters = 'new_character_2' 25 | 26 | # Naive checking on fields involved in Django query 27 | # boolean unchanged field is not updated on Django update query: GOOD ! 28 | with assert_number_of_queries_on_regex(r'.*characters.*', 1): 29 | with assert_number_of_queries_on_regex(r'.*boolean.*', 0): 30 | tm.save_dirty_fields() 31 | 32 | # We also check that the value has been correctly updated by our custom function 33 | assert tm.get_dirty_fields() == {} 34 | assert ModelTest.objects.get(pk=tm.pk).characters == 'new_character_2' 35 | 36 | 37 | @pytest.mark.django_db 38 | def test_save_dirty_related_field(): 39 | tm1 = ModelTest.objects.create() 40 | tm2 = ModelTest.objects.create() 41 | tmfm = MixedFieldsModelTest.objects.create(fkey=tm1) 42 | 43 | tmfm.fkey = tm2 44 | 45 | with assert_number_of_queries_on_regex(r'.*fkey_id.*', 1): 46 | with assert_number_of_queries_on_regex(r'.*characters.*', 1): 47 | tmfm.save() 48 | 49 | tmfm.fkey = tm1 50 | 51 | # Naive checking on fields involved in Django query 52 | # characters unchanged field is not updated on Django update query: GOOD ! 53 | with assert_number_of_queries_on_regex(r'.*fkey_id.*', 1): 54 | with assert_number_of_queries_on_regex(r'.*characters.*', 0): 55 | tmfm.save_dirty_fields() 56 | 57 | # We also check that the value has been correctly updated by our custom function 58 | assert tmfm.get_dirty_fields() == {} 59 | assert MixedFieldsModelTest.objects.get(pk=tmfm.pk).fkey_id == tm1.id 60 | 61 | 62 | @pytest.mark.django_db 63 | def test_save_dirty_full_save_on_adding(): 64 | tm = ModelTest() 65 | tm.save_dirty_fields() 66 | assert tm.pk 67 | assert tm.get_dirty_fields() == {} 68 | 69 | 70 | @pytest.mark.django_db 71 | def test_save_only_specific_fields_should_let_other_fields_dirty(): 72 | tm = ModelTest.objects.create(boolean=True, characters='dummy') 73 | 74 | tm.boolean = False 75 | tm.characters = 'new_dummy' 76 | 77 | tm.save(update_fields=['boolean']) 78 | 79 | # 'characters' field should still be dirty, update_fields was only saving the 'boolean' field in the db 80 | assert tm.get_dirty_fields() == {'characters': 'dummy'} 81 | 82 | 83 | @pytest.mark.django_db 84 | def test_save_empty_update_fields_wont_reset_dirty_state(): 85 | tm = ModelTest.objects.create(boolean=True, characters='dummy') 86 | 87 | tm.boolean = False 88 | tm.characters = 'new_dummy' 89 | assert tm.get_dirty_fields() == {"boolean": True, 'characters': 'dummy'} 90 | 91 | # Django docs say "An empty update_fields iterable will skip the save", 92 | # so this should not change the dirty state. 93 | tm.save(update_fields=[]) 94 | 95 | assert tm.boolean is False 96 | assert tm.characters == "new_dummy" 97 | assert tm.get_dirty_fields() == {"boolean": True, 'characters': 'dummy'} 98 | 99 | 100 | @pytest.mark.django_db 101 | def test_handle_foreignkeys_id_field_in_update_fields(): 102 | tm1 = ModelTest.objects.create(boolean=True, characters='dummy') 103 | tm2 = ModelTest.objects.create(boolean=True, characters='dummy') 104 | tmwfk = ModelWithForeignKeyTest.objects.create(fkey=tm1) 105 | 106 | tmwfk.fkey = tm2 107 | assert tmwfk.get_dirty_fields(check_relationship=True) == {'fkey': tm1.pk} 108 | 109 | tmwfk.save(update_fields=['fkey_id']) 110 | assert tmwfk.get_dirty_fields(check_relationship=True) == {} 111 | 112 | 113 | @pytest.mark.django_db 114 | def test_correctly_handle_foreignkeys_id_field_in_update_fields(): 115 | tm1 = ModelTest.objects.create(boolean=True, characters='dummy') 116 | tm2 = ModelTest.objects.create(boolean=True, characters='dummy') 117 | tmwfk = ModelWithForeignKeyTest.objects.create(fkey=tm1) 118 | 119 | tmwfk.fkey_id = tm2.pk 120 | assert tmwfk.get_dirty_fields(check_relationship=True) == {'fkey': tm1.pk} 121 | 122 | tmwfk.save(update_fields=['fkey']) 123 | assert tmwfk.get_dirty_fields(check_relationship=True) == {} 124 | 125 | 126 | @pytest.mark.django_db 127 | def test_save_deferred_field_with_update_fields(): 128 | ModelTest.objects.create() 129 | 130 | tm = ModelTest.objects.defer('boolean').first() 131 | tm.boolean = False 132 | # Test that providing a deferred field to the update_fields 133 | # save parameter doesn't raise a KeyError anymore. 134 | tm.save(update_fields=['boolean']) 135 | 136 | 137 | @pytest.mark.django_db 138 | def test_deferred_field_was_not_dirty(): 139 | ModelTest.objects.create() 140 | tm = ModelTest.objects.defer('boolean').first() 141 | tm.boolean = False 142 | assert tm.get_dirty_fields() == {} 143 | 144 | 145 | @pytest.mark.django_db 146 | def test_save_deferred_field_with_update_fields_behaviour(): 147 | """ Behaviour of deferred fields has changed in Django 1.10. 148 | Once explicitly updated (using the save update_fields parameter), 149 | a field cannot be considered deferred anymore. 150 | """ 151 | 152 | ModelTest.objects.create() 153 | tm = ModelTest.objects.defer('boolean').first() 154 | tm.save(update_fields=['boolean']) 155 | tm.boolean = False 156 | assert tm.get_dirty_fields() == {'boolean': True} 157 | 158 | 159 | @pytest.mark.django_db 160 | def test_get_dirty_fields_when_saving_with_f_objects(): 161 | """ 162 | This documents how get_dirty_fields() behaves when updating model fields 163 | with F objects. 164 | """ 165 | 166 | tm = ExpressionModelTest.objects.create(counter=0) 167 | assert tm.counter == 0 168 | assert tm.get_dirty_fields() == {} 169 | 170 | tm.counter = F("counter") + 1 171 | # tm.counter field is not considered dirty because it doesn't have a simple 172 | # value in memory we can compare to the original value. 173 | # i.e. we don't know what value it will be in the database after the F 174 | # object is translated into SQL. 175 | assert tm.get_dirty_fields() == {} 176 | 177 | tm.save() 178 | # tm.counter is still an F object after save() - we don't know the new 179 | # value in the database. 180 | assert tm.get_dirty_fields() == {} 181 | 182 | tm.counter = 10 183 | # even though we have now assigned a literal value to tm.counter, we don't 184 | # know the value in the database, so it is not considered dirty. 185 | assert tm.get_dirty_fields() == {} 186 | 187 | tm.save() 188 | assert tm.get_dirty_fields() == {} 189 | 190 | tm.refresh_from_db() 191 | # if we call refresh_from_db(), we load the database value, 192 | # so we can assign a value and make the field dirty again. 193 | tm.counter = 20 194 | assert tm.get_dirty_fields() == {"counter": 10} 195 | 196 | 197 | @pytest.mark.django_db 198 | def test_get_dirty_fields_when_saving_with_f_objects_update_fields_specified(): 199 | """ 200 | Same as above but with update_fields specified when saving/refreshing 201 | """ 202 | 203 | tm = ExpressionModelTest.objects.create(counter=0) 204 | assert tm.counter == 0 205 | assert tm.get_dirty_fields() == {} 206 | 207 | tm.counter = F("counter") + 1 208 | assert tm.get_dirty_fields() == {} 209 | 210 | tm.save(update_fields={"counter"}) 211 | assert tm.get_dirty_fields() == {} 212 | 213 | tm.counter = 10 214 | assert tm.get_dirty_fields() == {} 215 | 216 | tm.save(update_fields={"counter"}) 217 | assert tm.get_dirty_fields() == {} 218 | 219 | tm.refresh_from_db(fields={"counter"}) 220 | tm.counter = 20 221 | assert tm.get_dirty_fields() == {"counter": 10} 222 | -------------------------------------------------------------------------------- /tests/test_specified_fields.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .models import ( 4 | ModelTest, 5 | ModelWithSpecifiedFieldsTest, 6 | ModelWithM2MAndSpecifiedFieldsTest, 7 | ModelWithSpecifiedFieldsAndForeignKeyTest, 8 | ModelWithSpecifiedFieldsAndForeignKeyTest2 9 | ) 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_dirty_fields_on_model_with_specified_fields(): 14 | tm = ModelWithSpecifiedFieldsTest.objects.create() 15 | 16 | tm.boolean1 = False 17 | tm.boolean2 = False 18 | 19 | # boolean1 is tracked, boolean2 isn`t tracked 20 | assert tm.get_dirty_fields() == {'boolean1': True} 21 | 22 | 23 | @pytest.mark.django_db 24 | def test_dirty_fields_on_model_with_m2m_and_specified_fields(): 25 | tm = ModelWithM2MAndSpecifiedFieldsTest.objects.create() 26 | tm2 = ModelTest.objects.create() 27 | 28 | tm.m2m1.add(tm2) 29 | tm.m2m2.add(tm2) 30 | 31 | # m2m1 is tracked, m2m2 isn`t tracked 32 | assert tm.get_dirty_fields(check_m2m={'m2m1': set([])}) == {'m2m1': set([tm2.id])} 33 | assert tm.get_dirty_fields(check_m2m={'m2m2': set([])}) == {} 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_dirty_fields_on_model_with_specified_fields_can_save_when_non_tracked_field_is_modified(): 38 | tm = ModelWithSpecifiedFieldsTest.objects.create() 39 | 40 | tm.boolean1 = False 41 | tm.boolean2 = False 42 | 43 | tm.save(update_fields=["boolean2"]) 44 | 45 | assert "boolean1" in tm._original_state 46 | assert "boolean2" not in tm._original_state 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_dirty_fields_on_model_with_specified_fields_can_save_when_non_tracked_fk_field_is_modified(): 51 | tm = ModelWithSpecifiedFieldsAndForeignKeyTest.objects.create() 52 | fk = ModelTest.objects.create() 53 | tm.fk_field = fk 54 | tm.boolean1 = False 55 | tm.boolean2 = False 56 | 57 | tm.save(update_fields=["fk_field"]) 58 | assert "fk_field" in tm._original_state 59 | 60 | tm.boolean2 = True 61 | tm.save() 62 | assert "fk_field" in tm._original_state 63 | assert "boolean2" not in tm._original_state 64 | 65 | 66 | @pytest.mark.django_db 67 | def test_dirty_fields_on_model_with_specified_fields_can_save_when_non_tracked_fk_field_is_modified_2(): 68 | tm = ModelWithSpecifiedFieldsAndForeignKeyTest2.objects.create() 69 | fk = ModelTest.objects.create() 70 | tm.fk_field = fk 71 | tm.boolean1 = False 72 | tm.boolean2 = False 73 | 74 | tm.save(update_fields=["fk_field"]) 75 | assert "fk_field" in tm._original_state 76 | 77 | tm.boolean2 = True 78 | tm.save() 79 | assert "fk_field" in tm._original_state 80 | assert "boolean2" not in tm._original_state 81 | -------------------------------------------------------------------------------- /tests/test_timezone_aware_fields.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | import pytest 4 | from django.test.utils import override_settings 5 | from django.utils import timezone as django_timezone 6 | 7 | from .models import DatetimeModelTest, CurrentDatetimeModelTest 8 | 9 | 10 | @override_settings(USE_TZ=True) 11 | @pytest.mark.django_db 12 | def test_datetime_fields_when_aware_db_and_naive_current_value(): 13 | tm = DatetimeModelTest.objects.create(datetime_field=datetime(2000, 1, 1, tzinfo=timezone.utc)) 14 | 15 | # Adding a naive datetime 16 | tm.datetime_field = datetime(2016, 1, 1) 17 | 18 | with pytest.warns( 19 | RuntimeWarning, 20 | match=( 21 | r"DateTimeField received a naive datetime \(2016-01-01 00:00:00\) " 22 | r"while time zone support is active\." 23 | ), 24 | ): 25 | assert tm.get_dirty_fields() == {'datetime_field': datetime(2000, 1, 1, tzinfo=timezone.utc)} 26 | 27 | 28 | @override_settings(USE_TZ=False) 29 | @pytest.mark.django_db 30 | def test_datetime_fields_when_naive_db_and_aware_current_value(): 31 | tm = DatetimeModelTest.objects.create(datetime_field=datetime(2000, 1, 1)) 32 | 33 | # Adding an aware datetime 34 | tm.datetime_field = datetime(2016, 1, 1, tzinfo=timezone.utc) 35 | 36 | with pytest.warns( 37 | RuntimeWarning, 38 | match=( 39 | r"Time zone support is not active \(settings\.USE_TZ=False\), " 40 | r"and you pass a time zone aware value " 41 | r"\(2016-01-01 00:00:00\+00:00\) " 42 | r"Converting database value before comparison\." 43 | ), 44 | ): 45 | assert tm.get_dirty_fields() == {'datetime_field': datetime(2000, 1, 1)} 46 | 47 | 48 | @override_settings(USE_TZ=True) 49 | @pytest.mark.django_db 50 | def test_datetime_fields_when_aware_db_and_aware_current_value(): 51 | aware_dt = django_timezone.now() 52 | tm = DatetimeModelTest.objects.create(datetime_field=aware_dt) 53 | 54 | tm.datetime_field = django_timezone.now() 55 | 56 | assert tm.get_dirty_fields() == {'datetime_field': aware_dt} 57 | 58 | 59 | @override_settings(USE_TZ=False) 60 | @pytest.mark.django_db 61 | def test_datetime_fields_when_naive_db_and_naive_current_value(): 62 | naive_dt = datetime.now() 63 | tm = DatetimeModelTest.objects.create(datetime_field=naive_dt) 64 | 65 | tm.datetime_field = datetime.now() 66 | 67 | assert tm.get_dirty_fields() == {'datetime_field': naive_dt} 68 | 69 | 70 | @override_settings(USE_TZ=True, TIME_ZONE='America/Chicago') 71 | @pytest.mark.django_db 72 | def test_datetime_fields_with_current_timezone_conversion(): 73 | tm = CurrentDatetimeModelTest.objects.create(datetime_field=datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)) 74 | 75 | # Adding a naive datetime, that will be converted to local timezone. 76 | tm.datetime_field = datetime(2000, 1, 1, 6, 0, 0) 77 | 78 | # Chicago is UTC-6h, this field shouldn't be dirty, as we will automatically set this naive datetime 79 | # with current timezone and then convert it to utc to compare it with database one. 80 | with pytest.warns( 81 | RuntimeWarning, 82 | match=( 83 | r"DateTimeField received a naive datetime " 84 | r"\(2000-01-01 06:00:00\) while time zone support is active\." 85 | ), 86 | ): 87 | assert tm.get_dirty_fields() == {} 88 | 89 | 90 | @override_settings(USE_TZ=False, TIME_ZONE='America/Chicago') 91 | @pytest.mark.django_db 92 | def test_datetime_fields_with_current_timezone_conversion_without_timezone_support(): 93 | tm = CurrentDatetimeModelTest.objects.create(datetime_field=datetime(2000, 1, 1, 12, 0, 0)) 94 | 95 | # Adding an aware datetime, Chicago is UTC-6h 96 | chicago_timezone = timezone(timedelta(hours=-6)) 97 | tm.datetime_field = datetime(2000, 1, 1, 6, 0, 0, tzinfo=chicago_timezone) 98 | 99 | # If the database is naive, then we consider that it is defined as in UTC. 100 | with pytest.warns( 101 | RuntimeWarning, 102 | match=( 103 | r"Time zone support is not active \(settings\.USE_TZ=False\), " 104 | r"and you pass a time zone aware value " 105 | r"\(2000-01-01 06:00:00-06:00\) " 106 | r"Converting database value before comparison\." 107 | ), 108 | ): 109 | assert tm.get_dirty_fields() == {} 110 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.db import connection 5 | 6 | 7 | class assert_number_queries(object): 8 | 9 | def __init__(self, number): 10 | self.number = number 11 | 12 | def matched_queries(self): 13 | return connection.queries 14 | 15 | def query_count(self): 16 | return len(self.matched_queries()) 17 | 18 | def __enter__(self): 19 | self.DEBUG = settings.DEBUG 20 | settings.DEBUG = True 21 | self.num_queries_before = self.query_count() 22 | 23 | def __exit__(self, type, value, traceback): 24 | self.num_queries_after = self.query_count() 25 | assert self.num_queries_after - self.num_queries_before == self.number 26 | settings.DEBUG = self.DEBUG 27 | 28 | 29 | class RegexMixin(object): 30 | regex = None 31 | 32 | def matched_queries(self): 33 | matched_queries = super(RegexMixin, self).matched_queries() 34 | 35 | if self.regex is not None: 36 | pattern = re.compile(self.regex) 37 | regex_compliant_queries = [query for query in matched_queries if pattern.match(query.get('sql'))] 38 | 39 | return regex_compliant_queries 40 | 41 | 42 | class assert_number_of_queries_on_regex(RegexMixin, assert_number_queries): 43 | 44 | def __init__(self, regex, number): 45 | super(assert_number_of_queries_on_regex, self).__init__(number) 46 | self.regex = regex 47 | 48 | 49 | class assert_select_number_queries_on_model(assert_number_of_queries_on_regex): 50 | 51 | def __init__(self, model_class, number): 52 | model_name = model_class._meta.model_name 53 | regex = r'^.*SELECT.*FROM "tests_%s".*$' % model_name 54 | 55 | super(assert_select_number_queries_on_model, self).__init__(regex, number) 56 | 57 | 58 | def is_postgresql_env_with_jsonb_field(): 59 | try: 60 | PG_VERSION = connection.pg_version 61 | except AttributeError: 62 | PG_VERSION = 0 63 | 64 | return PG_VERSION >= 90400 65 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | ; tox is just used for testing the python/django matrix locally 2 | [tox] 3 | isolated_build = True 4 | envlist = 5 | py{39}-django{22,30,31}-{postgresql,sqlite} 6 | py{39,310}-django{32,40}-{postgresql,sqlite} 7 | py{39,310,311}-django{41}-{postgresql,sqlite} 8 | py{39,310,311,312}-django{42}-{postgresql,sqlite} 9 | py{310,311,312}-django{50}-{postgresql,sqlite} 10 | py{310,311,312,313}-django{51}-{postgresql,sqlite} 11 | py{310,311,312,313}-django{52}-{postgresql,sqlite} 12 | py{39,310,311,312}-flake8 13 | 14 | [testenv] 15 | passenv = 16 | postgresql: POSTGRES_USER POSTGRES_PASSWORD 17 | setenv = 18 | PYTHONPATH = {toxinidir} 19 | deps = 20 | coverage[toml]~=7.0 21 | django22: Django>=2.2,<2.3 22 | django30: Django>=3.0,<3.1 23 | django31: Django>=3.1,<3.2 24 | django32: Django>=3.2,<3.3 25 | django40: Django>=4.0,<4.1 26 | django41: Django>=4.1,<4.2 27 | django42: Django>=4.2,<4.3 28 | django50: Django>=5.0,<5.1 29 | django51: Django>=5.1,<5.2 30 | django52: Django>=5.2,<5.3 31 | -rtests-requirements.txt 32 | commands = 33 | python --version 34 | pip list 35 | coverage run -m pytest -v 36 | coverage report 37 | 38 | [testenv:py{39,310,311,312}-flake8] 39 | skip_install = True 40 | deps = flake8 41 | commands = 42 | python --version 43 | pip list 44 | flake8 -v src tests docs 45 | --------------------------------------------------------------------------------