diff --git a/.codeclimate.yml b/.codeclimate.yml index 978fb5fe3..026ed23f8 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,3 +1,4 @@ +--- version: "2" # required to adjust maintainability checks checks: argument-count: @@ -26,10 +27,10 @@ checks: threshold: 4 similar-code: config: - threshold: 50 # language-specific defaults. an override will affect all languages. + threshold: 50 # language-specific defaults. an override will affect all languages. identical-code: config: - threshold: # language-specific defaults. an override will affect all languages. + threshold: # language-specific defaults. an override will affect all languages. plugins: bandit: enabled: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 88363add0..2d98a9b29 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,4 @@ +--- version: 2 updates: - package-ecosystem: "pip" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81e9153f2..db2f70980 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ +--- name: Release on: push: tags: - - '*' + - '*' jobs: build: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 325876158..e143bd18f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,3 +1,4 @@ +--- name: Test on: [push, pull_request] @@ -5,13 +6,19 @@ on: [push, pull_request] jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) - runs-on: ubuntu-latest + runs-on: ubuntu-18.04 strategy: fail-fast: false matrix: python-version: ['3.6', '3.7', '3.8', '3.9'] - django-version: ['2.2', '3.0', '3.1', 'dev'] + django-version: ['2.2', '3.1', '3.2', 'main'] + + exclude: + - python-version: '3.6' + django-version: 'main' + - python-version: '3.7' + django-version: 'main' services: @@ -45,39 +52,39 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} - - name: Get pip cache dir - id: pip-cache - run: | - echo "::set-output name=dir::$(pip cache dir)" + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" - - name: Cache - uses: actions/cache@v2 - with: - path: ${{ steps.pip-cache.outputs.dir }} - key: - ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} - restore-keys: | - ${{ matrix.python-version }}-v1- + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements/tox.txt + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements/tox.txt - - name: Tox tests - run: | - tox -v - env: - DJANGO: ${{ matrix.django-version }} + - name: Tox tests + run: | + tox -v + env: + DJANGO: ${{ matrix.django-version }} - - name: Upload coverage - uses: codecov/codecov-action@v1 - with: - name: Python ${{ matrix.python-version }} + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..6c8d914df --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +--- +repos: + - repo: https://github.com/PyCQA/bandit + rev: 1.7.0 + hooks: + - id: bandit + args: + - "-x *test*.py" + + - repo: https://github.com/ambv/black + rev: 21.9b0 + hooks: + - id: black + language_version: python3.8 + + - repo: https://github.com/pycqa/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + args: + - "--config=tox.ini" + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: requirements-txt-fixer + files: requirements/.*\.txt$ + - id: trailing-whitespace + - id: check-added-large-files + - id: fix-byte-order-marker + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: debug-statements + - id: detect-private-key + + - repo: https://github.com/adrienverge/yamllint + rev: v1.26.3 + hooks: + - id: yamllint + args: + - "--strict" diff --git a/.yamllint b/.yamllint new file mode 100644 index 000000000..d77347d35 --- /dev/null +++ b/.yamllint @@ -0,0 +1,13 @@ +# allow "on" until yamllint stops checking keys for truthy! +# https://github.com/adrienverge/yamllint/issues/158 +--- +extends: default + +rules: + comments-indentation: disable + braces: disable + line-length: + max: 120 + truthy: + level: error + allowed-values: ['true', 'false', 'on'] diff --git a/AUTHORS.rst b/AUTHORS.rst index 84f7b8cce..6ae593cca 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -49,12 +49,15 @@ Authors - Hamish Downer - Hanyin Zhang - Hernan Esteves (`sevetseh28 `_) +- Hielke Walinga (`hwalinga `_) - Jack Cushman (`jcushman `_) +- Jake Howard (`RealOrangeOne `_) - James Muranga (`jamesmura `_) - James Pulec - Jesse Shapiro - Jihoon Baek (`jihoon796 `_) - Jim Gomez +- Jim King (`jeking3 `_) - Joao Junior (`joaojunior `_) - Joao Pedro Francese - `jofusa `_ @@ -92,6 +95,7 @@ Authors - Rajesh Pappula - Ray Logel - Raynald de Lahondes +- Renaud Perrin (`leminaw `_) - Roberto Aguilar - Rod Xavier Bondoc - Ross Lote @@ -103,6 +107,7 @@ Authors - Stefan Borer (`sbor23 `_) - Steven Buss (`sbuss `_) - Steven Klass +- Tim Schilling (`tim-schilling `_) - Tommy Beadle (`tbeadle `_) - Trey Hunner (`treyhunner `_) - Ulysses Vilela @@ -112,6 +117,8 @@ Authors - `Alex Todorov `_ - David Smith (`smithdc1 `_) - Shi Han Ng (`shihanng `_) +- `ddusi `_ +- `DanialErfanian `_ Background ========== diff --git a/CHANGES.rst b/CHANGES.rst index 62c43aa35..d0d327c97 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,37 @@ Changes Unreleased ---------- + +Upgrade Implications: + +- Run `makemigrations` after upgrading to realize the benefit of indexing changes. + +Full list of changes: + +- Added index on `history_date` column; opt-out with setting `SIMPLE_HISTORY_DATE_INDEX` (gh-565) +- Added ``excluded_field_kwargs`` to support custom ``OneToOneField`` that have + additional arguments that don't exist on ``ForeignKey``. (gh-870) +- Fixed ``prev_record`` and ``next_record`` performance when using ``excluded_fields`` (gh-791) +- Fixed `update_change_reason` in pk (gh-806) +- Fixed bug where serializer of djangorestframework crashed if used with ``OrderingFilter`` (gh-821) +- Fixed `make format` so it works by using tox (gh-859) +- Fixed bug where latest() is not idempotent for identical ``history_date`` records (gh-861) +- Support ``included_fields`` for ``history.diff_against`` (gh-776) +- Improve performance of ``history.diff_against`` by reducing number of queries to 0 in most cases (gh-776) +- Added Czech translations (gh-885) +- Added pre-commit for better commit quality (gh-896) +- Added ability to break into debugger on unit test failure (gh-890) + +3.0.0 (2021-04-16) +------------------ + +Breaking changes: + +- Removed support for Django 3.0 +- Removed `changeReason` in favor of `_change_reason` (see 2.10.0) + +Full list of changes: + - Removed support for Django versions prior to 2.2 (gh-652) - Migrate from TravisCI to Github Actions (gh-739) - Add Python 3.9 support (gh-745) @@ -10,6 +41,11 @@ Unreleased - Use ``asgiref`` when available instead of thread locals (gh-747) - Sort imports with isort (gh-751) - Queryset ``history.as_of`` speed improvements by calculating in the DB (gh-758) +- Increase `black` and `isort` python version to 3.6 (gh-817) +- Remove Django 3.0 support (gh-817) +- Add Django 3.2 support (gh-817) +- Improve French translations (gh-811) +- Remove support for changeReason (gh-819) 2.12.0 (2020-10-14) ------------------- diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6855cd913..e0d5efab5 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,46 @@ -# Contributor Covenant Code of Conduct +# Code of Conduct -## Our Pledge +As contributors and maintainers of the Jazzband projects, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. -In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +We are committed to making participation in the Jazzband a harassment-free experience +for everyone, regardless of the level of experience, gender, gender identity and +expression, sexual orientation, disability, personal appearance, body size, race, +ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ross@cadre.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ +- The use of sexualized language or imagery +- Personal attacks +- Trolling or insulting/derogatory comments +- Public or private harassment +- Publishing other's private information, such as physical or electronic addresses, + without explicit permission +- Other unethical or unprofessional conduct + +The Jazzband roadies have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are not +aligned to this Code of Conduct, or to ban temporarily or permanently any contributor +for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, the roadies commit themselves to fairly and +consistently applying these principles to every aspect of managing the jazzband +projects. Roadies who do not follow or enforce the Code of Conduct may be permanently +removed from the Jazzband roadies. + +This code of conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and appropriate to +the circumstances. Roadies are obligated to maintain confidentiality with regard to the +reporter of an incident. + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version +1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ecf976c88..1c0c2a248 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -38,15 +38,13 @@ running tests:: This will install: - `tox`_: used for running the tests against all supported versions of Django - and Python + and Python as well as running tasks like lint, format, docs - `coverage`_: used for analyzing test coverage for tests -- `Sphinx`_: used for generating documentation If not using a virtualenv, the command should be prepended with ``sudo``. .. _tox: http://testrun.org/tox/latest// .. _coverage: http://nedbatchelder.com/code/coverage/ -.. _sphinx: http://sphinx-doc.org/ Documentation ------------- @@ -77,9 +75,13 @@ We make use of `black`_ for code formatting. .. _black: https://black.readthedocs.io/en/stable/installation_and_usage.html -Once it is installed you can make sure the code is properly formatted by running:: +You can install and run it along with other linters through pre-commit: - make format + pre-commit install + pre-commit run + +Once you install pre-commit it will sanity check any commit you make. +Additionally, the CI process runs this check as well. Translations ------------ diff --git a/Makefile b/Makefile index dcd7c9025..04fc170e7 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ clean-pyc: find . -name '*~' -exec rm -f {} + init: - pip install "tox>=1.8" coverage Sphinx + pip install "tox>=1.8" coverage test: coverage erase @@ -24,7 +24,7 @@ test: docs: documentation documentation: - sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html + tox -e docs dist: clean pip install -U wheel @@ -44,5 +44,4 @@ release: dist twine upload dist/* format: - isort docs simple_history runtests.py setup.py - black docs simple_history runtests.py setup.py + tox -e format diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index 94724990b..b18a4653e 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -28,7 +28,7 @@ ## Checklist: -- [ ] I have run the `make format` command to format my code +- [ ] I have run the `pre-commit run` command to format and lint. - [ ] My change requires a change to the documentation. - [ ] I have updated the documentation accordingly. - [ ] I have read the **CONTRIBUTING** document. diff --git a/README.rst b/README.rst index 3da4eabf2..cd057d747 100644 --- a/README.rst +++ b/README.rst @@ -41,9 +41,9 @@ This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= -2.2 3.5, 3.6, 3.7, 3.8, 3.9 -3.0 3.6, 3.7, 3.8, 3.9 +2.2 3.6, 3.7, 3.8, 3.9 3.1 3.6, 3.7, 3.8, 3.9 +3.2 3.6, 3.7, 3.8, 3.9 ========== ======================= Getting Help diff --git a/codecov.yml b/codecov.yml index 434bb0f51..856338872 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,4 @@ +--- coverage: status: patch: diff --git a/docs/common_issues.rst b/docs/common_issues.rst index 7eac8eb8d..46101b7cc 100644 --- a/docs/common_issues.rst +++ b/docs/common_issues.rst @@ -254,3 +254,25 @@ Working with BitBucket Pipelines When using BitBucket Pipelines to test your Django project with the django-simple-history middleware, you will run into an error relating to missing migrations relating to the historic User model from the auth app. This is because the migration file is not held within either your project or django-simple-history. In order to pypass the error you need to add a ```python manage.py makemigrations auth``` step into your YML file prior to running the tests. + + +Using custom OneToOneFields +--------------------------- + +If you are using a custom OneToOneField that has additional arguments and receiving +the the following ``TypeError``:: + +.. + TypeError: __init__() got an unexpected keyword argument + +This is because Django Simple History coerces ``OneToOneField`` into ``ForeignKey`` +on the historical model. You can work around this by excluded those additional +arguments using ``excluded_field_kwargs`` as follows: + +.. code-block:: python + + class Poll(models.Model): + organizer = CustomOneToOneField(Organizer, ..., custom_argument="some_value") + history = HistoricalRecords( + excluded_field_kwargs={"organizer": set(["custom_argument"])} + ) diff --git a/docs/conf.py b/docs/conf.py index 223bfa0a5..dd0eee686 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,6 @@ # # All configuration values have a default; values that are commented out # serve to show the default. - import os import sys @@ -176,11 +175,11 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'preamble': '', + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples diff --git a/docs/historical_model.rst b/docs/historical_model.rst index 72d3a3300..f46ea1f32 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -26,7 +26,7 @@ The example below uses a ``UUIDField`` instead of an ``AutoField``: Since using a ``UUIDField`` for the ``history_id`` is a common use case, there is a -``SIMPLE_HISTORY_HISTORY_ID_USE_UUID`` setting that will set all ``history_id``s to UUIDs. +``SIMPLE_HISTORY_HISTORY_ID_USE_UUID`` setting that will set all instances of ``history_id`` to UUIDs. Set this with the following line in your ``settings.py`` file: @@ -86,6 +86,26 @@ model, will work too. my_poll.save() +Indexed ``history_date`` +------------------------ + +Many queries use ``history_date`` as a filter. The as_of queries combine this with the +original model's promary key to extract point-in-time snapshots of history. By default +the ``history_date`` field is indexed. You can control this behavior using settings.py. + +.. code-block:: python + + # disable indexing on history_date + SIMPLE_HISTORY_DATE_INDEX = False + + # enable indexing on history_date (default setting) + SIMPLE_HISTORY_DATE_INDEX = True + + # enable composite indexing on history_date and model pk (to improve as_of queries) + # the string is case-insensitive + SIMPLE_HISTORY_DATE_INDEX = "Composite" + + Custom history table name ------------------------- diff --git a/docs/history_diffing.rst b/docs/history_diffing.rst index 2b1fc730e..3e409068c 100644 --- a/docs/history_diffing.rst +++ b/docs/history_diffing.rst @@ -20,3 +20,5 @@ This may be useful when you want to construct timelines and need to get only the delta = new_record.diff_against(old_record) for change in delta.changes: print("{} changed from {} to {}".format(change.field, change.old, change.new)) + +``diff_against`` also accepts 2 arguments ``excluded_fields`` and ``included_fields`` to either explicitly include or exclude fields from being diffed. diff --git a/docs/index.rst b/docs/index.rst index b6608e7b0..5e79e3216 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,8 +41,9 @@ This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= -2.2 3.5, 3.6, 3.7, 3.8, 3.9 -3.0 3.6, 3.7, 3.8, 3.9 +2.2 3.6, 3.7, 3.8, 3.9 +3.1 3.6, 3.7, 3.8, 3.9 +3.2 3.6, 3.7, 3.8, 3.9 ========== ======================= Contribute diff --git a/docs/querying_history.rst b/docs/querying_history.rst index 011c380c0..aa1798eff 100644 --- a/docs/querying_history.rst +++ b/docs/querying_history.rst @@ -145,6 +145,12 @@ If you want to save a model without a historical record, you can use the followi poll = Poll(question='something') poll.save_without_historical_record() +Or disable history records for all models by putting following lines in your ``settings.py`` file: + +.. code-block:: python + + SIMPLE_HISTORY_ENABLED = False + Filtering data using a relationship to the model ------------------------------------------------ diff --git a/docs/quick_start.rst b/docs/quick_start.rst index 8e268d278..ffdb84bfc 100644 --- a/docs/quick_start.rst +++ b/docs/quick_start.rst @@ -150,7 +150,7 @@ the fields in the instance of the base model, as well as other metadata: - ``history_user``: the user that made the create/update/delete - ``history_date``: the datetime at which the create/update/delete occurred - ``history_change_reason``: the reason the create/update/delete occurred (null by default) -- ``history_id``: the primary key for the historical table (note the the base table's +- ``history_id``: the primary key for the historical table (note the base table's primary key is not unique on the historical table since there are multiple versions of it on the historical table) - ``history_type``: ``+`` for create, ``~`` for update, and ``-`` for delete diff --git a/docs/utils.rst b/docs/utils.rst index 4b4217326..1c7bd429a 100644 --- a/docs/utils.rst +++ b/docs/utils.rst @@ -34,7 +34,7 @@ from the duplicate check clean_old_history ----------------------- -You may want to remove historical records that have existed for a certain amount of time. +You may want to remove historical records that have existed for a certain amount of time. If you find yourself with a lot of old history you can schedule the ``clean_old_history`` command @@ -43,9 +43,9 @@ If you find yourself with a lot of old history you can schedule the $ python manage.py clean_old_history --auto -You can use ``--auto`` to remove old historial entries +You can use ``--auto`` to remove old historial entries with ``HistoricalRecords`` or enumerate specific models as args. -You may also specify a ``--days`` parameter, which indicates how many +You may also specify a ``--days`` parameter, which indicates how many days of records you want to keep. The default it 30 days, meaning that all records older than 30 days would be removed. diff --git a/pyproject.toml b/pyproject.toml index 29ae6d2b9..01baab5d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [tool.black] line-length = 88 -target-version = ["py35"] +target-version = ["py36"] [tool.isort] profile = "black" -py_version = "35" +py_version = "36" [tool.coverage.run] parallel = true diff --git a/requirements/coverage.txt b/requirements/coverage.txt index 7fa8e3f17..d6047fbc6 100644 --- a/requirements/coverage.txt +++ b/requirements/coverage.txt @@ -1,2 +1,2 @@ -coverage==5.4 +coverage==6.0.2 toml==0.10.2 diff --git a/requirements/docs.txt b/requirements/docs.txt index 345d135f3..94c0e1037 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1 +1 @@ -Sphinx==3.4.3 +Sphinx==4.2.0 diff --git a/requirements/lint.txt b/requirements/lint.txt index 68e888934..feff553e0 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -1,3 +1,3 @@ -black==20.8.b1 -flake8==3.8.4 -isort==5.7.0 +black==21.9b0 +flake8==4.0.1 +isort==5.9.3 diff --git a/requirements/mysql.txt b/requirements/mysql.txt index 5f213e9a4..fbbfdfeae 100644 --- a/requirements/mysql.txt +++ b/requirements/mysql.txt @@ -1 +1 @@ -mysqlclient==2.0.3 \ No newline at end of file +mysqlclient==2.0.3 diff --git a/requirements/postgres.txt b/requirements/postgres.txt index 4ae932dc8..be478bcc3 100644 --- a/requirements/postgres.txt +++ b/requirements/postgres.txt @@ -1 +1 @@ -psycopg2-binary==2.8.6 \ No newline at end of file +psycopg2-binary==2.9.1 diff --git a/requirements/tox.txt b/requirements/tox.txt index ca74336e1..01b6ba49b 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -1,3 +1,3 @@ -r ./coverage.txt -tox==3.21.4 -tox-gh-actions==2.4.0 +tox==3.24.4 +tox-gh-actions==2.8.1 diff --git a/runtests.py b/runtests.py index 3a304018f..dbed2a0a1 100755 --- a/runtests.py +++ b/runtests.py @@ -99,7 +99,7 @@ def __getitem__(self, item): } -DEFAULT_SETTINGS = dict( +DEFAULT_SETTINGS = dict( # nosec SECRET_KEY="not a secret", ALLOWED_HOSTS=["localhost"], AUTH_USER_MODEL="custom_user.CustomUser", @@ -147,8 +147,10 @@ def __getitem__(self, item): def main(): parser = ArgumentParser(description="Run package tests.") - parser.add_argument("--tag", action="append", nargs="?") parser.add_argument("--database", action="store", nargs="?", default="sqlite3") + parser.add_argument("--failfast", action="store_true") + parser.add_argument("--pdb", action="store_true") + parser.add_argument("--tag", action="append", nargs="?") namespace = parser.parse_args() db_settings = DATABASE_NAME_TO_DATABASE_SETTINGS[namespace.database] if not settings.configured: @@ -157,12 +159,12 @@ def main(): django.setup() tags = namespace.tag - failures = DiscoverRunner(failfast=False, tags=tags).run_tests( - ["simple_history.tests"] - ) - failures |= DiscoverRunner(failfast=False, tags=tags).run_tests( - ["simple_history.registry_tests"] - ) + failures = DiscoverRunner( + failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags + ).run_tests(["simple_history.tests"]) + failures |= DiscoverRunner( + failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags + ).run_tests(["simple_history.registry_tests"]) sys.exit(failures) diff --git a/setup.py b/setup.py index 29164d996..c4d4c7d8f 100644 --- a/setup.py +++ b/setup.py @@ -24,8 +24,8 @@ "Intended Audience :: Developers", "Framework :: Django", "Framework :: Django :: 2.2", - "Framework :: Django :: 3.0", "Framework :: Django :: 3.1", + "Framework :: Django :: 3.2", "Programming Language :: Python", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", diff --git a/simple_history/__init__.py b/simple_history/__init__.py old mode 100755 new mode 100644 index b99d87238..e3e1ca7d0 --- a/simple_history/__init__.py +++ b/simple_history/__init__.py @@ -13,7 +13,7 @@ def register( manager_name="history", records_class=None, table_name=None, - **records_config + **records_config, ): """ Create historical model for `model` and attach history manager to `model`. diff --git a/simple_history/locale/cs_CZ/LC_MESSAGES/django.mo b/simple_history/locale/cs_CZ/LC_MESSAGES/django.mo new file mode 100644 index 000000000..2f6a5fb28 Binary files /dev/null and b/simple_history/locale/cs_CZ/LC_MESSAGES/django.mo differ diff --git a/simple_history/locale/cs_CZ/LC_MESSAGES/django.po b/simple_history/locale/cs_CZ/LC_MESSAGES/django.po new file mode 100644 index 000000000..c67a760e9 --- /dev/null +++ b/simple_history/locale/cs_CZ/LC_MESSAGES/django.po @@ -0,0 +1,134 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-09-18 21:54+0200\n" +"PO-Revision-Date: 2021-09-20 19:50+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: cs\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n " +"<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n" + +#: simple_history/admin.py:102 +#, python-format +msgid "View history: %s" +msgstr "Zobrazit historii: %s" + +#: simple_history/admin.py:104 +#, python-format +msgid "Change history: %s" +msgstr "Historie změn: %s" + +#: simple_history/admin.py:110 +#, python-format +msgid "The %(name)s \"%(obj)s\" was changed successfully." +msgstr "%(name)s \"%(obj)s\" bylo úspěšně změněno." + +#: simple_history/admin.py:116 +msgid "You may edit it again below" +msgstr "Níže jej můžete znovu upravit" + +#: simple_history/admin.py:216 +#, python-format +msgid "View %s" +msgstr "Zobrazit %s" + +#: simple_history/admin.py:218 +#, python-format +msgid "Revert %s" +msgstr "Vrátit změny: %s" + +#: simple_history/models.py:433 +msgid "Created" +msgstr "Vytvořeno" + +#: simple_history/models.py:433 +msgid "Changed" +msgstr "Změněno" + +#: simple_history/models.py:433 +msgid "Deleted" +msgstr "Smazáno" + +#: simple_history/templates/simple_history/_object_history_list.html:9 +msgid "Object" +msgstr "Objekt" + +#: simple_history/templates/simple_history/_object_history_list.html:13 +msgid "Date/time" +msgstr "Datum/čas" + +#: simple_history/templates/simple_history/_object_history_list.html:14 +msgid "Comment" +msgstr "Komentář" + +#: simple_history/templates/simple_history/_object_history_list.html:15 +msgid "Changed by" +msgstr "Změnil" + +#: simple_history/templates/simple_history/_object_history_list.html:16 +msgid "Change reason" +msgstr "Důvod změny" + +#: simple_history/templates/simple_history/_object_history_list.html:37 +msgid "None" +msgstr "Žádné" + +#: simple_history/templates/simple_history/object_history.html:11 +msgid "" +"Choose a date from the list below to revert to a previous version of this " +"object." +msgstr "Vyberte datum ze seznamu níže a vraťte se k předchozí verzi tohoto " +"objektu." + +#: simple_history/templates/simple_history/object_history.html:16 +msgid "This object doesn't have a change history." +msgstr "Tento objekt nemá historii změn." + +#: simple_history/templates/simple_history/object_history_form.html:7 +msgid "Home" +msgstr "Domů" + +#: simple_history/templates/simple_history/object_history_form.html:11 +msgid "History" +msgstr "Historie" + +#: simple_history/templates/simple_history/object_history_form.html:12 +#, python-format +msgid "View %(verbose_name)s" +msgstr "Zobrazit %(verbose_name)s" + +#: simple_history/templates/simple_history/object_history_form.html:12 +#, python-format +msgid "Revert %(verbose_name)s" +msgstr "Vrátit %(verbose_name)s" + +#: simple_history/templates/simple_history/object_history_form.html:25 +msgid "" +"Press the 'Revert' button below to revert to this version of the object. " +msgstr "Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objektu." + +#: simple_history/templates/simple_history/object_history_form.html:25 +msgid "Press the 'Change History' button below to edit the history." +msgstr "Chcete-li historii upravit, stiskněte tlačítko 'Změnit historii'" + +#: simple_history/templates/simple_history/submit_line.html:4 +msgid "Revert" +msgstr "Vrátit změny" + +#: simple_history/templates/simple_history/submit_line.html:6 +msgid "Change History" +msgstr "Historie změn" + +#: simple_history/templates/simple_history/submit_line.html:7 +msgid "Close" +msgstr "Zavřít" diff --git a/simple_history/locale/fr/LC_MESSAGES/django.mo b/simple_history/locale/fr/LC_MESSAGES/django.mo index fa9e4f4bb..6c64cb387 100644 Binary files a/simple_history/locale/fr/LC_MESSAGES/django.mo and b/simple_history/locale/fr/LC_MESSAGES/django.mo differ diff --git a/simple_history/locale/fr/LC_MESSAGES/django.po b/simple_history/locale/fr/LC_MESSAGES/django.po index 959d21e90..f21b319eb 100644 --- a/simple_history/locale/fr/LC_MESSAGES/django.po +++ b/simple_history/locale/fr/LC_MESSAGES/django.po @@ -7,132 +7,132 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-04-25 22:00+0200\n" +"POT-Creation-Date: 2021-04-04 01:50+0300\n" "PO-Revision-Date: 2020-04-25 22:50+0200\n" +"Last-Translator: \n" +"Language-Team: \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"Last-Translator: \n" -"Language-Team: \n" "X-Generator: Poedit 2.3\n" -#: simple_history/admin.py:111 +#: .\simple_history\admin.py:102 #, python-format msgid "View history: %s" msgstr "Historique de vue: %s" -#: simple_history/admin.py:113 +#: .\simple_history\admin.py:104 #, python-format msgid "Change history: %s" msgstr "Historique de changement: %s" -#: simple_history/admin.py:119 +#: .\simple_history\admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "L'objet \"%(obj)s\" %(name)s a été changé avec succès." -#: simple_history/admin.py:125 +#: .\simple_history\admin.py:116 msgid "You may edit it again below" msgstr "Vous pouvez le modifier à nouveau ci-dessous" -#: simple_history/admin.py:225 +#: .\simple_history\admin.py:216 #, python-format msgid "View %s" msgstr "Voir %s" -#: simple_history/admin.py:227 +#: .\simple_history\admin.py:218 #, python-format msgid "Revert %s" -msgstr "Annuler %s" +msgstr "Rétablir %s" -#: simple_history/models.py:427 +#: .\simple_history\models.py:433 msgid "Created" msgstr "Créé" -#: simple_history/models.py:427 +#: .\simple_history\models.py:433 msgid "Changed" msgstr "Modifié" -#: simple_history/models.py:427 +#: .\simple_history\models.py:433 msgid "Deleted" msgstr "Effacé" -#: simple_history/templates/simple_history/_object_history_list.html:9 +#: .\simple_history\templates\simple_history\_object_history_list.html:9 msgid "Object" msgstr "Objet" -#: simple_history/templates/simple_history/_object_history_list.html:13 +#: .\simple_history\templates\simple_history\_object_history_list.html:13 msgid "Date/time" msgstr "Date/heure" -#: simple_history/templates/simple_history/_object_history_list.html:14 +#: .\simple_history\templates\simple_history\_object_history_list.html:14 msgid "Comment" msgstr "Commentaire" -#: simple_history/templates/simple_history/_object_history_list.html:15 +#: .\simple_history\templates\simple_history\_object_history_list.html:15 msgid "Changed by" msgstr "Modifié par" -#: simple_history/templates/simple_history/_object_history_list.html:16 +#: .\simple_history\templates\simple_history\_object_history_list.html:16 msgid "Change reason" msgstr "Raison de la modification" -#: simple_history/templates/simple_history/_object_history_list.html:37 +#: .\simple_history\templates\simple_history\_object_history_list.html:37 msgid "None" msgstr "Aucun" -#: simple_history/templates/simple_history/object_history.html:11 +#: .\simple_history\templates\simple_history\object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" -"Choisissez une date dans le liste ci-dessous pour revenir à une version " -"précédante de cet objet." +"Choisissez une date dans la liste ci-dessous pour revenir à une version " +"précédente de cet objet." -#: simple_history/templates/simple_history/object_history.html:16 +#: .\simple_history\templates\simple_history\object_history.html:16 msgid "This object doesn't have a change history." msgstr "Cet objet n'a pas d'historique." -#: simple_history/templates/simple_history/object_history_form.html:7 +#: .\simple_history\templates\simple_history\object_history_form.html:7 msgid "Home" msgstr "Accueil" -#: simple_history/templates/simple_history/object_history_form.html:11 +#: .\simple_history\templates\simple_history\object_history_form.html:11 msgid "History" msgstr "Historique" -#: simple_history/templates/simple_history/object_history_form.html:12 +#: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Voir %(verbose_name)s" -#: simple_history/templates/simple_history/object_history_form.html:12 +#: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" -msgstr "Annuler %(verbose_name)s" +msgstr "Rétablir %(verbose_name)s" -#: simple_history/templates/simple_history/object_history_form.html:21 +#: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object. " msgstr "" -"Cliquez sur le bouton 'Annuler' ci-dessous pour revenir à la version de " -"cet objet. " +"Cliquez sur le bouton 'Rétablir' ci-dessous pour revenir à cette version de " +"l' objet. " -#: simple_history/templates/simple_history/object_history_form.html:21 +#: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "Press the 'Change History' button below to edit the history." msgstr "" "Cliquez sur le bouton 'Historique' ci-dessous pour modifier l'historique." -#: simple_history/templates/simple_history/submit_line.html:4 +#: .\simple_history\templates\simple_history\submit_line.html:4 msgid "Revert" -msgstr "Annuler" +msgstr "Rétablir" -#: simple_history/templates/simple_history/submit_line.html:6 +#: .\simple_history\templates\simple_history\submit_line.html:6 msgid "Change History" -msgstr "Historique de changement" +msgstr "Historique des changements" -#: simple_history/templates/simple_history/submit_line.html:7 +#: .\simple_history\templates\simple_history\submit_line.html:7 msgid "Close" msgstr "Fermer" diff --git a/simple_history/manager.py b/simple_history/manager.py old mode 100755 new mode 100644 index f720a3c87..5e52414fc --- a/simple_history/manager.py +++ b/simple_history/manager.py @@ -1,8 +1,12 @@ +from django.conf import settings from django.db import connection, models from django.db.models import OuterRef, Subquery from django.utils import timezone -from simple_history.utils import get_change_reason_from_object +from simple_history.utils import ( + get_app_model_primary_key_name, + get_change_reason_from_object, +) class HistoryDescriptor: @@ -29,10 +33,7 @@ def get_queryset(self): if self.instance is None: return qs - if isinstance(self.instance._meta.pk, models.ForeignKey): - key_name = self.instance._meta.pk.name + "_id" - else: - key_name = self.instance._meta.pk.name + key_name = get_app_model_primary_key_name(self.instance) return self.get_super_queryset().filter(**{key_name: self.instance.pk}) def most_recent(self): @@ -137,6 +138,8 @@ def bulk_history_create( If called by bulk_update_with_history, use the update boolean and save the history_type accordingly. """ + if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): + return history_type = "+" if update: diff --git a/simple_history/models.py b/simple_history/models.py index 6f0aba37e..a67f27536 100644 --- a/simple_history/models.py +++ b/simple_history/models.py @@ -7,9 +7,9 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.db import models -from django.db.models import ManyToManyField, Q +from django.db.models import ManyToManyField from django.db.models.fields.proxy import OrderWrt from django.forms.models import model_to_dict from django.urls import reverse @@ -79,6 +79,7 @@ def __init__( related_name=None, use_base_model_db=False, user_db_constraint=True, + excluded_field_kwargs=None, ): self.user_set_verbose_name = verbose_name self.user_related_name = user_related_name @@ -101,6 +102,10 @@ def __init__( if excluded_fields is None: excluded_fields = [] self.excluded_fields = excluded_fields + + if excluded_field_kwargs is None: + excluded_field_kwargs = {} + self.excluded_field_kwargs = excluded_field_kwargs try: if isinstance(bases, str): raise TypeError @@ -235,6 +240,12 @@ def fields_included(self, model): fields.append(field) return fields + def field_excluded_kwargs(self, field): + """ + Find the excluded kwargs for a given field. + """ + return self.excluded_field_kwargs.get(field.name, set()) + def copy_fields(self, model): """ Creates copies of the model's original fields, returning @@ -262,6 +273,12 @@ def copy_fields(self, model): else: FieldType = type(old_field) + # Remove any excluded kwargs for the field. + # This is useful when a custom OneToOneField is being used that + # has a different set of arguments than ForeignKey + for exclude_arg in self.field_excluded_kwargs(old_field): + field_args.pop(exclude_arg, None) + # If field_args['to'] is 'self' then we have a case where the object # has a foreign key to itself. If we pass the historical record's # field to = 'self', the foreign key will point to an historical @@ -399,24 +416,55 @@ def get_next_record(self): """ Get the next history record for the instance. `None` if last. """ - history = utils.get_history_manager_for_model(self.instance) + history = utils.get_history_manager_from_history(self) return ( - history.filter(Q(history_date__gt=self.history_date)) + history.filter(history_date__gt=self.history_date) .order_by("history_date") .first() ) + def get_next_record_diff(self): + """ + Get the difference between this and the next record. + + Return the diff or `None` of no next record. + """ + + next_record = self.get_next_record() + + if next_record is not None: + return self.diff_against(next_record) + + return None + def get_prev_record(self): """ Get the previous history record for the instance. `None` if first. """ - history = utils.get_history_manager_for_model(self.instance) + history = utils.get_history_manager_from_history(self) return ( - history.filter(Q(history_date__lt=self.history_date)) + history.filter(history_date__lt=self.history_date) .order_by("history_date") .last() ) + def get_prev_record_diff(self): + """ + Get the difference between this and the previous record. + + The diff should be based on the previous record, + since the diff uses `old_record` and `new_record` semantics. + + Return the diff or `None` if no previous record. + """ + + previous_record = self.get_prev_record() + + if previous_record is not None: + return previous_record.diff_against(self) + + return None + def get_default_history_user(instance): """ Returns the user specified by `get_user` method for manually creating @@ -426,7 +474,7 @@ def get_default_history_user(instance): extra_fields = { "history_id": self._get_history_id_field(), - "history_date": models.DateTimeField(), + "history_date": models.DateTimeField(db_index=self._date_indexing is True), "history_change_reason": self._get_history_change_reason_field(), "history_type": models.CharField( max_length=1, @@ -438,7 +486,9 @@ def get_default_history_user(instance): "instance": property(get_instance), "instance_type": model, "next_record": property(get_next_record), + "next_record_diff": property(get_next_record_diff), "prev_record": property(get_prev_record), + "prev_record_diff": property(get_prev_record_diff), "revert_url": revert_url, "__str__": lambda self: "{} as of {}".format( self.history_object, self.history_date @@ -451,6 +501,23 @@ def get_default_history_user(instance): return extra_fields + @property + def _date_indexing(self): + """False, True, or 'composite'; default is True""" + result = getattr(settings, "SIMPLE_HISTORY_DATE_INDEX", True) + valid = True + if isinstance(result, str): + result = result.lower() + if result not in ("composite",): + valid = False + elif not isinstance(result, bool): + valid = False + if not valid: + raise ImproperlyConfigured( + "SIMPLE_HISTORY_DATE_INDEX must be one of (False, True, 'Composite')" + ) + return result + def get_meta_options(self, model): """ Returns a dictionary of fields that will be added to @@ -458,7 +525,7 @@ def get_meta_options(self, model): """ meta_fields = { "ordering": ("-history_date", "-history_id"), - "get_latest_by": "history_date", + "get_latest_by": ("history_date", "history_id"), } if self.user_set_verbose_name: name = self.user_set_verbose_name @@ -467,15 +534,21 @@ def get_meta_options(self, model): meta_fields["verbose_name"] = name if self.app: meta_fields["app_label"] = self.app + if self._date_indexing == "composite": + meta_fields["index_together"] = (("history_date", model._meta.pk.attname),) return meta_fields def post_save(self, instance, created, using=None, **kwargs): + if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): + return if not created and hasattr(instance, "skip_history_when_saving"): return if not kwargs.get("raw", False): self.create_historical_record(instance, created and "+" or "~", using=using) def post_delete(self, instance, using=None, **kwargs): + if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): + return if self.cascade_delete_history: manager = getattr(instance, self.manager_name) manager.using(using).all().delete() @@ -579,12 +652,14 @@ def __init__(self, model, fields_included): self.fields_included = fields_included def __get__(self, instance, owner): + if instance is None: + return self values = {f.attname: getattr(instance, f.attname) for f in self.fields_included} return self.model(**values) class HistoricalChanges: - def diff_against(self, old_history, excluded_fields=None): + def diff_against(self, old_history, excluded_fields=None, included_fields=None): if not isinstance(old_history, type(self)): raise TypeError( ("unsupported type(s) for diffing: " "'{}' and '{}'").format( @@ -592,20 +667,26 @@ def diff_against(self, old_history, excluded_fields=None): ) ) if excluded_fields is None: - excluded_fields = [] + excluded_fields = set() + + if included_fields is None: + included_fields = {f.name for f in old_history.instance_type._meta.fields} + + fields = set(included_fields).difference(excluded_fields) + changes = [] changed_fields = [] - old_values = model_to_dict(old_history.instance) - current_values = model_to_dict(self.instance) - for field, new_value in current_values.items(): - if field in excluded_fields: - continue - if field in old_values: - old_value = old_values[field] - if old_value != new_value: - change = ModelChange(field, old_value, new_value) - changes.append(change) - changed_fields.append(field) + + old_values = model_to_dict(old_history, fields=fields) + current_values = model_to_dict(self, fields=fields) + + for field in fields: + old_value = old_values[field] + current_value = current_values[field] + + if old_value != current_value: + changes.append(ModelChange(field, old_value, current_value)) + changed_fields.append(field) return ModelDelta(changes, changed_fields, old_history, self) diff --git a/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py b/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py new file mode 100644 index 000000000..0f40470bc --- /dev/null +++ b/simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.0.dev20210802171549 on 2021-08-11 11:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "migration_test_app", + "0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey", + ), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalmodelwithcustomattrforeignkey", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical model with custom attr foreign key", + }, + ), + migrations.AlterModelOptions( + name="historicalyar", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical yar", + }, + ), + ] diff --git a/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py b/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py new file mode 100644 index 000000000..fbe0f04d0 --- /dev/null +++ b/simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py @@ -0,0 +1,26 @@ +# Generated by Django 4.0.dev20210811195242 on 2021-08-13 10:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "migration_test_app", + "0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="historicalmodelwithcustomattrforeignkey", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name="historicalyar", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py b/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py new file mode 100644 index 000000000..85859b1d9 --- /dev/null +++ b/simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py @@ -0,0 +1,91 @@ +# Generated by Django 3.2.6 on 2021-08-24 10:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import simple_history.models +import simple_history.registry_tests.migration_test_app.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ( + "migration_test_app", + "0004_history_date_indexing", + ), + ] + + operations = [ + migrations.CreateModel( + name="ModelWithCustomAttrOneToOneField", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "what_i_mean", + simple_history.registry_tests.migration_test_app.models.CustomAttrNameOneToOneField( + attr_name="custom_attr_name", + on_delete=django.db.models.deletion.CASCADE, + to="migration_test_app.whatimean", + ), + ), + ], + ), + migrations.CreateModel( + name="HistoricalModelWithCustomAttrOneToOneField", + fields=[ + ( + "id", + models.IntegerField( + auto_created=True, blank=True, db_index=True, verbose_name="ID" + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField( + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], + max_length=1, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "what_i_mean", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="migration_test_app.whatimean", + ), + ), + ], + options={ + "verbose_name": "historical model with custom attr one to one field", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py b/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py new file mode 100644 index 000000000..5c16f6ab4 --- /dev/null +++ b/simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.dev20211006030854 on 2021-10-07 13:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "migration_test_app", + "0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield", + ), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicalmodelwithcustomattronetoonefield", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical model with custom attr one to one field", + }, + ), + migrations.AlterField( + model_name="historicalmodelwithcustomattronetoonefield", + name="history_date", + field=models.DateTimeField(db_index=True), + ), + ] diff --git a/simple_history/registry_tests/migration_test_app/models.py b/simple_history/registry_tests/migration_test_app/models.py index ac726404f..8d93a83ef 100644 --- a/simple_history/registry_tests/migration_test_app/models.py +++ b/simple_history/registry_tests/migration_test_app/models.py @@ -36,3 +36,29 @@ class ModelWithCustomAttrForeignKey(models.Model): WhatIMean, models.CASCADE, attr_name="custom_attr_name" ) history = HistoricalRecords() + + +class CustomAttrNameOneToOneField(models.OneToOneField): + def __init__(self, *args, **kwargs): + self.attr_name = kwargs.pop("attr_name", None) + super(CustomAttrNameOneToOneField, self).__init__(*args, **kwargs) + + def get_attname(self): + return self.attr_name or super(CustomAttrNameOneToOneField, self).get_attname() + + def deconstruct(self): + name, path, args, kwargs = super( + CustomAttrNameOneToOneField, self + ).deconstruct() + if self.attr_name: + kwargs["attr_name"] = self.attr_name + return name, path, args, kwargs + + +class ModelWithCustomAttrOneToOneField(models.Model): + what_i_mean = CustomAttrNameOneToOneField( + WhatIMean, models.CASCADE, attr_name="custom_attr_name" + ) + history = HistoricalRecords( + excluded_field_kwargs={"what_i_mean": set(["attr_name"])} + ) diff --git a/simple_history/registry_tests/tests.py b/simple_history/registry_tests/tests.py index cfa9d2769..ba8bd3ac5 100644 --- a/simple_history/registry_tests/tests.py +++ b/simple_history/registry_tests/tests.py @@ -17,6 +17,7 @@ InheritTracking3, InheritTracking4, ModelWithCustomAttrForeignKey, + ModelWithCustomAttrOneToOneField, ModelWithHistoryInDifferentApp, Poll, Restaurant, @@ -50,7 +51,7 @@ def get_history(model): self.assertRaises(AttributeError, get_history, User) self.assertEqual(len(User.histories.all()), 0) - user = User.objects.create(username="bob", password="pass") + user = User.objects.create(username="bob", password="pass") # nosec self.assertEqual(len(User.histories.all()), 1) self.assertEqual(len(user.histories.all()), 1) @@ -79,11 +80,11 @@ def test_register_history_id_field(self): class TestUserAccessor(unittest.TestCase): def test_accessor_default(self): register(UserAccessorDefault) - assert not hasattr(User, "historicaluseraccessordefault_set") + self.assertFalse(hasattr(User, "historicaluseraccessordefault_set")) def test_accessor_override(self): register(UserAccessorOverride, user_related_name="my_history_model_accessor") - assert hasattr(User, "my_history_model_accessor") + self.assertTrue(hasattr(User, "my_history_model_accessor")) class TestInheritedModule(TestCase): @@ -198,13 +199,21 @@ def test_registering_with_tracked_abstract_base(self): class TestCustomAttrForeignKey(TestCase): - """ https://github.com/jazzband/django-simple-history/issues/431 """ + """https://github.com/jazzband/django-simple-history/issues/431""" def test_custom_attr(self): field = ModelWithCustomAttrForeignKey.history.model._meta.get_field("poll") self.assertEqual(field.attr_name, "custom_poll") +class TestCustomAttrOneToOneField(TestCase): + """https://github.com/jazzband/django-simple-history/issues/870""" + + def test_custom_attr(self): + field = ModelWithCustomAttrOneToOneField.history.model._meta.get_field("poll") + self.assertFalse(hasattr(field, "attr_name")) + + @override_settings(MIGRATION_MODULES={}) class TestMigrate(TestCase): def test_makemigration_command(self): @@ -219,7 +228,7 @@ def test_migrate_command(self): class TestModelWithHistoryInDifferentApp(TestCase): - """ https://github.com/jazzband/django-simple-history/issues/485 """ + """https://github.com/jazzband/django-simple-history/issues/485""" def test__different_app(self): appLabel = ModelWithHistoryInDifferentApp.history.model._meta.app_label diff --git a/simple_history/tests/models.py b/simple_history/tests/models.py index 640c27d78..993ee7a76 100644 --- a/simple_history/tests/models.py +++ b/simple_history/tests/models.py @@ -119,6 +119,28 @@ class ModelWithCustomAttrForeignKey(models.Model): history = HistoricalRecords() +class CustomAttrNameOneToOneField(models.OneToOneField): + def __init__(self, *args, **kwargs): + self.attr_name = kwargs.pop("attr_name", None) + super(CustomAttrNameOneToOneField, self).__init__(*args, **kwargs) + + def get_attname(self): + return self.attr_name or super(CustomAttrNameOneToOneField, self).get_attname() + + def deconstruct(self): + name, path, args, kwargs = super( + CustomAttrNameOneToOneField, self + ).deconstruct() + if self.attr_name: + kwargs["attr_name"] = self.attr_name + return name, path, args, kwargs + + +class ModelWithCustomAttrOneToOneField(models.Model): + poll = CustomAttrNameOneToOneField(Poll, models.CASCADE, attr_name="custom_poll") + history = HistoricalRecords(excluded_field_kwargs={"poll": set(["attr_name"])}) + + class Temperature(models.Model): location = models.CharField(max_length=200) temperature = models.IntegerField() @@ -649,9 +671,9 @@ class CustomManagerNameModel(models.Model): log = HistoricalRecords() -""" -Following classes test the "custom_model_name" option -""" +# +# Following classes test the "custom_model_name" option +# class OverrideModelNameAsString(models.Model): diff --git a/simple_history/tests/tests/test_index.py b/simple_history/tests/tests/test_index.py new file mode 100644 index 000000000..093a3ffc5 --- /dev/null +++ b/simple_history/tests/tests/test_index.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.db import models +from django.test import TestCase, override_settings + +from simple_history.models import HistoricalRecords + + +@override_settings(SIMPLE_HISTORY_DATE_INDEX="Composite") +class HistoricalIndexTest(TestCase): + def test_has_composite_index(self): + self.assertEqual(settings.SIMPLE_HISTORY_DATE_INDEX, "Composite") + + class Foo(models.Model): + history = HistoricalRecords() + + self.assertEqual( + ("history_date", "id"), Foo.history.model._meta.index_together[0] + ) diff --git a/simple_history/tests/tests/test_manager.py b/simple_history/tests/tests/test_manager.py index d39ea9eef..f5f594247 100644 --- a/simple_history/tests/tests/test_manager.py +++ b/simple_history/tests/tests/test_manager.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from django.db import IntegrityError -from django.test import TestCase, skipUnlessDBFeature +from django.test import TestCase, skipUnlessDBFeature, override_settings from ..models import Document, Poll @@ -110,6 +110,11 @@ def test_simple_bulk_history_create(self): self.assertEqual(created, []) self.assertEqual(Poll.history.count(), 4) + @override_settings(SIMPLE_HISTORY_ENABLED=False) + def test_simple_bulk_history_create_without_history_enabled(self): + Poll.history.bulk_history_create(self.data) + self.assertEqual(Poll.history.count(), 0) + def test_bulk_history_create_with_change_reason(self): for poll in self.data: poll._change_reason = "reason" diff --git a/simple_history/tests/tests/test_models.py b/simple_history/tests/tests/test_models.py index c386f632e..9da811a98 100644 --- a/simple_history/tests/tests/test_models.py +++ b/simple_history/tests/tests/test_models.py @@ -5,8 +5,9 @@ import django from django.apps import apps +from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.files.base import ContentFile from django.db import IntegrityError, models from django.db.models.fields.proxy import OrderWrt @@ -266,6 +267,15 @@ def test_save_without_historical_record(self): }, ) + @override_settings(SIMPLE_HISTORY_ENABLED=False) + def test_save_with_disabled_history(self): + anthony = Person.objects.create(name="Anthony Gillard") + anthony.name = "something else" + anthony.save() + self.assertEqual(Person.history.count(), 0) + anthony.delete() + self.assertEqual(Person.history.count(), 0) + def test_save_without_historical_record_for_registered_model(self): model = ExternalModelSpecifiedWithAppParam.objects.create( name="registered model" @@ -623,7 +633,8 @@ def test_history_diff_includes_changed_fields(self): p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() - delta = new_record.diff_against(old_record) + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record) expected_change = ModelChange("question", "what's up?", "what's up, man") self.assertEqual(delta.changed_fields, ["question"]) self.assertEqual(delta.old_record, old_record) @@ -635,7 +646,8 @@ def test_history_diff_does_not_include_unchanged_fields(self): p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() - delta = new_record.diff_against(old_record) + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record) self.assertNotIn("pub_date", delta.changed_fields) def test_history_diff_includes_changed_fields_of_base_model(self): @@ -644,7 +656,8 @@ def test_history_diff_includes_changed_fields_of_base_model(self): r.name = "DonnutsKing" r.save() new_record, old_record = r.history.all() - delta = new_record.diff_against(old_record) + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record) expected_change = ModelChange("name", "McDonna", "DonnutsKing") self.assertEqual(delta.changed_fields, ["name"]) self.assertEqual(delta.old_record, old_record) @@ -664,10 +677,38 @@ def test_history_diff_with_excluded_fields(self): p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() - delta = new_record.diff_against(old_record, excluded_fields=("question",)) + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record, excluded_fields=("question",)) + self.assertEqual(delta.changed_fields, []) + self.assertEqual(delta.changes, []) + + def test_history_diff_with_included_fields(self): + p = Poll.objects.create(question="what's up?", pub_date=today) + p.question = "what's up, man?" + p.save() + new_record, old_record = p.history.all() + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record, included_fields=[]) self.assertEqual(delta.changed_fields, []) self.assertEqual(delta.changes, []) + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record, included_fields=["question"]) + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(len(delta.changes), 1) + + def test_history_with_unknown_field(self): + p = Poll.objects.create(question="what's up?", pub_date=today) + p.question = "what's up, man?" + p.save() + new_record, old_record = p.history.all() + with self.assertRaises(KeyError): + with self.assertNumQueries(0): + new_record.diff_against(old_record, included_fields=["unknown_field"]) + + with self.assertNumQueries(0): + new_record.diff_against(old_record, excluded_fields=["unknown_field"]) + class GetPrevRecordAndNextRecordTestCase(TestCase): def assertRecordsMatch(self, record_a, record_b): @@ -690,9 +731,12 @@ def test_get_prev_record(self): third_record = self.poll.history.filter(question="eh?").get() fourth_record = self.poll.history.filter(question="one more?").get() - self.assertRecordsMatch(second_record.prev_record, first_record) - self.assertRecordsMatch(third_record.prev_record, second_record) - self.assertRecordsMatch(fourth_record.prev_record, third_record) + with self.assertNumQueries(1): + self.assertRecordsMatch(second_record.prev_record, first_record) + with self.assertNumQueries(1): + self.assertRecordsMatch(third_record.prev_record, second_record) + with self.assertNumQueries(1): + self.assertRecordsMatch(fourth_record.prev_record, third_record) def test_get_prev_record_none_if_only(self): self.assertEqual(self.poll.history.count(), 1) @@ -705,14 +749,26 @@ def test_get_prev_record_none_if_earliest(self): first_record = self.poll.history.filter(question="what's up?").get() self.assertIsNone(first_record.prev_record) - def get_prev_record_with_custom_manager_name(self): - instance = CustomManagerNameModel(name="Test name 1") - instance.save() + def test_get_prev_record_with_custom_manager_name(self): + instance = CustomManagerNameModel.objects.create(name="Test name 1") instance.name = "Test name 2" - first_record = instance.log.filter(name="Test name").get() + instance.save() + first_record = instance.log.filter(name="Test name 1").get() second_record = instance.log.filter(name="Test name 2").get() - self.assertRecordsMatch(second_record.prev_record, first_record) + self.assertEqual(second_record.prev_record, first_record) + + def test_get_prev_record_with_excluded_field(self): + instance = PollWithExcludeFields.objects.create( + question="what's up?", pub_date=today + ) + instance.question = "ask questions?" + instance.save() + first_record = instance.history.filter(question="what's up?").get() + second_record = instance.history.filter(question="ask questions?").get() + + with self.assertNumQueries(1): + self.assertRecordsMatch(second_record.prev_record, first_record) def test_get_next_record(self): self.poll.question = "ask questions?" @@ -727,9 +783,12 @@ def test_get_next_record(self): fourth_record = self.poll.history.filter(question="one more?").get() self.assertIsNone(fourth_record.next_record) - self.assertRecordsMatch(first_record.next_record, second_record) - self.assertRecordsMatch(second_record.next_record, third_record) - self.assertRecordsMatch(third_record.next_record, fourth_record) + with self.assertNumQueries(1): + self.assertRecordsMatch(first_record.next_record, second_record) + with self.assertNumQueries(1): + self.assertRecordsMatch(second_record.next_record, third_record) + with self.assertNumQueries(1): + self.assertRecordsMatch(third_record.next_record, fourth_record) def test_get_next_record_none_if_only(self): self.assertEqual(self.poll.history.count(), 1) @@ -742,14 +801,82 @@ def test_get_next_record_none_if_most_recent(self): recent_record = self.poll.history.filter(question="ask questions?").get() self.assertIsNone(recent_record.next_record) - def get_next_record_with_custom_manager_name(self): - instance = CustomManagerNameModel(name="Test name 1") - instance.save() + def test_get_next_record_with_custom_manager_name(self): + instance = CustomManagerNameModel.objects.create(name="Test name 1") instance.name = "Test name 2" - first_record = instance.log.filter(name="Test name").get() + instance.save() + first_record = instance.log.filter(name="Test name 1").get() second_record = instance.log.filter(name="Test name 2").get() - self.assertRecordsMatch(first_record.next_record, second_record) + self.assertEqual(first_record.next_record, second_record) + + def test_get_next_record_with_excluded_field(self): + instance = PollWithExcludeFields.objects.create( + question="what's up?", pub_date=today + ) + instance.question = "ask questions?" + instance.save() + first_record = instance.history.filter(question="what's up?").get() + second_record = instance.history.filter(question="ask questions?").get() + + with self.assertNumQueries(1): + self.assertRecordsMatch(first_record.next_record, second_record) + + +class GetPrevRecordAndNextRecordDiffsTestCase(TestCase): + def setUp(self): + self.poll = Poll(question="what's up?", pub_date=today) + self.poll.save() + + def test_get_prev_record_diff(self): + self.poll.question = "ask questions?" + self.poll.save() + + first_record, second_record = self.poll.history.all() + delta = second_record.prev_record_diff() + + expected_change = ModelChange("question", "what's up?", "ask questions?") + + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(delta.old_record, first_record) + self.assertEqual(delta.new_record, second_record) + self.assertEqual(expected_change.field, delta.changes[0].field) + + def test_get_prev_record_diff_none_if_only(self): + self.assertEqual(self.poll.history.count(), 1) + record = self.poll.history.get() + self.assertIsNone(record.prev_record_diff) + + def test_get_prev_record_diff_none_if_earliest(self): + self.poll.question = "ask questions?" + self.poll.save() + first_record = self.poll.history.filter(question="what's up?").get() + self.assertIsNone(first_record.prev_record_diff) + + def test_get_next_record_diff(self): + self.poll.question = "ask questions?" + self.poll.save() + + first_record, second_record = self.poll.history.all() + delta = first_record.next_record_diff() + + expected_change = ModelChange("question", "ask questions?", "what's up?") + self.assertEqual(1, 2) + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(delta.old_record, first_record) + self.assertEqual(delta.new_record, second_record) + self.assertEqual(expected_change.field, delta.changes[0].field) + + def test_get_next_record_diff_none_if_only(self): + self.assertEqual(self.poll.history.count(), 1) + record = self.poll.history.get() + self.assertIsNone(record.next_record_diff) + + def test_get_next_record_diff_none_if_most_recent(self): + self.poll.question = "ask questions?" + self.poll.save() + recent_record = self.poll.history.filter(question="ask questions?").get() + self.assertIsNone(recent_record.next_record_diff) class CreateHistoryModelTests(unittest.TestCase): @@ -977,6 +1104,25 @@ def test_most_recent_nonexistant(self): poll.delete() self.assertRaises(Poll.DoesNotExist, poll.history.most_recent) + def test_date_indexing_options(self): + records = HistoricalRecords() + delattr(settings, "SIMPLE_HISTORY_DATE_INDEX") + self.assertTrue(records._date_indexing) + settings.SIMPLE_HISTORY_DATE_INDEX = False + self.assertFalse(records._date_indexing) + settings.SIMPLE_HISTORY_DATE_INDEX = "Composite" + self.assertEqual(records._date_indexing, "composite") + settings.SIMPLE_HISTORY_DATE_INDEX = "foo" + with self.assertRaises(ImproperlyConfigured): + records._date_indexing + settings.SIMPLE_HISTORY_DATE_INDEX = 42 + with self.assertRaises(ImproperlyConfigured): + records._date_indexing + settings.SIMPLE_HISTORY_DATE_INDEX = None + with self.assertRaises(ImproperlyConfigured): + records._date_indexing + delattr(settings, "SIMPLE_HISTORY_DATE_INDEX") + def test_as_of(self): poll = Poll.objects.create(question="what's up?", pub_date=today) poll.question = "how's it going?" @@ -1185,11 +1331,11 @@ def test_migrations_include_order(self): if name == "_order": found = True self.assertEqual(type(field), models.IntegerField) - assert found, "_order not in fields " + repr(model_state.fields) + self.assertTrue(found, "_order not in fields " + repr(model_state.fields)) class TestLatest(TestCase): - """"Test behavior of `latest()` without any field parameters""" + """Test behavior of `latest()` without any field parameters""" def setUp(self): poll = Poll.objects.create(question="Does `latest()` work?", pub_date=yesterday) @@ -1207,19 +1353,19 @@ def test_ordered(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": today}] ) - assert HistoricalPoll.objects.latest().pk == 2 + self.assertEqual(HistoricalPoll.objects.latest().pk, 2) def test_jumbled(self): self.write_history( [{"pk": 1, "history_date": today}, {"pk": 2, "history_date": yesterday}] ) - assert HistoricalPoll.objects.latest().pk == 1 + self.assertEqual(HistoricalPoll.objects.latest().pk, 1) def test_sameinstant(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": yesterday}] ) - assert HistoricalPoll.objects.latest().pk == 1 + self.assertEqual(HistoricalPoll.objects.latest().pk, 2) class TestMissingOneToOne(TestCase): @@ -1554,7 +1700,7 @@ class MultiDBExplicitHistoryUserIDTest(TestCase): databases = {"default", "other"} def setUp(self): - self.user = get_user_model().objects.create( + self.user = get_user_model().objects.create( # nosec username="username", email="username@test.com", password="top_secret" ) @@ -1595,10 +1741,10 @@ def test_history_user_does_not_exist(self): class RelatedNameTest(TestCase): def setUp(self): - self.user_one = get_user_model().objects.create( + self.user_one = get_user_model().objects.create( # nosec username="username_one", email="first@user.com", password="top_secret" ) - self.user_two = get_user_model().objects.create( + self.user_two = get_user_model().objects.create( # nosec username="username_two", email="second@user.com", password="top_secret" ) diff --git a/simple_history/tests/tests/test_utils.py b/simple_history/tests/tests/test_utils.py index fd12f4f78..b88a6fcdf 100644 --- a/simple_history/tests/tests/test_utils.py +++ b/simple_history/tests/tests/test_utils.py @@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction -from django.test import TestCase, TransactionTestCase +from django.test import TestCase, TransactionTestCase, override_settings from django.utils import timezone from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError @@ -77,6 +77,13 @@ def test_bulk_create_history(self): self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 5) + @override_settings(SIMPLE_HISTORY_ENABLED=False) + def test_bulk_create_history_with_disabled_setting(self): + bulk_create_with_history(self.data, Poll) + + self.assertEqual(Poll.objects.count(), 5) + self.assertEqual(Poll.history.count(), 0) + def test_bulk_create_history_alternative_manager(self): bulk_create_with_history( self.data, @@ -291,6 +298,21 @@ def test_bulk_update_history(self): self.assertEqual(Poll.history.count(), 10) self.assertEqual(Poll.history.filter(history_type="~").count(), 5) + @override_settings(SIMPLE_HISTORY_ENABLED=False) + def test_bulk_update_history_without_history_enabled(self): + self.assertEqual(Poll.history.count(), 5) + # because setup called with enabled settings + bulk_update_with_history( + self.data, + Poll, + fields=["question"], + ) + + self.assertEqual(Poll.objects.count(), 5) + self.assertEqual(Poll.objects.get(id=4).question, "Updated question") + self.assertEqual(Poll.history.count(), 5) + self.assertEqual(Poll.history.filter(history_type="~").count(), 0) + def test_bulk_update_history_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") diff --git a/simple_history/utils.py b/simple_history/utils.py index f7290b4c5..d1748db3f 100644 --- a/simple_history/utils.py +++ b/simple_history/utils.py @@ -2,7 +2,7 @@ import django from django.db import transaction -from django.db.models import ManyToManyField +from django.db.models import ForeignKey, ManyToManyField from django.forms.models import model_to_dict from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError @@ -11,7 +11,7 @@ def update_change_reason(instance, reason): attrs = {} model = type(instance) - manager = instance if instance.id is not None else model + manager = instance if instance.pk is not None else model history = get_history_manager_for_model(manager) history_fields = [field.attname for field in history.model._meta.fields] for field in instance._meta.fields: @@ -40,11 +40,28 @@ def get_history_manager_for_model(model): return getattr(model, manager_name) +def get_history_manager_from_history(history_instance): + """ + Return the history manager, based on an existing history instance. + """ + key_name = get_app_model_primary_key_name(history_instance.instance_type) + return get_history_manager_for_model(history_instance.instance_type).filter( + **{key_name: getattr(history_instance, key_name)} + ) + + def get_history_model_for_model(model): """Return the history model for a given app model.""" return get_history_manager_for_model(model).model +def get_app_model_primary_key_name(model): + """Return the primary key name for a given app model.""" + if isinstance(model._meta.pk, ForeignKey): + return model._meta.pk.name + "_id" + return model._meta.pk.name + + def bulk_create_with_history( objs, model, @@ -164,13 +181,4 @@ def get_change_reason_from_object(obj): if hasattr(obj, "_change_reason"): return getattr(obj, "_change_reason") - if hasattr(obj, "changeReason"): - warning_msg = ( - "Using the attr changeReason to populate history_change_reason is" - " deprecated in 2.10.0 and will be removed in 3.0.0. Use " - "_change_reason instead. " - ) - warnings.warn(warning_msg, DeprecationWarning) - return getattr(obj, "changeReason") - return None diff --git a/tox.ini b/tox.ini index 7ee1d3eaf..1e09fbbce 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = - py{36,37,38,39}-dj{22,30,31,dev}-{sqlite3,postgres,mysql,mariadb}, + py{36,37,38,39}-dj{22,31,32}-{sqlite3,postgres,mysql,mariadb}, + py{38,39}-djmain-{sqlite3,postgres,mysql,mariadb}, docs, lint @@ -14,9 +15,9 @@ python = [gh-actions:env] DJANGO = 2.2: dj22 - 3.0: dj30 3.1: dj31 - dev: djdev + 3.2: dj32 + main: djmain [flake8] ignore = N802,F401,W503 @@ -28,20 +29,27 @@ exclude = __init__.py,simple_history/registry_tests/migration_test_app/migration deps = -rrequirements/test.txt dj22: Django>=2.2,<2.3 - dj30: Django>=3.0,<3.1 dj31: Django>=3.1,<3.2 - djdev: https://github.com/django/django/tarball/master + dj32: Django>=3.2,<3.3 + djmain: https://github.com/django/django/tarball/main postgres: -rrequirements/postgres.txt mysql: -rrequirements/mysql.txt mariadb: -rrequirements/mysql.txt commands = - sqlite3: coverage run -a runtests.py - postgres: coverage run -a runtests.py --database=postgres - mysql: coverage run -a runtests.py --database=mysql - mariadb: coverage run -a runtests.py --database=mariadb + sqlite3: coverage run -a runtests.py {posargs} + postgres: coverage run -a runtests.py --database=postgres {posargs} + mysql: coverage run -a runtests.py --database=mysql {posargs} + mariadb: coverage run -a runtests.py --database=mariadb {posargs} coverage report +[testenv:format] +deps = -rrequirements/lint.txt +commands = + isort docs simple_history runtests.py setup.py + black docs simple_history runtests.py setup.py + flake8 simple_history + [testenv:lint] deps = -rrequirements/lint.txt commands =