├── .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 |
--------------------------------------------------------------------------------