diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad3ac5b..164a364 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,41 +1,102 @@ --- -name: Release +name: Publish Python 🐍 distribution 📦 to PyPI on: push: tags: + # Order matters, the last rule that applies to a tag + # is the one that takes effect: + # https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#example-including-and-excluding-branches-and-tags - '*' + # There should be no dev tags created, but to be safe, + # let's not publish them. + - '!*.dev*' + +env: + PYPI_URL: https://pypi.org/p/django-simple-history jobs: + build: - if: github.repository == 'jazzband/django-simple-history' + name: Build distribution 📦 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.x - - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install -U setuptools twine wheel - - - name: Build package - run: | - python setup.py --version - python setup.py sdist --format=gztar bdist_wheel - twine check dist/* - - - name: Upload packages to Jazzband - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + python-version: "3.x" + - name: Install pypa/build + run: + python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: ${{ env.PYPI_URL }} + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1.12 + + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.1 with: - user: jazzband - password: ${{ secrets.JAZZBAND_RELEASE_KEY }} - repository_url: https://jazzband.co/projects/django-simple-history/upload + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 575ab7a..65b1c72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,27 +11,24 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13-dev'] - django-version: ['3.2', '4.2', '5.0', 'main'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + django-version: ['4.2', '5.0', '5.1', '5.2', 'main'] exclude: - # Exclude py3.8 and py3.9 for Django main and 5.0 - - python-version: '3.8' - django-version: '5.0' + # Exclude py3.9 for Django >= 5.0, + # and py3.10 and py3.11 for Django > 5.2 - python-version: '3.9' django-version: '5.0' - - python-version: '3.8' - django-version: 'main' + - python-version: '3.9' + django-version: '5.1' + - python-version: '3.9' + django-version: '5.2' - python-version: '3.9' django-version: 'main' - - # Exclude py3.11, py3.12 and py3.13 for Django 3.2 + - python-version: '3.10' + django-version: 'main' - python-version: '3.11' - django-version: '3.2' - - python-version: '3.12' - django-version: '3.2' - - python-version: '3.13-dev' - django-version: '3.2' + django-version: 'main' services: @@ -73,7 +70,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: | - setup.py + pyproject.toml tox.ini requirements/*.txt @@ -89,8 +86,9 @@ jobs: DJANGO: ${{ matrix.django-version }} - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: + token: ${{ secrets.CODECOV_TOKEN }} name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} @@ -109,7 +107,7 @@ jobs: - name: Set up newest stable Python version uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 cache: 'pip' # Invalidate the cache when this file updates, as the dependencies' versions # are pinned in the step below @@ -121,7 +119,7 @@ jobs: # Install this project in editable mode, so that its package metadata can be queried pip install -e . # Install the latest minor version of Django we support - pip install Django==5.0 + pip install Django==5.1 - name: Check translation files are updated run: python -m simple_history.tests.generated_file_checks.check_translations diff --git a/.gitignore b/.gitignore index 6263b36..325ff5e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .idea .tox/ .venv/ +.python-version /.project /.pydevproject /.ve diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf58ede..d8871b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,32 +1,31 @@ --- repos: - repo: https://github.com/PyCQA/bandit - rev: 1.7.7 + rev: 1.8.3 hooks: - id: bandit - args: - - "-x *test*.py" + exclude: /.*tests/ - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.2.0 + rev: 25.1.0 hooks: - id: black - language_version: python3.8 + language_version: python3.9 - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 + rev: 7.2.0 hooks: - id: flake8 args: - "--config=tox.ini" - repo: https://github.com/PyCQA/isort - rev: 5.13.2 + rev: 6.0.1 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: requirements-txt-fixer files: requirements/.*\.txt$ @@ -36,18 +35,28 @@ repos: - id: check-docstring-first - id: check-executables-have-shebangs - id: check-merge-conflict + - id: check-toml - id: debug-statements - id: detect-private-key + - repo: https://github.com/tox-dev/pyproject-fmt + rev: v2.5.1 + hooks: + - id: pyproject-fmt + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.24.1 + hooks: + - id: validate-pyproject + - repo: https://github.com/adrienverge/yamllint - rev: v1.35.1 + rev: v1.37.1 hooks: - id: yamllint args: - "--strict" - repo: https://github.com/asottile/pyupgrade - rev: v3.15.1 + rev: v3.19.1 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py39-plus] diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 7e1713f..87b8a41 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -21,3 +21,6 @@ sphinx: python: install: - requirements: requirements/docs.txt + # Install this project locally, so that its package metadata can be queried + - method: pip + path: . diff --git a/AUTHORS.rst b/AUTHORS.rst index fcf285f..70daee9 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -19,6 +19,7 @@ Authors - Anton Kulikov (`bigtimecriminal `_) - Ben Lawson (`blawson `_) - Benjamin Mampaey (`bmampaey `_) +- Berke Agababaoglu (`bagababaoglu `_) - Bheesham Persaud (`bheesham `_) - `bradford281 `_ - Brian Armstrong (`barm `_) @@ -43,6 +44,7 @@ Authors - Dmytro Shyshov (`xahgmah `_) - Edouard Richard (`vied12 ` _) - Eduardo Cuducos +- Eric Uriostigue (`euriostigue `_) - Erik van Widenfelt (`erikvw `_) - Fábio Capuano (`fabiocapsouza `_) - Jonathan Sanchez - Jonathan Zvesper (`zvesp `_) -- Jordon Wing (`jordonwii `_) +- Jordon Wing (`jordonwii `_) - Josh Fyne - Josh Thomas (`joshuadavidthomas `_) +- Jurrian Tromp (`jurrian `_) - Keith Hackbarth - Kevin Foster - Kira (`kiraware `_) @@ -109,6 +113,7 @@ Authors - Nianpeng Li - Nick Träger - Noel James (`NoelJames `_) +- Ofek Lev (`ofek `_) - Phillip Marshall - Prakash Venkatraman (`dopatraman `_) - Rajesh Pappula @@ -141,6 +146,8 @@ Authors - `ddusi `_ - `DanialErfanian `_ - `Sridhar Marella `_ +- `Mattia Fantoni `_ +- `Trent Holliday `_ Background ========== diff --git a/CHANGES.rst b/CHANGES.rst index 653c695..fe33ad3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,8 +3,84 @@ Changes Unreleased ---------- +- Fixed `diff_against` to work with deleted objects (gh-1312) + +3.10.1 (2025-06-20) +------------------- + +- Fixed changelog syntax to support PyPI packaging (gh-1499) + +3.10.0 (2025-06-20) +------------------- + +- Tests are no longer bundled in released wheels (gh-1478) +- Move repository to the Django Commons organization (gh-1391) + +3.9.0 (2025-01-26) +------------------ + +- Removed the ``simple_history_admin_list.display_list()`` template tag that was + deprecated in version 3.6.0 (gh-1444) + +3.8.0 (2025-01-23) +------------------ + +- Made ``skip_history_when_saving`` work when creating an object - not just when + updating an object (gh-1262) +- Improved performance of the ``latest_of_each()`` history manager method (gh-1360) +- Fixed issue with deferred fields causing DoesNotExist error (gh-678) +- Added HistoricOneToOneField (gh-1394) +- Updated all djangoproject.com links to reference the stable version (gh-1420) +- Dropped support for Python 3.8, which reached end-of-life on 2024-10-07 (gh-1421) +- Added support for Django 5.1 (gh-1388) +- Added pagination to ``SimpleHistoryAdmin`` (gh-1277) +- Fixed issue with history button not working when viewing historical entries in the + admin (gh-527) +- Added support for Django 5.2 (gh-1441) +- ``simple_history_admin_list.display_list()`` *was planned to be removed in this + release, but it was overlooked, and will instead be removed in 3.9.0* + +3.7.0 (2024-05-29) +------------------ + +- Dropped support for Django 3.2, which reached end-of-life on 2024-04-01 (gh-1344) +- Removed the temporary requirement on ``asgiref>=3.6`` added in 3.5.0, + now that the minimum required Django version is 4.2 (gh-1344) +- Migrated package building from using the deprecated ``setup.py`` to using + ``pyproject.toml`` (with Hatchling as build backend); + ``setup.py`` has consequently been removed (gh-1348) +- Added ``django>=4.2`` as an installation dependency, to mirror the minimum version + tested in our CI (gh-1349) + +3.6.0 (2024-05-26) +------------------ - Support custom History ``Manager`` and ``QuerySet`` classes (gh-1280) +- Renamed the (previously internal) admin template + ``simple_history/_object_history_list.html`` to + ``simple_history/object_history_list.html``, and added the field + ``SimpleHistoryAdmin.object_history_list_template`` for overriding it (gh-1128) +- Deprecated the undocumented template tag ``simple_history_admin_list.display_list()``; + it will be removed in version 3.8 (gh-1128) +- Added ``SimpleHistoryAdmin.get_history_queryset()`` for overriding which ``QuerySet`` + is used to list the historical records (gh-1128) +- Added ``SimpleHistoryAdmin.get_history_list_display()`` which returns + ``history_list_display`` by default, and made the latter into an actual field (gh-1128) +- ``ModelDelta`` and ``ModelChange`` (in ``simple_history.models``) are now immutable + dataclasses; their signatures remain unchanged (gh-1128) +- ``ModelDelta``'s ``changes`` and ``changed_fields`` are now sorted alphabetically by + field name. Also, if ``ModelChange`` is for an M2M field, its ``old`` and ``new`` + lists are sorted by the related object. This should help prevent flaky tests. (gh-1128) +- ``diff_against()`` has a new keyword argument, ``foreign_keys_are_objs``; + see usage in the docs under "History Diffing" (gh-1128) +- Added a "Changes" column to ``SimpleHistoryAdmin``'s object history table, listing + the changes between each historical record of the object; see the docs under + "Customizing the History Admin Templates" for overriding its template context (gh-1128) +- Fixed the setting ``SIMPLE_HISTORY_ENABLED = False`` not preventing M2M historical + records from being created (gh-1328) +- For history-tracked M2M fields, adding M2M objects (using ``add()`` or ``set()``) + used to cause a number of database queries that scaled linearly with the number of + objects; this has been fixed to now be a constant number of queries (gh-1333) 3.5.0 (2024-02-19) ------------------ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e0d5efa..97efd44 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,3 @@ # Code of Conduct -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. - -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 -- 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/ +The `django-simple-history` project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md). diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f66f96b..58136c6 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,11 +1,7 @@ Contributing to django-simple-history ===================================== -.. image:: https://jazzband.co/static/img/jazzband.svg - :target: https://jazzband.co/ - :alt: Jazzband - -This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. +By contributing you agree to abide by the `Contributor Code of Conduct `_. Pull Requests ------------- @@ -97,4 +93,4 @@ steps: 4. Compile these with ``django-admin compilemessages``. 5. Commit and publish your translations as described above. -.. _translation docs: https://docs.djangoproject.com/en/dev/topics/i18n/translation/#localization-how-to-create-language-files +.. _translation docs: https://docs.djangoproject.com/en/stable/topics/i18n/translation/#localization-how-to-create-language-files diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 9944790..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include MANIFEST.in -include *.rst -include *.txt -recursive-include docs *.rst -recursive-include simple_history/locale * -recursive-include simple_history/templates * diff --git a/Makefile b/Makefile index 04fc170..05b30ac 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,7 @@ clean: clean-build clean-pyc rm -fr htmlcov/ clean-build: - rm -fr build/ rm -fr dist/ - rm -fr *.egg-info clean-pyc: find . -name '*.pyc' -exec rm -f {} + @@ -27,9 +25,8 @@ documentation: tox -e docs dist: clean - pip install -U wheel - python setup.py sdist - python setup.py bdist_wheel + pip install -U build + python -m build for file in dist/* ; do gpg --detach-sign -a "$$file" ; done ls -l dist diff --git a/README.rst b/README.rst index c8442f6..cd3b838 100644 --- a/README.rst +++ b/README.rst @@ -1,61 +1,62 @@ -django-simple-history -===================== +django-simple-history |pypi-version| +==================================== -.. image:: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml/badge.svg - :target: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml +.. Start of PyPI readme + +|build-status| |docs| |coverage| |maintainability| |code-style| |downloads| + +.. |pypi-version| image:: https://img.shields.io/pypi/v/django-simple-history.svg + :target: https://pypi.org/project/django-simple-history/ + :alt: PyPI Version + +.. |build-status| image:: https://github.com/django-commons/django-simple-history/actions/workflows/test.yml/badge.svg + :target: https://github.com/django-commons/django-simple-history/actions/workflows/test.yml :alt: Build Status -.. image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest +.. |docs| image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest :target: https://django-simple-history.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://img.shields.io/codecov/c/github/jazzband/django-simple-history/master.svg - :target: https://app.codecov.io/github/jazzband/django-simple-history?branch=master +.. |coverage| image:: https://img.shields.io/codecov/c/github/django-commons/django-simple-history/master.svg + :target: https://app.codecov.io/github/django-commons/django-simple-history?branch=master :alt: Test Coverage -.. image:: https://img.shields.io/pypi/v/django-simple-history.svg - :target: https://pypi.org/project/django-simple-history/ - :alt: PyPI Version - -.. image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability - :target: https://codeclimate.com/github/jazzband/django-simple-history/maintainability +.. |maintainability| image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability + :target: https://codeclimate.com/github/django-commons/django-simple-history/maintainability :alt: Maintainability -.. image:: https://static.pepy.tech/badge/django-simple-history - :target: https://pepy.tech/project/django-simple-history - :alt: Downloads - -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg +.. |code-style| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black :alt: Code Style -.. image:: https://jazzband.co/static/img/badge.svg - :target: https://jazzband.co/ - :alt: Jazzband +.. |downloads| image:: https://static.pepy.tech/badge/django-simple-history + :target: https://pepy.tech/project/django-simple-history + :alt: Downloads -django-simple-history stores Django model state on every create/update/delete. +``django-simple-history`` stores Django model state on every create/update/delete. This app supports the following combinations of Django and Python: ========== ======================== Django Python ========== ======================== -3.2 3.8, 3.9, 3.10 -4.2 3.8, 3.9, 3.10, 3.11, 3.12, 3.13-dev -5.0 3.10, 3.11, 3.12, 3.13-dev -main 3.10, 3.11, 3.12, 3.13-dev +4.2 3.9, 3.10, 3.11, 3.12, 3.13 +5.0 3.10, 3.11, 3.12, 3.13 +5.1 3.10, 3.11, 3.12, 3.13 +5.2 3.10, 3.11, 3.12, 3.13 +main 3.12, 3.13 ========== ======================== Getting Help ------------ -Documentation is available at https://django-simple-history.readthedocs.io/ +Documentation is available at https://django-simple-history.readthedocs.io/en/stable/ -Pull requests are welcome. Read the `CONTRIBUTING`_ file for tips on +Pull requests are welcome. Read the `CONTRIBUTING`_ file for tips on submitting a pull request. -.. _CONTRIBUTING: https://github.com/jazzband/django-simple-history/blob/master/CONTRIBUTING.rst +.. _CONTRIBUTING: https://github.com/django-commons/django-simple-history/blob/master/CONTRIBUTING.rst License ------- diff --git a/docs/admin.rst b/docs/admin.rst index 1b34f92..1bf2201 100644 --- a/docs/admin.rst +++ b/docs/admin.rst @@ -49,7 +49,7 @@ By default, the history log displays one line per change containing You can add other columns (for example the object's status to see how it evolved) by adding a ``history_list_display`` array of fields to the -admin class +admin class. .. code-block:: python @@ -62,6 +62,7 @@ admin class list_display = ["id", "name", "status"] history_list_display = ["status"] search_fields = ['name', 'user__username'] + history_list_per_page = 100 admin.site.register(Poll, PollHistoryAdmin) admin.site.register(Choice, SimpleHistoryAdmin) @@ -69,6 +70,64 @@ admin class .. image:: screens/5_history_list_display.png + +Changing the page size in the admin history list view +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, the history list view of ``SimpleHistoryAdmin`` shows the last 100 records. +You can change this by adding a `history_list_per_page` attribute to the admin class. + + +.. code-block:: python + + from django.contrib import admin + from simple_history.admin import SimpleHistoryAdmin + from .models import Poll + + + class PollHistoryAdmin(SimpleHistoryAdmin): + # history_list_per_page defaults to 100 + history_list_per_page = 200 + + admin.site.register(Poll, PollHistoryAdmin) + + +Customizing the History Admin Templates +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you'd like to customize the HTML of ``SimpleHistoryAdmin``'s object history pages, +you can override the following attributes with the names of your own templates: + +- ``object_history_template``: The main object history page, which includes (inserts) + ``object_history_list_template``. +- ``object_history_list_template``: The table listing an object's historical records and + the changes made between them. +- ``object_history_form_template``: The form pre-filled with the details of an object's + historical record, which also allows you to revert the object to a previous version. + +If you'd like to only customize certain parts of the mentioned templates, look for +``block`` template tags in the source code that you can override - like the +``history_delta_changes`` block in ``simple_history/object_history_list.html``, +which lists the changes made between each historical record. + +Customizing Context +^^^^^^^^^^^^^^^^^^^ + +You can also customize the template context by overriding the following methods: + +- ``render_history_view()``: Called by both ``history_view()`` and + ``history_form_view()`` before the templates are rendered. Customize the context by + changing the ``context`` parameter. +- ``history_view()``: Returns a rendered ``object_history_template``. + Inject context by calling the super method with the ``extra_context`` argument. +- ``get_historical_record_context_helper()``: Returns an instance of + ``simple_history.template_utils.HistoricalRecordContextHelper`` that's used to format + some template context for each historical record displayed through ``history_view()``. + Customize the context by extending the mentioned class and overriding its methods. +- ``history_form_view()``: Returns a rendered ``object_history_form_template``. + Inject context by calling the super method with the ``extra_context`` argument. + + Disabling the option to revert an object ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/common_issues.rst b/docs/common_issues.rst index b1c9a39..0f76ca8 100644 --- a/docs/common_issues.rst +++ b/docs/common_issues.rst @@ -15,8 +15,8 @@ As of ``django-simple-history`` 2.2.0, we can use the utility function ``bulk_create_with_history`` in order to bulk create objects while saving their history: -.. _bulk_create: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#bulk-create -.. _bulk_update: https://docs.djangoproject.com/en/3.0/ref/models/querysets/#bulk-update +.. _bulk_create: https://docs.djangoproject.com/en/stable/ref/models/querysets/#bulk-create +.. _bulk_update: https://docs.djangoproject.com/en/stable/ref/models/querysets/#bulk-update .. code-block:: pycon @@ -142,7 +142,7 @@ As the Django documentation says:: e.comments_on = False e.save() -.. _queryset updates: https://docs.djangoproject.com/en/2.2/ref/models/querysets/#update +.. _queryset updates: https://docs.djangoproject.com/en/stable/ref/models/querysets/#update Note: Django 2.2 now allows ``bulk_update``. No ``pre_save`` or ``post_save`` signals are sent still. @@ -170,7 +170,7 @@ Thus, when an ``F()`` expression is used on a model with a history table, the historical model tries to insert using the ``F()`` expression, and raises a ``ValueError``. -.. _here: https://docs.djangoproject.com/en/2.0/ref/models/expressions/#f-expressions +.. _here: https://docs.djangoproject.com/en/stable/ref/models/expressions/#f-expressions Reserved Field Names @@ -248,7 +248,7 @@ Usage with django-modeltranslation ---------------------------------- If you have ``django-modeltranslation`` installed, you will need to use the ``register()`` -method to model translation, as described `here `__. +method to model translation, as described `here `__. Pointing to the model diff --git a/docs/historical_model.rst b/docs/historical_model.rst index fbf931c..32447f1 100644 --- a/docs/historical_model.rst +++ b/docs/historical_model.rst @@ -554,7 +554,6 @@ You will see the many to many changes when diffing between two historical record informal = Category.objects.create(name="informal questions") official = Category.objects.create(name="official questions") p = Poll.objects.create(question="what's up?") - p.save() p.categories.add(informal, official) p.categories.remove(informal) diff --git a/docs/history_diffing.rst b/docs/history_diffing.rst index 3e40906..7887c61 100644 --- a/docs/history_diffing.rst +++ b/docs/history_diffing.rst @@ -1,24 +1,110 @@ History Diffing =============== -When you have two instances of the same ``HistoricalRecord`` (such as the ``HistoricalPoll`` example above), -you can perform diffs to see what changed. This will result in a ``ModelDelta`` containing the following properties: +When you have two instances of the same historical model +(such as the ``HistoricalPoll`` example above), +you can perform a diff using the ``diff_against()`` method to see what changed. +This will return a ``ModelDelta`` object with the following attributes: -1. A list with each field changed between the two historical records -2. A list with the names of all fields that incurred changes from one record to the other -3. the old and new records. +- ``old_record`` and ``new_record``: The old and new history records +- ``changed_fields``: A list of the names of all fields that were changed between + ``old_record`` and ``new_record``, in alphabetical order +- ``changes``: A list of ``ModelChange`` objects - one for each field in + ``changed_fields``, in the same order. + These objects have the following attributes: -This may be useful when you want to construct timelines and need to get only the model modifications. + - ``field``: The name of the changed field + (this name is equal to the corresponding field in ``changed_fields``) + - ``old`` and ``new``: The old and new values of the changed field + + - For many-to-many fields, these values will be lists of dicts from the through + model field names to the primary keys of the through model's related objects. + The lists are sorted by the value of the many-to-many related object. + +This may be useful when you want to construct timelines and need to get only +the model modifications. .. code-block:: python - p = Poll.objects.create(question="what's up?") - p.question = "what's up, man?" - p.save() + poll = Poll.objects.create(question="what's up?") + poll.question = "what's up, man?" + poll.save() - new_record, old_record = p.history.all() + new_record, old_record = poll.history.all() delta = new_record.diff_against(old_record) for change in delta.changes: - print("{} changed from {} to {}".format(change.field, change.old, change.new)) + print(f"'{change.field}' changed from '{change.old}' to '{change.new}'") + + # Output: + # 'question' changed from 'what's up?' to 'what's up, man?' + +``diff_against()`` also accepts the following additional arguments: + +- ``excluded_fields`` and ``included_fields``: These can be used to either explicitly + exclude or include fields from being diffed, respectively. +- ``foreign_keys_are_objs``: + + - If ``False`` (default): The diff will only contain the raw primary keys of any + ``ForeignKey`` fields. + - If ``True``: The diff will contain the actual related model objects instead of just + the primary keys. + Deleted related objects (both foreign key objects and many-to-many objects) + will be instances of ``DeletedObject``, which only contain a ``model`` field with a + reference to the deleted object's model, as well as a ``pk`` field with the value of + the deleted object's primary key. + + Note that this will add extra database queries for each related field that's been + changed - as long as the related objects have not been prefetched + (using e.g. ``select_related()``). + + A couple examples showing the difference: + + .. code-block:: python + + # --- Effect on foreign key fields --- + + whats_up = Poll.objects.create(pk=15, name="what's up?") + still_around = Poll.objects.create(pk=31, name="still around?") + + choice = Choice.objects.create(poll=whats_up) + choice.poll = still_around + choice.save() + + new, old = choice.history.all() + + default_delta = new.diff_against(old) + # Printing the changes of `default_delta` will output: + # 'poll' changed from '15' to '31' + + delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) + # Printing the changes of `delta_with_objs` will output: + # 'poll' changed from 'what's up?' to 'still around?' + + # Deleting all the polls: + Poll.objects.all().delete() + delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) + # Printing the changes of `delta_with_objs` will now output: + # 'poll' changed from 'Deleted poll (pk=15)' to 'Deleted poll (pk=31)' + + + # --- Effect on many-to-many fields --- + + informal = Category.objects.create(pk=63, name="informal questions") + whats_up.categories.add(informal) + + new = whats_up.history.latest() + old = new.prev_record + + default_delta = new.diff_against(old) + # Printing the changes of `default_delta` will output: + # 'categories' changed from [] to [{'poll': 15, 'category': 63}] + + delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) + # Printing the changes of `delta_with_objs` will output: + # 'categories' changed from [] to [{'poll': , 'category': }] -``diff_against`` also accepts 2 arguments ``excluded_fields`` and ``included_fields`` to either explicitly include or exclude fields from being diffed. + # Deleting all the categories: + Category.objects.all().delete() + delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) + # Printing the changes of `delta_with_objs` will now output: + # 'categories' changed from [] to [{'poll': , 'category': DeletedObject(model=, pk=63)}] diff --git a/docs/index.rst b/docs/index.rst index e6eaaf7..fcd8420 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,16 +1,16 @@ django-simple-history ===================== -.. image:: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml/badge.svg - :target: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml +.. image:: https://github.com/django-commons/django-simple-history/actions/workflows/test.yml/badge.svg + :target: https://github.com/django-commons/django-simple-history/actions/workflows/test.yml :alt: Build Status .. image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest :target: https://django-simple-history.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://img.shields.io/codecov/c/github/jazzband/django-simple-history/master.svg - :target: https://app.codecov.io/github/jazzband/django-simple-history?branch=master +.. image:: https://img.shields.io/codecov/c/github/django-commons/django-simple-history/master.svg + :target: https://app.codecov.io/github/django-commons/django-simple-history?branch=master :alt: Test Coverage .. image:: https://img.shields.io/pypi/v/django-simple-history.svg @@ -18,7 +18,7 @@ django-simple-history :alt: PyPI Version .. image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability - :target: https://codeclimate.com/github/jazzband/django-simple-history/maintainability + :target: https://codeclimate.com/github/django-commons/django-simple-history/maintainability :alt: Maintainability .. image:: https://static.pepy.tech/badge/django-simple-history @@ -29,10 +29,6 @@ django-simple-history :target: https://github.com/psf/black :alt: Code Style -.. image:: https://jazzband.co/static/img/badge.svg - :target: https://jazzband.co/ - :alt: Jazzband - django-simple-history stores Django model state on every create/update/delete. @@ -41,17 +37,18 @@ This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= -3.2 3.8, 3.9, 3.10 -4.2 3.8, 3.9, 3.10, 3.11, 3.12, 3.13-dev -5.0 3.10, 3.11, 3.12, 3.13-dev -main 3.10, 3.11, 3.12, 3.13-dev +4.2 3.9, 3.10, 3.11, 3.12, 3.13 +5.0 3.10, 3.11, 3.12, 3.13 +5.1 3.10, 3.11, 3.12, 3.13 +5.2 3.10, 3.11, 3.12, 3.13 +main 3.12, 3.13 ========== ======================= Contribute ---------- -- Issue Tracker: https://github.com/jazzband/django-simple-history/issues -- Source Code: https://github.com/jazzband/django-simple-history +- Issue Tracker: https://github.com/django-commons/django-simple-history/issues +- Source Code: https://github.com/django-commons/django-simple-history Pull requests are welcome. diff --git a/docs/querying_history.rst b/docs/querying_history.rst index 23b30f7..bddd80f 100644 --- a/docs/querying_history.rst +++ b/docs/querying_history.rst @@ -149,6 +149,7 @@ historic point in time (even if it is the most recent version). You can use `to_historic` to return the historical model that was used to furnish the instance at hand, if it is actually historic. +.. _`HistoricForeignKey`: HistoricForeignKey ------------------ @@ -162,6 +163,11 @@ reverse relationships. See the `HistoricForeignKeyTest` code and models for an example. +HistoricOneToOneField +--------------------- + +Similar to :ref:`HistoricForeignKey`, but for OneToOneFields instead. + most_recent ----------- @@ -175,30 +181,50 @@ model history. -Save without a historical record --------------------------------- +Save without creating historical records +---------------------------------------- -If you want to save a model without a historical record, you can use the following: +If you want to save model objects without triggering the creation of any historical +records, you can do the following: .. code-block:: python - class Poll(models.Model): - question = models.CharField(max_length=200) - history = HistoricalRecords() + poll.skip_history_when_saving = True + poll.save() + # We recommend deleting the attribute afterward + del poll.skip_history_when_saving + +This also works when creating an object, but only when calling ``save()``: - def save_without_historical_record(self, *args, **kwargs): - self.skip_history_when_saving = True - try: - ret = self.save(*args, **kwargs) - finally: - del self.skip_history_when_saving - return ret +.. code-block:: python + + # Note that `Poll.objects.create()` is not called + poll = Poll(question="Why?") + poll.skip_history_when_saving = True + poll.save() + del poll.skip_history_when_saving + +.. note:: + Historical records will always be created when calling the ``create()`` manager method. +Alternatively, call the ``save_without_historical_record()`` method on each object +instead of ``save()``. +This method is automatically added to a model when registering it for history-tracking +(i.e. defining a ``HistoricalRecords`` manager field on the model), +and it looks like this: + +.. code-block:: python - poll = Poll(question='something') - poll.save_without_historical_record() + def save_without_historical_record(self, *args, **kwargs): + self.skip_history_when_saving = True + try: + ret = self.save(*args, **kwargs) + finally: + del self.skip_history_when_saving + return ret -Or disable history records for all models by putting following lines in your ``settings.py`` file: +Or disable the creation of historical records for *all* models +by adding the following line to your settings: .. code-block:: python diff --git a/docs/screens/10_revert_disabled.png b/docs/screens/10_revert_disabled.png index 851fcfe..e5ab873 100644 Binary files a/docs/screens/10_revert_disabled.png and b/docs/screens/10_revert_disabled.png differ diff --git a/docs/screens/1_poll_history.png b/docs/screens/1_poll_history.png index e2f1b24..713352f 100644 Binary files a/docs/screens/1_poll_history.png and b/docs/screens/1_poll_history.png differ diff --git a/docs/screens/2_revert.png b/docs/screens/2_revert.png index 3cba441..c9a5254 100644 Binary files a/docs/screens/2_revert.png and b/docs/screens/2_revert.png differ diff --git a/docs/screens/3_poll_reverted.png b/docs/screens/3_poll_reverted.png index 6b7f3e6..802bcab 100644 Binary files a/docs/screens/3_poll_reverted.png and b/docs/screens/3_poll_reverted.png differ diff --git a/docs/screens/4_history_after_poll_reverted.png b/docs/screens/4_history_after_poll_reverted.png index b49c75b..264e8bb 100644 Binary files a/docs/screens/4_history_after_poll_reverted.png and b/docs/screens/4_history_after_poll_reverted.png differ diff --git a/docs/screens/5_history_list_display.png b/docs/screens/5_history_list_display.png index 6b1f033..fc07c56 100644 Binary files a/docs/screens/5_history_list_display.png and b/docs/screens/5_history_list_display.png differ diff --git a/pyproject.toml b/pyproject.toml index 52fb6e3..4a9295d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,107 @@ +[build-system] +build-backend = "hatchling.build" +requires = [ + "hatch-fancy-pypi-readme", + "hatch-vcs", + "hatchling", +] + +[project] +name = "django-simple-history" +description = "Store model history and view/revert changes from admin site." +maintainers = [ + { name = "Trey Hunner" }, +] +authors = [ + { name = "Corey Bertram", email = "corey@qr7.com" }, +] +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dynamic = [ + "readme", + "version", +] +dependencies = [ + "django>=4.2", +] +urls.Changelog = "https://github.com/django-commons/django-simple-history/blob/master/CHANGES.rst" +urls.Documentation = "https://django-simple-history.readthedocs.io/en/stable/" +urls.Homepage = "https://github.com/django-commons/django-simple-history" +urls.Source = "https://github.com/django-commons/django-simple-history" +urls.Tracker = "https://github.com/django-commons/django-simple-history/issues" + +[tool.hatch.version] +source = "vcs" +fallback-version = "0.0.0" + +[tool.hatch.version.raw-options] +version_scheme = "no-guess-dev" +local_scheme = "node-and-date" + +[tool.hatch.build.targets.wheel] +packages = [ + "simple_history", +] +exclude = [ + "simple_history/registry_tests", + "simple_history/tests", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/x-rst" +# (Preview the generated readme by installing `hatch` and running +# `hatch project metadata readme` - see +# https://github.com/hynek/hatch-fancy-pypi-readme/blob/24.1.0/README.md#cli-interface) +fragments = [ + { path = "README.rst", start-after = ".. Start of PyPI readme\n\n" }, + { text = "\n====\n\nChangelog\n=========\n\n" }, + # Only include the first title after "Unreleased" - as well as the rest of the file + { path = "CHANGES.rst", pattern = "\nUnreleased\n-{4,}\n(?:.*?)\n([^\n]+\n-{4,}\n.*)" }, +] + [tool.black] line-length = 88 -target-version = ["py38"] +target-version = [ + "py39", +] [tool.isort] profile = "black" -py_version = "38" +py_version = "39" [tool.coverage.run] parallel = true branch = true -source = ["simple_history"] +source = [ + "simple_history", +] [tool.coverage.paths] -source = ["simple_history", ".tox/*/site-packages"] +source = [ + "simple_history", + ".tox/*/site-packages", +] [tool.coverage.report] show_missing = true skip_covered = true -omit = ["requirements/*"] +omit = [ + "requirements/*", +] diff --git a/requirements/coverage.txt b/requirements/coverage.txt index aa0f0f3..c326d40 100644 --- a/requirements/coverage.txt +++ b/requirements/coverage.txt @@ -1,2 +1,2 @@ -coverage==7.3.2 +coverage==7.9.0 toml==0.10.2 diff --git a/requirements/docs.txt b/requirements/docs.txt index 06417b8..c17ae30 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,4 +1,2 @@ -# Install this project in editable mode, so that its package metadata can be queried --e . -Sphinx==7.2.6 -sphinx-rtd-theme==1.3.0 +Sphinx==8.2.3 +sphinx-rtd-theme==3.0.2 diff --git a/requirements/lint.txt b/requirements/lint.txt index 274abb6..58bbabc 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -1,3 +1,3 @@ -black==23.12.0 -flake8==6.1.0 -isort==5.12.0 +black==25.1.0 +flake8==7.2.0 +isort==6.0.1 diff --git a/requirements/mysql.txt b/requirements/mysql.txt index 5cb3ab0..e5bb115 100644 --- a/requirements/mysql.txt +++ b/requirements/mysql.txt @@ -1 +1 @@ -mysqlclient==2.2.0 +mysqlclient==2.2.7 diff --git a/requirements/postgres.txt b/requirements/postgres.txt index aaa87fe..281d811 100644 --- a/requirements/postgres.txt +++ b/requirements/postgres.txt @@ -1,4 +1 @@ -# DEV: Replace this with `psycopg[binary]` when the minimum required Django version is -# 4.2 or higher, as this is likely to be deprecated in the future -# (see https://docs.djangoproject.com/en/4.2/releases/4.2/#psycopg-3-support) -psycopg2-binary==2.9.9 +psycopg[binary]==3.2.9 diff --git a/requirements/test.txt b/requirements/test.txt index 0b7414d..ae6ed25 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1 @@ -r ./coverage.txt -# DEV: Remove this requirement entirely when the minimum required Django version is 4.2 -asgiref>=3.6 diff --git a/requirements/tox.txt b/requirements/tox.txt index 35682c6..1fb321f 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -1,3 +1,3 @@ -r ./coverage.txt -tox==4.11.3 -tox-gh-actions==3.1.3 +tox==4.26.0 +tox-gh-actions==3.3.0 diff --git a/runtests.py b/runtests.py index 62ebbef..de2e9c1 100755 --- a/runtests.py +++ b/runtests.py @@ -135,6 +135,12 @@ def __getitem__(self, item): }, } ], + STORAGES={ + "default": { + # Speeds up tests and prevents locally storing files created through them + "BACKEND": "django.core.files.storage.InMemoryStorage", + }, + }, DEFAULT_AUTO_FIELD="django.db.models.AutoField", USE_TZ=False, ) @@ -146,16 +152,6 @@ def __getitem__(self, item): DEFAULT_SETTINGS["MIDDLEWARE"] = MIDDLEWARE -# DEV: Merge these settings into DEFAULT_SETTINGS when the minimum required -# Django version is 4.2 or higher -if django.VERSION >= (4, 2): - DEFAULT_SETTINGS["STORAGES"] = { - "default": { - # Speeds up tests and prevents locally storing files created through them - "BACKEND": "django.core.files.storage.InMemoryStorage", - }, - } - def get_default_settings(*, database_name=DEFAULT_DATABASE_NAME): return { diff --git a/setup.py b/setup.py deleted file mode 100644 index 8e1909d..0000000 --- a/setup.py +++ /dev/null @@ -1,56 +0,0 @@ -from setuptools import setup - -with open("README.rst") as readme, open("CHANGES.rst") as changes: - setup( - name="django-simple-history", - use_scm_version={ - "version_scheme": "post-release", - "local_scheme": "node-and-date", - "relative_to": __file__, - "root": ".", - "fallback_version": "0.0.0", - }, - setup_requires=["setuptools_scm"], - # DEV: Remove `asgiref` when the minimum required Django version is 4.2 - install_requires=["asgiref>=3.6"], - description="Store model history and view/revert changes from admin site.", - long_description="\n".join((readme.read(), changes.read())), - long_description_content_type="text/x-rst", - author="Corey Bertram", - author_email="corey@qr7.com", - maintainer="Trey Hunner", - url="https://github.com/jazzband/django-simple-history", - project_urls={ - "Documentation": "https://django-simple-history.readthedocs.io/", - "Changelog": "https://github.com/jazzband/django-simple-history/blob/master/CHANGES.rst", # noqa: E501 - "Source": "https://github.com/jazzband/django-simple-history", - "Tracker": "https://github.com/jazzband/django-simple-history/issues", - }, - packages=[ - "simple_history", - "simple_history.management", - "simple_history.management.commands", - "simple_history.templatetags", - ], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Framework :: Django", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "License :: OSI Approved :: BSD License", - ], - python_requires=">=3.8", - include_package_data=True, - ) diff --git a/simple_history/admin.py b/simple_history/admin.py index 34e3033..fdee136 100644 --- a/simple_history/admin.py +++ b/simple_history/admin.py @@ -1,11 +1,17 @@ +from collections.abc import Sequence +from typing import Any + from django import http from django.apps import apps as django_apps from django.conf import settings from django.contrib import admin from django.contrib.admin import helpers from django.contrib.admin.utils import unquote +from django.contrib.admin.views.main import PAGE_VAR from django.contrib.auth import get_permission_codename, get_user_model from django.core.exceptions import PermissionDenied +from django.core.paginator import Paginator +from django.db.models import QuerySet from django.shortcuts import get_object_or_404, render from django.urls import re_path, reverse from django.utils.encoding import force_str @@ -13,14 +19,21 @@ from django.utils.text import capfirst from django.utils.translation import gettext as _ +from .manager import HistoricalQuerySet, HistoryManager +from .models import HistoricalChanges +from .template_utils import HistoricalRecordContextHelper from .utils import get_history_manager_for_model, get_history_model_for_model SIMPLE_HISTORY_EDIT = getattr(settings, "SIMPLE_HISTORY_EDIT", False) class SimpleHistoryAdmin(admin.ModelAdmin): + history_list_display = [] + object_history_template = "simple_history/object_history.html" + object_history_list_template = "simple_history/object_history_list.html" object_history_form_template = "simple_history/object_history_form.html" + history_list_per_page = 100 def get_urls(self): """Returns the additional urls used by the Reversion admin.""" @@ -46,41 +59,51 @@ def history_view(self, request, object_id, extra_context=None): pk_name = opts.pk.attname history = getattr(model, model._meta.simple_history_manager_attribute) object_id = unquote(object_id) - action_list = history.filter(**{pk_name: object_id}) - if not isinstance(history.model.history_user, property): - # Only select_related when history_user is a ForeignKey (not a property) - action_list = action_list.select_related("history_user") - history_list_display = getattr(self, "history_list_display", []) + historical_records = self.get_history_queryset( + request, history, pk_name, object_id + ) + history_list_display = self.get_history_list_display(request) # If no history was found, see whether this object even exists. try: obj = self.get_queryset(request).get(**{pk_name: object_id}) except model.DoesNotExist: try: - obj = action_list.latest("history_date").instance - except action_list.model.DoesNotExist: + obj = historical_records.latest("history_date").instance + except historical_records.model.DoesNotExist: raise http.Http404 if not self.has_view_history_or_change_history_permission(request, obj): raise PermissionDenied - # Set attribute on each action_list entry from admin methods + # Use the same pagination as in Django admin, with history_list_per_page items + paginator = Paginator(historical_records, self.history_list_per_page) + page_obj = paginator.get_page(request.GET.get(PAGE_VAR)) + page_range = paginator.get_elided_page_range(page_obj.number) + + # Set attribute on each historical record from admin methods for history_list_entry in history_list_display: value_for_entry = getattr(self, history_list_entry, None) if value_for_entry and callable(value_for_entry): - for list_entry in action_list: - setattr(list_entry, history_list_entry, value_for_entry(list_entry)) + for record in page_obj.object_list: + setattr(record, history_list_entry, value_for_entry(record)) + + self.set_history_delta_changes(request, page_obj) content_type = self.content_type_model_cls.objects.get_for_model( get_user_model() ) - admin_user_view = "admin:{}_{}_change".format( content_type.app_label, content_type.model, ) + context = { "title": self.history_view_title(request, obj), - "action_list": action_list, + "object_history_list_template": self.object_history_list_template, + "page_obj": page_obj, + "page_range": page_range, + "page_var": PAGE_VAR, + "pagination_required": paginator.count > self.history_list_per_page, "module_name": capfirst(force_str(opts.verbose_name_plural)), "object": obj, "root_path": getattr(self.admin_site, "root_path", None), @@ -97,6 +120,73 @@ def history_view(self, request, object_id, extra_context=None): request, self.object_history_template, context, **extra_kwargs ) + def get_history_queryset( + self, request, history_manager: HistoryManager, pk_name: str, object_id: Any + ) -> QuerySet: + """ + Return a ``QuerySet`` of all historical records that should be listed in the + ``object_history_list_template`` template. + This is used by ``history_view()``. + + :param request: + :param history_manager: + :param pk_name: The name of the original model's primary key field. + :param object_id: The primary key of the object whose history is listed. + """ + qs: HistoricalQuerySet = history_manager.filter(**{pk_name: object_id}) + if not isinstance(history_manager.model.history_user, property): + # Only select_related when history_user is a ForeignKey (not a property) + qs = qs.select_related("history_user") + # Prefetch related objects to reduce the number of DB queries when diffing + qs = qs._select_related_history_tracked_objs() + return qs + + def get_history_list_display(self, request) -> Sequence[str]: + """ + Return a sequence containing the names of additional fields to be displayed on + the object history page. These can either be fields or properties on the model + or the history model, or methods on the admin class. + """ + return self.history_list_display + + def get_historical_record_context_helper( + self, request, historical_record: HistoricalChanges + ) -> HistoricalRecordContextHelper: + """ + Return an instance of ``HistoricalRecordContextHelper`` for formatting + the template context for ``historical_record``. + """ + return HistoricalRecordContextHelper(self.model, historical_record) + + def set_history_delta_changes( + self, + request, + historical_records: Sequence[HistoricalChanges], + foreign_keys_are_objs=True, + ): + """ + Add a ``history_delta_changes`` attribute to all historical records + except the first (oldest) one. + + :param request: + :param historical_records: + :param foreign_keys_are_objs: Passed to ``diff_against()`` when calculating + the deltas; see its docstring for details. + """ + previous = None + for current in historical_records: + if previous is None: + previous = current + continue + # Related objects should have been prefetched in `get_history_queryset()` + delta = previous.diff_against( + current, foreign_keys_are_objs=foreign_keys_are_objs + ) + helper = self.get_historical_record_context_helper(request, previous) + previous.history_delta_changes = helper.context_for_delta_changes(delta) + + previous = current + def history_view_title(self, request, obj): if self.revert_disabled(request, obj) and not SIMPLE_HISTORY_EDIT: return _("View history: %s") % force_str(obj) diff --git a/simple_history/locale/ar/LC_MESSAGES/django.po b/simple_history/locale/ar/LC_MESSAGES/django.po index 109e825..c723473 100644 --- a/simple_history/locale/ar/LC_MESSAGES/django.po +++ b/simple_history/locale/ar/LC_MESSAGES/django.po @@ -60,30 +60,6 @@ msgstr "تغيير" msgid "Deleted" msgstr "تمت إزالته" -#: simple_history/templates/simple_history/_object_history_list.html:9 -msgid "Object" -msgstr "عنصر" - -#: simple_history/templates/simple_history/_object_history_list.html:13 -msgid "Date/time" -msgstr "التاريخ/الوقت" - -#: simple_history/templates/simple_history/_object_history_list.html:14 -msgid "Comment" -msgstr "تعليق" - -#: simple_history/templates/simple_history/_object_history_list.html:15 -msgid "Changed by" -msgstr "تغير من قبل" - -#: simple_history/templates/simple_history/_object_history_list.html:16 -msgid "Change reason" -msgstr "سبب التغير" - -#: simple_history/templates/simple_history/_object_history_list.html:37 -msgid "None" -msgstr "فارغ" - #: 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 " @@ -121,6 +97,30 @@ msgstr "اضغط على زر 'استرجاع' ادناه للاسترجاع له msgid "Press the 'Change History' button below to edit the history." msgstr "اضغط على زر 'تعديل سجل التغيرات' ادناه لتعديل التاريخ." +#: simple_history/templates/simple_history/object_history_list.html:9 +msgid "Object" +msgstr "عنصر" + +#: simple_history/templates/simple_history/object_history_list.html:13 +msgid "Date/time" +msgstr "التاريخ/الوقت" + +#: simple_history/templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "تعليق" + +#: simple_history/templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "تغير من قبل" + +#: simple_history/templates/simple_history/object_history_list.html:16 +msgid "Change reason" +msgstr "سبب التغير" + +#: simple_history/templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "فارغ" + #: simple_history/templates/simple_history/submit_line.html:4 msgid "Revert" msgstr "استرجاع" diff --git a/simple_history/locale/cs_CZ/LC_MESSAGES/django.po b/simple_history/locale/cs_CZ/LC_MESSAGES/django.po index 0f8dd76..aa8dabc 100644 --- a/simple_history/locale/cs_CZ/LC_MESSAGES/django.po +++ b/simple_history/locale/cs_CZ/LC_MESSAGES/django.po @@ -59,30 +59,6 @@ msgstr "Změněno" 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 " @@ -121,6 +97,30 @@ msgstr "Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objek 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/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:42 +msgid "None" +msgstr "Žádné" + #: simple_history/templates/simple_history/submit_line.html:4 msgid "Revert" msgstr "Vrátit změny" diff --git a/simple_history/locale/de/LC_MESSAGES/django.po b/simple_history/locale/de/LC_MESSAGES/django.po index 37ac754..e38d456 100644 --- a/simple_history/locale/de/LC_MESSAGES/django.po +++ b/simple_history/locale/de/LC_MESSAGES/django.po @@ -48,30 +48,6 @@ msgstr "Geändert" msgid "Deleted" msgstr "Gelöscht" -#: 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/Uhrzeit" - -#: simple_history/templates/simple_history/_object_history_list.html:14 -msgid "Comment" -msgstr "Kommentar" - -#: simple_history/templates/simple_history/_object_history_list.html:15 -msgid "Changed by" -msgstr "Geändert von" - -#: simple_history/templates/simple_history/_object_history_list.html:16 -msgid "Change reason" -msgstr "Änderungsgrund" - -#: simple_history/templates/simple_history/_object_history_list.html:37 -msgid "None" -msgstr "Keine/r" - #: 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 " @@ -108,6 +84,30 @@ msgstr "" msgid "Or press the 'Change History' button to edit the history." msgstr "Oder wählen Sie 'Historie ändern', um diese zu bearbeiten." +#: 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/Uhrzeit" + +#: simple_history/templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "Kommentar" + +#: simple_history/templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "Geändert von" + +#: simple_history/templates/simple_history/object_history_list.html:16 +msgid "Change reason" +msgstr "Änderungsgrund" + +#: simple_history/templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "Keine/r" + #: simple_history/templates/simple_history/submit_line.html:3 msgid "Revert" msgstr "Wiederherstellen" diff --git a/simple_history/locale/fr/LC_MESSAGES/django.po b/simple_history/locale/fr/LC_MESSAGES/django.po index 883afba..6528938 100644 --- a/simple_history/locale/fr/LC_MESSAGES/django.po +++ b/simple_history/locale/fr/LC_MESSAGES/django.po @@ -59,30 +59,6 @@ msgstr "Modifié" msgid "Deleted" msgstr "Effacé" -#: .\simple_history\templates\simple_history\_object_history_list.html:9 -msgid "Object" -msgstr "Objet" - -#: .\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 -msgid "Comment" -msgstr "Commentaire" - -#: .\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 -msgid "Change reason" -msgstr "Raison de la modification" - -#: .\simple_history\templates\simple_history\_object_history_list.html:37 -msgid "None" -msgstr "Aucun" - #: .\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 " @@ -125,6 +101,30 @@ 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\object_history_list.html:9 +msgid "Object" +msgstr "Objet" + +#: .\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 +msgid "Comment" +msgstr "Commentaire" + +#: .\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 +msgid "Change reason" +msgstr "Raison de la modification" + +#: .\simple_history\templates\simple_history\object_history_list.html:42 +msgid "None" +msgstr "Aucun" + #: .\simple_history\templates\simple_history\submit_line.html:4 msgid "Revert" msgstr "Rétablir" diff --git a/simple_history/locale/id/LC_MESSAGES/django.po b/simple_history/locale/id/LC_MESSAGES/django.po index 86e9e28..b649d6b 100644 --- a/simple_history/locale/id/LC_MESSAGES/django.po +++ b/simple_history/locale/id/LC_MESSAGES/django.po @@ -58,30 +58,6 @@ msgstr "Diubah" msgid "Deleted" msgstr "Dihapus" -#: .\simple_history\templates\simple_history\_object_history_list.html:9 -msgid "Object" -msgstr "Objek" - -#: .\simple_history\templates\simple_history\_object_history_list.html:13 -msgid "Date/time" -msgstr "Tanggal/waktu" - -#: .\simple_history\templates\simple_history\_object_history_list.html:14 -msgid "Comment" -msgstr "Komentar" - -#: .\simple_history\templates\simple_history\_object_history_list.html:15 -msgid "Changed by" -msgstr "Diubah oleh" - -#: .\simple_history\templates\simple_history\_object_history_list.html:16 -msgid "Change reason" -msgstr "Alasan perubahan" - -#: .\simple_history\templates\simple_history\_object_history_list.html:37 -msgid "None" -msgstr "Tidak ada" - #: .\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 " @@ -122,6 +98,30 @@ msgstr "" msgid "Press the 'Change History' button below to edit the history." msgstr "Tekan tombol 'Ubah Riwayat' di bawah ini untuk mengubah riwayat." +#: .\simple_history\templates\simple_history\object_history_list.html:9 +msgid "Object" +msgstr "Objek" + +#: .\simple_history\templates\simple_history\object_history_list.html:13 +msgid "Date/time" +msgstr "Tanggal/waktu" + +#: .\simple_history\templates\simple_history\object_history_list.html:14 +msgid "Comment" +msgstr "Komentar" + +#: .\simple_history\templates\simple_history\object_history_list.html:15 +msgid "Changed by" +msgstr "Diubah oleh" + +#: .\simple_history\templates\simple_history\object_history_list.html:16 +msgid "Change reason" +msgstr "Alasan perubahan" + +#: .\simple_history\templates\simple_history\object_history_list.html:42 +msgid "None" +msgstr "Tidak ada" + #: .\simple_history\templates\simple_history\submit_line.html:4 msgid "Revert" msgstr "Kembalikan" diff --git a/simple_history/locale/nb/LC_MESSAGES/django.mo b/simple_history/locale/nb/LC_MESSAGES/django.mo index 89ba69d..594a7b8 100644 Binary files a/simple_history/locale/nb/LC_MESSAGES/django.mo and b/simple_history/locale/nb/LC_MESSAGES/django.mo differ diff --git a/simple_history/locale/nb/LC_MESSAGES/django.po b/simple_history/locale/nb/LC_MESSAGES/django.po index e2167f8..334f336 100644 --- a/simple_history/locale/nb/LC_MESSAGES/django.po +++ b/simple_history/locale/nb/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-07-09 13:55+0200\n" -"PO-Revision-Date: 2023-07-09 12:31+0200\n" +"PO-Revision-Date: 2024-04-11 19:34+0200\n" "Last-Translator: Anders \n" "Language-Team: Norwegian Bokmål \n" "Language: nb\n" @@ -19,31 +19,31 @@ msgstr "" # Dette er en tittel, ikke en handlingsbeskrivelse, så f.eks. # "Se/Vis (endrings)historikk" hadde ikke fungert så bra -#: simple_history/admin.py:102 +#: simple_history/admin.py:109 #, python-format msgid "View history: %s" msgstr "Endringshistorikk: %s" -#: simple_history/admin.py:104 +#: simple_history/admin.py:111 #, python-format msgid "Change history: %s" msgstr "Endringshistorikk: %s" -#: simple_history/admin.py:110 +#: simple_history/admin.py:117 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s «%(obj)s» ble endret." -#: simple_history/admin.py:116 +#: simple_history/admin.py:123 msgid "You may edit it again below" msgstr "Du kan redigere videre nedenfor" -#: simple_history/admin.py:217 +#: simple_history/admin.py:224 #, python-format msgid "View %s" msgstr "Se %s" -#: simple_history/admin.py:219 +#: simple_history/admin.py:226 #, python-format msgid "Revert %s" msgstr "Tilbakestill %s" @@ -60,29 +60,10 @@ msgstr "Endret" msgid "Deleted" msgstr "Slettet" -#: 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 "Dato/tid" - -#: simple_history/templates/simple_history/_object_history_list.html:14 -msgid "Comment" -msgstr "Kommentar" - -#: simple_history/templates/simple_history/_object_history_list.html:15 -msgid "Changed by" -msgstr "Endret av" - -#: simple_history/templates/simple_history/_object_history_list.html:16 -msgid "Change reason" -msgstr "Endringsårsak" - -#: simple_history/templates/simple_history/_object_history_list.html:37 -msgid "None" -msgstr "Ingen" +#: simple_history/models.py:1124 +#, python-format +msgid "Deleted %(type_name)s" +msgstr "Slettet %(type_name)s" #: simple_history/templates/simple_history/object_history.html:11 msgid "" @@ -125,6 +106,34 @@ msgstr "" msgid "Press the 'Change History' button below to edit the history." msgstr "Trykk på 'Endre historikk'-knappen under for å endre historikken." +#: 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 "Dato/tid" + +#: simple_history/templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "Kommentar" + +#: simple_history/templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "Endret av" + +#: simple_history/templates/simple_history/object_history_list.html:16 +msgid "Change reason" +msgstr "Endringsårsak" + +#: simple_history/templates/simple_history/object_history_list.html:17 +msgid "Changes" +msgstr "Endringer" + +#: simple_history/templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "Ingen" + #: simple_history/templates/simple_history/submit_line.html:4 msgid "Revert" msgstr "Tilbakestill" diff --git a/simple_history/locale/pl/LC_MESSAGES/django.po b/simple_history/locale/pl/LC_MESSAGES/django.po index 161b014..d420d2e 100644 --- a/simple_history/locale/pl/LC_MESSAGES/django.po +++ b/simple_history/locale/pl/LC_MESSAGES/django.po @@ -57,26 +57,6 @@ msgid "" msgstr "" "Wybierz datę z poniższej listy aby przywrócić poprzednią wersję tego obiektu." -#: templates/simple_history/object_history.html:17 -msgid "Object" -msgstr "Obiekt" - -#: templates/simple_history/object_history.html:18 -msgid "Date/time" -msgstr "Data/czas" - -#: templates/simple_history/object_history.html:19 -msgid "Comment" -msgstr "Komentarz" - -#: templates/simple_history/object_history.html:20 -msgid "Changed by" -msgstr "Zmodyfikowane przez" - -#: templates/simple_history/object_history.html:38 -msgid "None" -msgstr "Brak" - #: templates/simple_history/object_history.html:46 msgid "This object doesn't have a change history." msgstr "Ten obiekt nie ma historii zmian." @@ -103,6 +83,26 @@ msgstr "Naciśnij przycisk „Przywróć” aby przywrócić tę wersję obiektu msgid "Or press the 'Change History' button to edit the history." msgstr "Lub naciśnij przycisk „Historia zmian” aby edytować historię." +#: templates/simple_history/object_history_list.html:9 +msgid "Object" +msgstr "Obiekt" + +#: templates/simple_history/object_history_list.html:13 +msgid "Date/time" +msgstr "Data/czas" + +#: templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "Komentarz" + +#: templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "Zmodyfikowane przez" + +#: templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "Brak" + #: templates/simple_history/submit_line.html:3 msgid "Revert" msgstr "Przywróć" diff --git a/simple_history/locale/pt_BR/LC_MESSAGES/django.po b/simple_history/locale/pt_BR/LC_MESSAGES/django.po index b328efa..f5286ec 100644 --- a/simple_history/locale/pt_BR/LC_MESSAGES/django.po +++ b/simple_history/locale/pt_BR/LC_MESSAGES/django.po @@ -57,26 +57,6 @@ msgstr "" "Escolha a data desejada na lista a seguir para reverter as modificações " "feitas nesse objeto." -#: simple_history/templates/simple_history/object_history.html:17 -msgid "Object" -msgstr "Objeto" - -#: simple_history/templates/simple_history/object_history.html:18 -msgid "Date/time" -msgstr "Data/hora" - -#: simple_history/templates/simple_history/object_history.html:19 -msgid "Comment" -msgstr "Comentário" - -#: simple_history/templates/simple_history/object_history.html:20 -msgid "Changed by" -msgstr "Modificado por" - -#: simple_history/templates/simple_history/object_history.html:38 -msgid "None" -msgstr "-" - #: simple_history/templates/simple_history/object_history.html:46 msgid "This object doesn't have a change history." msgstr "Esse objeto não tem um histórico de modificações." @@ -104,6 +84,26 @@ msgstr "" msgid "Or press the 'Change History' button to edit the history." msgstr "Ou clique em 'Histórico de Modificações' para modificar o histórico." +#: simple_history/templates/simple_history/object_history_list.html:9 +msgid "Object" +msgstr "Objeto" + +#: simple_history/templates/simple_history/object_history_list.html:13 +msgid "Date/time" +msgstr "Data/hora" + +#: simple_history/templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "Comentário" + +#: simple_history/templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "Modificado por" + +#: simple_history/templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "-" + #: simple_history/templates/simple_history/submit_line.html:3 msgid "Revert" msgstr "Reverter" diff --git a/simple_history/locale/ru_RU/LC_MESSAGES/django.po b/simple_history/locale/ru_RU/LC_MESSAGES/django.po index 9865513..c50888e 100644 --- a/simple_history/locale/ru_RU/LC_MESSAGES/django.po +++ b/simple_history/locale/ru_RU/LC_MESSAGES/django.po @@ -50,26 +50,6 @@ msgstr "Изменено" msgid "Deleted" msgstr "Удалено" -#: templates/simple_history/_object_history_list.html:9 -msgid "Object" -msgstr "Объект" - -#: templates/simple_history/_object_history_list.html:13 -msgid "Date/time" -msgstr "Дата/время" - -#: templates/simple_history/_object_history_list.html:14 -msgid "Comment" -msgstr "Комментарий" - -#: templates/simple_history/_object_history_list.html:15 -msgid "Changed by" -msgstr "Изменено" - -#: templates/simple_history/_object_history_list.html:36 -msgid "None" -msgstr "None" - #: templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " @@ -105,6 +85,30 @@ msgstr "" msgid "Or press the 'Change History' button to edit the history." msgstr "Или нажмите кнопку 'Изменить запись', чтобы изменить историю." +#: templates/simple_history/object_history_list.html:9 +msgid "Object" +msgstr "Объект" + +#: templates/simple_history/object_history_list.html:13 +msgid "Date/time" +msgstr "Дата/время" + +#: templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "Комментарий" + +#: templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "Изменено" + +#: templates/simple_history/object_history_list.html:16 +msgid "Change reason" +msgstr "Причина изменения" + +#: templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "None" + #: templates/simple_history/submit_line.html:3 msgid "Revert" msgstr "Восстановить" @@ -112,7 +116,3 @@ msgstr "Восстановить" #: templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Изменить запись" - -#: templates/simple_history/_object_history_list.html:16 -msgid "Change reason" -msgstr "Причина изменения" diff --git a/simple_history/locale/ur/LC_MESSAGES/django.po b/simple_history/locale/ur/LC_MESSAGES/django.po index dc9c014..a55605e 100644 --- a/simple_history/locale/ur/LC_MESSAGES/django.po +++ b/simple_history/locale/ur/LC_MESSAGES/django.po @@ -58,30 +58,6 @@ msgstr "بدل گیا" msgid "Deleted" msgstr "حذف کر دیا گیا" -#: .\simple_history\templates\simple_history\_object_history_list.html:9 -msgid "Object" -msgstr "آبجیکٹ" - -#: .\simple_history\templates\simple_history\_object_history_list.html:13 -msgid "Date/time" -msgstr "تاریخ/وقت" - -#: .\simple_history\templates\simple_history\_object_history_list.html:14 -msgid "Comment" -msgstr "تبصرہ" - -#: .\simple_history\templates\simple_history\_object_history_list.html:15 -msgid "Changed by" -msgstr "کی طرف سے تبدیل" - -#: .\simple_history\templates\simple_history\_object_history_list.html:16 -msgid "Change reason" -msgstr "تبدیلی کا سبب" - -#: .\simple_history\templates\simple_history\_object_history_list.html:37 -msgid "None" -msgstr "کوئی نہیں" - #: .\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 " @@ -121,6 +97,30 @@ msgstr "" msgid "Press the 'Change History' button below to edit the history." msgstr "تاریخ میں ترمیم کرنے کے لیے نیچے دیے گئے 'تاریخ کو تبدیل کریں' کے بٹن کو دبائیں." +#: .\simple_history\templates\simple_history\object_history_list.html:9 +msgid "Object" +msgstr "آبجیکٹ" + +#: .\simple_history\templates\simple_history\object_history_list.html:13 +msgid "Date/time" +msgstr "تاریخ/وقت" + +#: .\simple_history\templates\simple_history\object_history_list.html:14 +msgid "Comment" +msgstr "تبصرہ" + +#: .\simple_history\templates\simple_history\object_history_list.html:15 +msgid "Changed by" +msgstr "کی طرف سے تبدیل" + +#: .\simple_history\templates\simple_history\object_history_list.html:16 +msgid "Change reason" +msgstr "تبدیلی کا سبب" + +#: .\simple_history\templates\simple_history\object_history_list.html:42 +msgid "None" +msgstr "کوئی نہیں" + #: .\simple_history\templates\simple_history\submit_line.html:4 msgid "Revert" msgstr "تبدیلی واپس کریں" diff --git a/simple_history/locale/zh_Hans/LC_MESSAGES/django.po b/simple_history/locale/zh_Hans/LC_MESSAGES/django.po index c69ba1c..0fe74eb 100644 --- a/simple_history/locale/zh_Hans/LC_MESSAGES/django.po +++ b/simple_history/locale/zh_Hans/LC_MESSAGES/django.po @@ -58,30 +58,6 @@ msgstr "已修改" msgid "Deleted" msgstr "已删除" -#: simple_history/templates/simple_history/_object_history_list.html:9 -msgid "Object" -msgstr "记录对象" - -#: simple_history/templates/simple_history/_object_history_list.html:13 -msgid "Date/time" -msgstr "日期/时间" - -#: simple_history/templates/simple_history/_object_history_list.html:14 -msgid "Comment" -msgstr "备注" - -#: simple_history/templates/simple_history/_object_history_list.html:15 -msgid "Changed by" -msgstr "修改人" - -#: simple_history/templates/simple_history/_object_history_list.html:16 -msgid "Change reason" -msgstr "修改原因" - -#: simple_history/templates/simple_history/_object_history_list.html:37 -msgid "None" -msgstr "无" - #: 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 " @@ -119,6 +95,30 @@ msgstr "按下面的“还原”按钮还原记录到当前版本。" msgid "Press the 'Change History' button below to edit the history." msgstr "按下面的“修改历史记录”按钮编辑历史记录。" +#: simple_history/templates/simple_history/object_history_list.html:9 +msgid "Object" +msgstr "记录对象" + +#: simple_history/templates/simple_history/object_history_list.html:13 +msgid "Date/time" +msgstr "日期/时间" + +#: simple_history/templates/simple_history/object_history_list.html:14 +msgid "Comment" +msgstr "备注" + +#: simple_history/templates/simple_history/object_history_list.html:15 +msgid "Changed by" +msgstr "修改人" + +#: simple_history/templates/simple_history/object_history_list.html:16 +msgid "Change reason" +msgstr "修改原因" + +#: simple_history/templates/simple_history/object_history_list.html:42 +msgid "None" +msgstr "无" + #: simple_history/templates/simple_history/submit_line.html:4 msgid "Revert" msgstr "还原" diff --git a/simple_history/manager.py b/simple_history/manager.py index 97d7452..5fa404d 100644 --- a/simple_history/manager.py +++ b/simple_history/manager.py @@ -1,6 +1,6 @@ from django.conf import settings -from django.db import connection, models -from django.db.models import OuterRef, QuerySet, Subquery +from django.db import models +from django.db.models import Exists, OuterRef, Q, QuerySet from django.utils import timezone from simple_history.utils import ( @@ -18,9 +18,9 @@ class HistoricalQuerySet(QuerySet): Enables additional functionality when working with historical records. For additional history on this topic, see: - - https://github.com/jazzband/django-simple-history/pull/229 - - https://github.com/jazzband/django-simple-history/issues/354 - - https://github.com/jazzband/django-simple-history/issues/397 + - https://github.com/django-commons/django-simple-history/pull/229 + - https://github.com/django-commons/django-simple-history/issues/354 + - https://github.com/django-commons/django-simple-history/issues/397 """ def __init__(self, *args, **kwargs): @@ -29,13 +29,11 @@ def __init__(self, *args, **kwargs): self._as_of = None self._pk_attr = self.model.instance_type._meta.pk.attname - def as_instances(self): + def as_instances(self) -> "HistoricalQuerySet": """ Return a queryset that generates instances instead of historical records. Queries against the resulting queryset will translate `pk` into the primary key field of the original type. - - Returns a queryset. """ if not self._as_instances: result = self.exclude(history_type="-") @@ -44,7 +42,7 @@ def as_instances(self): result = self._clone() return result - def filter(self, *args, **kwargs): + def filter(self, *args, **kwargs) -> "HistoricalQuerySet": """ If a `pk` filter arrives and the queryset is returning instances then the caller actually wants to filter based on the original @@ -55,54 +53,49 @@ def filter(self, *args, **kwargs): kwargs[self._pk_attr] = kwargs.pop("pk") return super().filter(*args, **kwargs) - def latest_of_each(self): + def latest_of_each(self) -> "HistoricalQuerySet": """ Ensures results in the queryset are the latest historical record for each - primary key. Deletions are not removed. - - Returns a queryset. + primary key. This includes deletion records. """ - # If using MySQL, need to get a list of IDs in memory and then use them for the - # second query. - # Does mean two loops through the DB to get the full set, but still a speed - # improvement. - backend = connection.vendor - if backend == "mysql": - history_ids = {} - for item in self.order_by("-history_date", "-pk"): - if getattr(item, self._pk_attr) not in history_ids: - history_ids[getattr(item, self._pk_attr)] = item.pk - latest_historics = self.filter(history_id__in=history_ids.values()) - elif backend == "postgresql": - latest_pk_attr_historic_ids = ( - self.order_by(self._pk_attr, "-history_date", "-pk") - .distinct(self._pk_attr) - .values_list("pk", flat=True) - ) - latest_historics = self.filter(history_id__in=latest_pk_attr_historic_ids) - else: - latest_pk_attr_historic_ids = ( - self.filter(**{self._pk_attr: OuterRef(self._pk_attr)}) - .order_by("-history_date", "-pk") - .values("pk")[:1] - ) - latest_historics = self.filter( - history_id__in=Subquery(latest_pk_attr_historic_ids) - ) - return latest_historics + # Subquery for finding the records that belong to the same history-tracked + # object as the record from the outer query (identified by `_pk_attr`), + # and that have a later `history_date` than the outer record. + # The very latest record of a history-tracked object should be excluded from + # this query - which will make it included in the `~Exists` query below. + later_records = self.filter( + Q(**{self._pk_attr: OuterRef(self._pk_attr)}), + Q(history_date__gt=OuterRef("history_date")), + ) + + # Filter the records to only include those for which the `later_records` + # subquery does not return any results. + return self.filter(~Exists(later_records)) - def _clone(self): + def _select_related_history_tracked_objs(self) -> "HistoricalQuerySet": + """ + A convenience method that calls ``select_related()`` with all the names of + the model's history-tracked ``ForeignKey`` fields. + """ + field_names = [ + field.name + for field in self.model.tracked_fields + if isinstance(field, models.ForeignKey) + ] + return self.select_related(*field_names) + + def _clone(self) -> "HistoricalQuerySet": c = super()._clone() c._as_instances = self._as_instances c._as_of = self._as_of c._pk_attr = self._pk_attr return c - def _fetch_all(self): + def _fetch_all(self) -> None: super()._fetch_all() self._instanceize() - def _instanceize(self): + def _instanceize(self) -> None: """ Convert the result cache to instances if possible and it has not already been done. If a query extracts `.values(...)` then the result cache will not contain diff --git a/simple_history/models.py b/simple_history/models.py index a4b9227..a2edaf3 100644 --- a/simple_history/models.py +++ b/simple_history/models.py @@ -2,8 +2,12 @@ import importlib import uuid import warnings +from collections.abc import Iterable, Sequence +from dataclasses import dataclass from functools import partial +from typing import TYPE_CHECKING, Any, Union +import django from django.apps import apps from django.conf import settings from django.contrib import admin @@ -15,7 +19,9 @@ from django.db.models.fields.related import ForeignKey from django.db.models.fields.related_descriptors import ( ForwardManyToOneDescriptor, + ForwardOneToOneDescriptor, ReverseManyToOneDescriptor, + ReverseOneToOneDescriptor, create_reverse_many_to_one_manager, ) from django.db.models.query import QuerySet @@ -28,9 +34,7 @@ from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ -from simple_history import utils - -from . import exceptions +from . import exceptions, utils from .manager import ( SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoricalQuerySet, @@ -43,13 +47,17 @@ pre_create_historical_m2m_records, pre_create_historical_record, ) -from .utils import get_change_reason_from_object try: from asgiref.local import Local as LocalContext except ImportError: from threading import local as LocalContext +if TYPE_CHECKING: + ModelTypeHint = models.Model +else: + ModelTypeHint = object + registered_models = {} @@ -175,9 +183,9 @@ def contribute_to_class(self, cls, name): def add_extra_methods(self, cls): def save_without_historical_record(self, *args, **kwargs): """ - Save model without saving a historical record + Save the model instance without creating a historical record. - Make sure you know what you're doing before you use this method. + Make sure you know what you're doing before using this method. """ self.skip_history_when_saving = True try: @@ -214,6 +222,7 @@ def finalize(self, sender, **kwargs): # so the signal handlers can't use weak references. models.signals.post_save.connect(self.post_save, sender=sender, weak=False) models.signals.post_delete.connect(self.post_delete, sender=sender, weak=False) + models.signals.pre_delete.connect(self.pre_delete, sender=sender, weak=False) m2m_fields = self.get_m2m_fields_from_model(sender) @@ -646,8 +655,9 @@ def get_meta_options(self, model): 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"): + if hasattr(instance, "skip_history_when_saving"): return + if not kwargs.get("raw", False): self.create_historical_record(instance, created and "+" or "~", using=using) @@ -660,14 +670,33 @@ def post_delete(self, instance, using=None, **kwargs): else: self.create_historical_record(instance, "-", using=using) + def pre_delete(self, instance, **kwargs): + """ + pre_delete method to ensure all deferred fileds are loaded on the model + """ + # First check that history is enabled (on model and globally) + if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): + return + if not hasattr(instance._meta, "simple_history_manager_attribute"): + return + fields = self.fields_included(instance) + field_attrs = {field.attname for field in fields} + deferred_attrs = instance.get_deferred_fields() + # Load all deferred fields that are present in fields_included + fields = field_attrs.intersection(deferred_attrs) + if fields: + instance.refresh_from_db(fields=fields) + def get_change_reason_for_object(self, instance, history_type, using): """ Get change reason for object. Customize this method to automatically fill change reason from context. """ - return get_change_reason_from_object(instance) + return utils.get_change_reason_from_object(instance) def m2m_changed(self, instance, action, attr, pk_set, reverse, **_): + if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): + return if hasattr(instance, "skip_history_when_saving"): return @@ -680,21 +709,21 @@ def create_historical_record_m2ms(self, history_instance, instance): m2m_history_model = self.m2m_models[field] original_instance = history_instance.instance through_model = getattr(original_instance, field.name).through + through_model_field_names = [f.name for f in through_model._meta.fields] + through_model_fk_field_names = [ + f.name for f in through_model._meta.fields if isinstance(f, ForeignKey) + ] insert_rows = [] - # `m2m_field_name()` is part of Django's internal API - through_field_name = field.m2m_field_name() - + through_field_name = utils.get_m2m_field_name(field) rows = through_model.objects.filter(**{through_field_name: instance}) - + rows = rows.select_related(*through_model_fk_field_names) for row in rows: insert_row = {"history": history_instance} - for through_model_field in through_model._meta.fields: - insert_row[through_model_field.name] = getattr( - row, through_model_field.name - ) + for field_name in through_model_field_names: + insert_row[field_name] = getattr(row, field_name) insert_rows.append(m2m_history_model(**insert_row)) pre_create_historical_m2m_records.send( @@ -813,26 +842,28 @@ def transform_field(field): # Unique fields can no longer be guaranteed unique, # but they should still be indexed for faster lookups. field.primary_key = False + # DEV: Remove this check (but keep the contents) when the minimum required + # Django version is 5.1 + if django.VERSION >= (5, 1): + field.unique = False + # (Django < 5.1) Can't set `unique` as it's a property, so set the backing field + # (Django >= 5.1) Set the backing field in addition to the cached property + # above, to cover all bases field._unique = False field.db_index = True field.serialize = True -class HistoricForwardManyToOneDescriptor(ForwardManyToOneDescriptor): - """ - Overrides get_queryset to provide historic query support, should the - instance be historic (and therefore was generated by a timepoint query) - and the other side of the relation also uses a history manager. - """ +class HistoricDescriptorMixin: - def get_queryset(self, **hints) -> QuerySet: + def get_queryset(self, **hints): instance = hints.get("instance") if instance: history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None) histmgr = getattr( - self.field.remote_field.model, + self.get_related_model(), getattr( - self.field.remote_field.model._meta, + self.get_related_model()._meta, "simple_history_manager_attribute", "_notthere", ), @@ -843,6 +874,19 @@ def get_queryset(self, **hints) -> QuerySet: return super().get_queryset(**hints) +class HistoricForwardManyToOneDescriptor( + HistoricDescriptorMixin, ForwardManyToOneDescriptor +): + """ + Overrides get_queryset to provide historic query support, should the + instance be historic (and therefore was generated by a timepoint query) + and the other side of the relation also uses a history manager. + """ + + def get_related_model(self): + return self.field.remote_field.model + + class HistoricReverseManyToOneDescriptor(ReverseManyToOneDescriptor): """ Overrides get_queryset to provide historic query support, should the @@ -856,10 +900,14 @@ def related_manager_cls(self): class HistoricRelationModelManager(related_model._default_manager.__class__): def get_queryset(self): + cache_name = ( + # DEV: Remove this when support for Django 5.0 has been dropped + self.field.remote_field.get_cache_name() + if django.VERSION < (5, 1) + else self.field.remote_field.cache_name + ) try: - return self.instance._prefetched_objects_cache[ - self.field.remote_field.get_cache_name() - ] + return self.instance._prefetched_objects_cache[cache_name] except (AttributeError, KeyError): history = getattr( self.instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None @@ -907,6 +955,54 @@ class HistoricForeignKey(ForeignKey): related_accessor_class = HistoricReverseManyToOneDescriptor +class HistoricForwardOneToOneDescriptor( + HistoricDescriptorMixin, ForwardOneToOneDescriptor +): + """ + Overrides get_queryset to provide historic query support, should the + instance be historic (and therefore was generated by a timepoint query) + and the other side of the relation also uses a history manager. + """ + + def get_related_model(self): + return self.field.remote_field.model + + +class HistoricReverseOneToOneDescriptor( + HistoricDescriptorMixin, ReverseOneToOneDescriptor +): + """ + Overrides get_queryset to provide historic query support, should the + instance be historic (and therefore was generated by a timepoint query) + and the other side of the relation also uses a history manager. + """ + + def get_related_model(self): + return self.related.related_model + + +class HistoricOneToOneField(models.OneToOneField): + """ + Allows one to one fields to work properly from a historic instance. + + If you use as_of queries to extract historical instances from + a model, and you have other models that are related by one to + one fields and also historic, changing them to a + HistoricOneToOneField field type will allow you to naturally + cross the relationship boundary at the same point in time as + the origin instance. + + A historic instance maintains an attribute ("_historic") when + it is historic, holding the historic record instance and the + timepoint used to query it ("_as_of"). HistoricOneToOneField + looks for this and uses an as_of query against the related + object so the relationship is assessed at the same timepoint. + """ + + forward_related_accessor_class = HistoricForwardOneToOneDescriptor + related_accessor_class = HistoricReverseOneToOneDescriptor + + def is_historic(instance): """ Returns True if the instance was acquired with an as_of timepoint. @@ -934,13 +1030,34 @@ def __get__(self, instance, owner): return self.model(**values) -class HistoricalChanges: - def diff_against(self, old_history, excluded_fields=None, included_fields=None): +class HistoricalChanges(ModelTypeHint): + def diff_against( + self, + old_history: "HistoricalChanges", + excluded_fields: Iterable[str] = None, + included_fields: Iterable[str] = None, + *, + foreign_keys_are_objs=False, + ) -> "ModelDelta": + """ + :param old_history: + :param excluded_fields: The names of fields to exclude from diffing. + This takes precedence over ``included_fields``. + :param included_fields: The names of the only fields to include when diffing. + If not provided, all history-tracked fields will be included. + :param foreign_keys_are_objs: If ``False``, the returned diff will only contain + the raw PKs of any ``ForeignKey`` fields. + If ``True``, the diff will contain the actual related model objects + instead of just the PKs; deleted related objects will be instances of + ``DeletedObject``. + Note that passing ``True`` will necessarily query the database if the + related objects have not been prefetched (using e.g. + ``select_related()``). + """ if not isinstance(old_history, type(self)): raise TypeError( - ("unsupported type(s) for diffing: " "'{}' and '{}'").format( - type(self), type(old_history) - ) + "unsupported type(s) for diffing:" + f" '{type(self)}' and '{type(old_history)}'" ) if excluded_fields is None: excluded_fields = set() @@ -958,59 +1075,172 @@ def diff_against(self, old_history, excluded_fields=None, included_fields=None): ) m2m_fields = set(included_m2m_fields).difference(excluded_fields) + changes = [ + *self._get_field_changes_for_diff( + old_history, fields, foreign_keys_are_objs + ), + *self._get_m2m_field_changes_for_diff( + old_history, m2m_fields, foreign_keys_are_objs + ), + ] + # Sort by field (attribute) name, to ensure a consistent order + changes.sort(key=lambda change: change.field) + changed_fields = [change.field for change in changes] + return ModelDelta(changes, changed_fields, old_history, self) + + def _get_field_changes_for_diff( + self, + old_history: "HistoricalChanges", + fields: Iterable[str], + foreign_keys_are_objs: bool, + ) -> list["ModelChange"]: + """Helper method for ``diff_against()``.""" changes = [] - changed_fields = [] old_values = model_to_dict(old_history, fields=fields) - current_values = model_to_dict(self, fields=fields) + new_values = model_to_dict(self, fields=fields) for field in fields: - old_value = old_values[field] - current_value = current_values[field] + old_value = None if old_history.history_type == "-" else old_values[field] + new_value = None if self.history_type == "-" else new_values[field] + + if old_value != new_value: + field_meta = self._meta.get_field(field) + if foreign_keys_are_objs and isinstance(field_meta, ForeignKey): + # Set the fields to their related model objects instead of + # the raw PKs from `model_to_dict()` + def get_value(record, foreign_key): + try: + value = getattr(record, field) + # `value` seems to be None (without raising this exception) + # if the object has not been refreshed from the database + except ObjectDoesNotExist: + value = None + + if value is None: + value = DeletedObject(field_meta.related_model, foreign_key) + return value + + old_value = get_value(old_history, old_value) + new_value = get_value(self, new_value) + + change = ModelChange(field, old_value, new_value) + changes.append(change) - if old_value != current_value: - changes.append(ModelChange(field, old_value, current_value)) - changed_fields.append(field) + return changes - # Separately compare m2m fields: - for field in m2m_fields: - # First retrieve a single item to get the field names from: - reference_history_m2m_item = ( - getattr(old_history, field).first() or getattr(self, field).first() - ) - history_field_names = [] - if reference_history_m2m_item: - # Create a list of field names to compare against. - # The list is generated without the primary key of the intermediate - # table, the foreign key to the history record, and the actual 'history' - # field, to avoid false positives while diffing. - history_field_names = [ - f.name - for f in reference_history_m2m_item._meta.fields - if f.editable and f.name not in ["id", "m2m_history_id", "history"] - ] + def _get_m2m_field_changes_for_diff( + self, + old_history: "HistoricalChanges", + m2m_fields: Iterable[str], + foreign_keys_are_objs: bool, + ) -> list["ModelChange"]: + """Helper method for ``diff_against()``.""" + changes = [] - old_rows = list(getattr(old_history, field).values(*history_field_names)) - new_rows = list(getattr(self, field).values(*history_field_names)) + for field in m2m_fields: + original_field_meta = self.instance_type._meta.get_field(field) + reverse_field_name = utils.get_m2m_reverse_field_name(original_field_meta) + # Sort the M2M rows by the related object, to ensure a consistent order + old_m2m_qs = getattr(old_history, field).order_by(reverse_field_name) + new_m2m_qs = getattr(self, field).order_by(reverse_field_name) + m2m_through_model_opts = new_m2m_qs.model._meta + + # Create a list of field names to compare against. + # The list is generated without the PK of the intermediate (through) + # table, the foreign key to the history record, and the actual `history` + # field, to avoid false positives while diffing. + through_model_fields = [ + f.name + for f in m2m_through_model_opts.fields + if f.editable and f.name not in ["id", "m2m_history_id", "history"] + ] + old_rows = list(old_m2m_qs.values(*through_model_fields)) + new_rows = list(new_m2m_qs.values(*through_model_fields)) if old_rows != new_rows: + if foreign_keys_are_objs: + fk_fields = [ + f + for f in through_model_fields + if isinstance(m2m_through_model_opts.get_field(f), ForeignKey) + ] + + # Set the through fields to their related model objects instead of + # the raw PKs from `values()` + def rows_with_foreign_key_objs(m2m_qs): + def get_value(obj, through_field): + try: + value = getattr(obj, through_field) + # If the related object has been deleted, `value` seems to + # usually already be None instead of raising this exception + except ObjectDoesNotExist: + value = None + + if value is None: + meta = m2m_through_model_opts.get_field(through_field) + foreign_key = getattr(obj, meta.attname) + value = DeletedObject(meta.related_model, foreign_key) + return value + + # Replicate the format of the return value of QuerySet.values() + return [ + { + through_field: get_value(through_obj, through_field) + for through_field in through_model_fields + } + for through_obj in m2m_qs.select_related(*fk_fields) + ] + + old_rows = rows_with_foreign_key_objs(old_m2m_qs) + new_rows = rows_with_foreign_key_objs(new_m2m_qs) + change = ModelChange(field, old_rows, new_rows) changes.append(change) - changed_fields.append(field) - return ModelDelta(changes, changed_fields, old_history, self) + return changes +@dataclass(frozen=True) +class DeletedObject: + model: type[models.Model] + pk: Any + + def __str__(self): + deleted_model_str = _("Deleted %(type_name)s") % { + "type_name": self.model._meta.verbose_name, + } + return f"{deleted_model_str} (pk={self.pk})" + + +# Either: +# - The value of a foreign key field: +# - If ``foreign_keys_are_objs=True`` is passed to ``diff_against()``: +# Either the related object or ``DeletedObject``. +# - Otherwise: +# The PK of the related object. +# +# - The value of a many-to-many field: +# A list of dicts from the through model field names to either: +# - If ``foreign_keys_are_objs=True`` is passed to ``diff_against()``: +# Either the through model's related objects or ``DeletedObject``. +# - Otherwise: +# The PK of the through model's related objects. +# +# - Any of the other possible values of a model field. +ModelChangeValue = Union[Any, DeletedObject, list[dict[str, Union[Any, DeletedObject]]]] + + +@dataclass(frozen=True) class ModelChange: - def __init__(self, field_name, old_value, new_value): - self.field = field_name - self.old = old_value - self.new = new_value + field: str + old: ModelChangeValue + new: ModelChangeValue +@dataclass(frozen=True) class ModelDelta: - def __init__(self, changes, changed_fields, old_record, new_record): - self.changes = changes - self.changed_fields = changed_fields - self.old_record = old_record - self.new_record = new_record + changes: Sequence[ModelChange] + changed_fields: Sequence[str] + old_record: HistoricalChanges + new_record: HistoricalChanges diff --git a/simple_history/registry_tests/tests.py b/simple_history/registry_tests/tests.py index ff55f73..ceea603 100644 --- a/simple_history/registry_tests/tests.py +++ b/simple_history/registry_tests/tests.py @@ -51,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") # nosec + user = User.objects.create(username="bob", password="pass") self.assertEqual(len(User.histories.all()), 1) self.assertEqual(len(user.histories.all()), 1) @@ -199,7 +199,7 @@ def test_registering_with_tracked_abstract_base(self): class TestCustomAttrForeignKey(TestCase): - """https://github.com/jazzband/django-simple-history/issues/431""" + """https://github.com/django-commons/django-simple-history/issues/431""" def test_custom_attr(self): field = ModelWithCustomAttrForeignKey.history.model._meta.get_field("poll") @@ -207,7 +207,7 @@ def test_custom_attr(self): class TestCustomAttrOneToOneField(TestCase): - """https://github.com/jazzband/django-simple-history/issues/870""" + """https://github.com/django-commons/django-simple-history/issues/870""" def test_custom_attr(self): field = ModelWithCustomAttrOneToOneField.history.model._meta.get_field("poll") @@ -228,7 +228,7 @@ def test_migrate_command(self): class TestModelWithHistoryInDifferentApp(TestCase): - """https://github.com/jazzband/django-simple-history/issues/485""" + """https://github.com/django-commons/django-simple-history/issues/485""" def test__different_app(self): appLabel = ModelWithHistoryInDifferentApp.history.model._meta.app_label diff --git a/simple_history/template_utils.py b/simple_history/template_utils.py new file mode 100644 index 0000000..722edf1 --- /dev/null +++ b/simple_history/template_utils.py @@ -0,0 +1,249 @@ +import dataclasses +from os.path import commonprefix +from typing import Any, Final, Union + +from django.db.models import ManyToManyField, Model +from django.utils.html import conditional_escape +from django.utils.safestring import SafeString, mark_safe +from django.utils.text import capfirst + +from .models import HistoricalChanges, ModelChange, ModelChangeValue, ModelDelta +from .utils import get_m2m_reverse_field_name + + +def conditional_str(obj: Any) -> str: + """ + Converts ``obj`` to a string, unless it's already one. + """ + if isinstance(obj, str): + return obj + return str(obj) + + +def is_safe_str(s: Any) -> bool: + """ + Returns whether ``s`` is a (presumably) pre-escaped string or not. + + This relies on the same ``__html__`` convention as Django's ``conditional_escape`` + does. + """ + return hasattr(s, "__html__") + + +class HistoricalRecordContextHelper: + """ + Class containing various utilities for formatting the template context for + a historical record. + """ + + DEFAULT_MAX_DISPLAYED_DELTA_CHANGE_CHARS: Final = 100 + + def __init__( + self, + model: type[Model], + historical_record: HistoricalChanges, + *, + max_displayed_delta_change_chars=DEFAULT_MAX_DISPLAYED_DELTA_CHANGE_CHARS, + ): + self.model = model + self.record = historical_record + + self.max_displayed_delta_change_chars = max_displayed_delta_change_chars + + def context_for_delta_changes(self, delta: ModelDelta) -> list[dict[str, Any]]: + """ + Return the template context for ``delta.changes``. + By default, this is a list of dicts with the keys ``"field"``, + ``"old"`` and ``"new"`` -- corresponding to the fields of ``ModelChange``. + + :param delta: The result from calling ``diff_against()`` with another historical + record. Its ``old_record`` or ``new_record`` field should have been + assigned to ``self.record``. + """ + context_list = [] + for change in delta.changes: + formatted_change = self.format_delta_change(change) + context_list.append( + { + "field": formatted_change.field, + "old": formatted_change.old, + "new": formatted_change.new, + } + ) + return context_list + + def format_delta_change(self, change: ModelChange) -> ModelChange: + """ + Return a ``ModelChange`` object with fields formatted for being used as + template context. + """ + old = self.prepare_delta_change_value(change, change.old) + new = self.prepare_delta_change_value(change, change.new) + + old, new = self.stringify_delta_change_values(change, old, new) + + field_meta = self.model._meta.get_field(change.field) + return dataclasses.replace( + change, + field=capfirst(field_meta.verbose_name), + old=old, + new=new, + ) + + def prepare_delta_change_value( + self, + change: ModelChange, + value: ModelChangeValue, + ) -> Any: + """ + Return the prepared value for the ``old`` and ``new`` fields of ``change``, + before it's passed through ``stringify_delta_change_values()`` (in + ``format_delta_change()``). + + For example, if ``value`` is a list of M2M related objects, it could be + "prepared" by replacing the related objects with custom string representations. + + :param change: + :param value: Either ``change.old`` or ``change.new``. + """ + field_meta = self.model._meta.get_field(change.field) + if isinstance(field_meta, ManyToManyField): + reverse_field_name = get_m2m_reverse_field_name(field_meta) + # Display a list of only the instances of the M2M field's related model + display_value = [ + obj_values_dict[reverse_field_name] for obj_values_dict in value + ] + else: + display_value = value + return display_value + + def stringify_delta_change_values( + self, change: ModelChange, old: Any, new: Any + ) -> tuple[SafeString, SafeString]: + """ + Called by ``format_delta_change()`` after ``old`` and ``new`` have been + prepared by ``prepare_delta_change_value()``. + + Return a tuple -- ``(old, new)`` -- where each element has been + escaped/sanitized and turned into strings, ready to be displayed in a template. + These can be HTML strings (remember to pass them through ``mark_safe()`` *after* + escaping). + + If ``old`` or ``new`` are instances of ``list``, the default implementation will + use each list element's ``__str__()`` method, and also reapply ``mark_safe()`` + if all the passed elements are safe strings. + """ + + def stringify_value(value: Any) -> Union[str, SafeString]: + # If `value` is a list, stringify each element using `str()` instead of + # `repr()` (the latter is the default when calling `list.__str__()`) + if isinstance(value, list): + string = f"[{', '.join(map(conditional_str, value))}]" + # If all elements are safe strings, reapply `mark_safe()` + if all(map(is_safe_str, value)): + string = mark_safe(string) # nosec + else: + string = conditional_str(value) + return string + + old_str, new_str = stringify_value(old), stringify_value(new) + diff_display = self.get_obj_diff_display() + old_short, new_short = diff_display.common_shorten_repr(old_str, new_str) + # Escape *after* shortening, as any shortened, previously safe HTML strings have + # likely been mangled. Other strings that have not been shortened, should have + # their "safeness" unchanged. + return conditional_escape(old_short), conditional_escape(new_short) + + def get_obj_diff_display(self) -> "ObjDiffDisplay": + """ + Return an instance of ``ObjDiffDisplay`` that will be used in + ``stringify_delta_change_values()`` to display the difference between + the old and new values of a ``ModelChange``. + """ + return ObjDiffDisplay(max_length=self.max_displayed_delta_change_chars) + + +class ObjDiffDisplay: + """ + A class grouping functions and settings related to displaying the textual + difference between two (or more) objects. + ``common_shorten_repr()`` is the main method for this. + + The code is based on + https://github.com/python/cpython/blob/v3.12.0/Lib/unittest/util.py#L8-L52. + """ + + def __init__( + self, + *, + max_length=80, + placeholder_len=12, + min_begin_len=5, + min_end_len=5, + min_common_len=5, + ): + self.max_length = max_length + self.placeholder_len = placeholder_len + self.min_begin_len = min_begin_len + self.min_end_len = min_end_len + self.min_common_len = min_common_len + self.min_diff_len = max_length - ( + min_begin_len + + placeholder_len + + min_common_len + + placeholder_len + + min_end_len + ) + assert self.min_diff_len >= 0 # nosec + + def common_shorten_repr(self, *args: Any) -> tuple[str, ...]: + """ + Returns ``args`` with each element converted into a string representation. + If any of the strings are longer than ``self.max_length``, they're all shortened + so that the first differences between the strings (after a potential common + prefix in all of them) are lined up. + """ + args = tuple(map(conditional_str, args)) + max_len = max(map(len, args)) + if max_len <= self.max_length: + return args + + prefix = commonprefix(args) + prefix_len = len(prefix) + + common_len = self.max_length - ( + max_len - prefix_len + self.min_begin_len + self.placeholder_len + ) + if common_len > self.min_common_len: + assert ( + self.min_begin_len + + self.placeholder_len + + self.min_common_len + + (max_len - prefix_len) + < self.max_length + ) # nosec + prefix = self.shorten(prefix, self.min_begin_len, common_len) + return tuple(f"{prefix}{s[prefix_len:]}" for s in args) + + prefix = self.shorten(prefix, self.min_begin_len, self.min_common_len) + return tuple( + prefix + self.shorten(s[prefix_len:], self.min_diff_len, self.min_end_len) + for s in args + ) + + def shorten(self, s: str, prefix_len: int, suffix_len: int) -> str: + skip = len(s) - prefix_len - suffix_len + if skip > self.placeholder_len: + suffix_index = len(s) - suffix_len + s = self.shortened_str(s[:prefix_len], skip, s[suffix_index:]) + return s + + def shortened_str(self, prefix: str, num_skipped_chars: int, suffix: str) -> str: + """ + Return a shortened version of the string representation of one of the args + passed to ``common_shorten_repr()``. + This should be in the format ``f"{prefix}{skip_str}{suffix}"``, where + ``skip_str`` is a string indicating how many characters (``num_skipped_chars``) + of the string representation were skipped between ``prefix`` and ``suffix``. + """ + return f"{prefix}[{num_skipped_chars:d} chars]{suffix}" diff --git a/simple_history/templates/simple_history/_object_history_list.html b/simple_history/templates/simple_history/_object_history_list.html deleted file mode 100644 index 0dd575c..0000000 --- a/simple_history/templates/simple_history/_object_history_list.html +++ /dev/null @@ -1,46 +0,0 @@ -{% load i18n %} -{% load url from simple_history_compat %} -{% load admin_urls %} -{% load getattribute from getattributes %} - - - - - - {% for column in history_list_display %} - - {% endfor %} - - - - - - - - {% for action in action_list %} - - - {% for column in history_list_display %} - - - - - - {% endfor %} - -
{% trans 'Object' %}{% trans column %}{% trans 'Date/time' %}{% trans 'Comment' %}{% trans 'Changed by' %}{% trans 'Change reason' %}
{{ action.history_object }}{{ action|getattribute:column }} - {% endfor %} - {{ action.history_date }}{{ action.get_history_type_display }} - {% if action.history_user %} - {% url admin_user_view action.history_user_id as admin_user_url %} - {% if admin_user_url %} - {{ action.history_user }} - {% else %} - {{ action.history_user }} - {% endif %} - {% else %} - {% trans "None" %} - {% endif %} - - {{ action.history_change_reason }} -
diff --git a/simple_history/templates/simple_history/object_history.html b/simple_history/templates/simple_history/object_history.html index 163679b..21eb7be 100644 --- a/simple_history/templates/simple_history/object_history.html +++ b/simple_history/templates/simple_history/object_history.html @@ -1,8 +1,5 @@ {% extends "admin/object_history.html" %} {% load i18n %} -{% load url from simple_history_compat %} -{% load admin_urls %} -{% load display_list from simple_history_admin_list %} {% block content %} @@ -10,8 +7,8 @@ {% if not revert_disabled %}

{% blocktrans %}Choose a date from the list below to revert to a previous version of this object.{% endblocktrans %}

{% endif %}
- {% if action_list %} - {% display_list %} + {% if page_obj.object_list %} + {% include object_history_list_template %} {% else %}

{% trans "This object doesn't have a change history." %}

{% endif %} diff --git a/simple_history/templates/simple_history/object_history_form.html b/simple_history/templates/simple_history/object_history_form.html index e6fca0e..84784c5 100644 --- a/simple_history/templates/simple_history/object_history_form.html +++ b/simple_history/templates/simple_history/object_history_form.html @@ -1,5 +1,5 @@ {% extends "admin/change_form.html" %} -{% load i18n %} +{% load i18n admin_urls %} {% load url from simple_history_compat %} {% block breadcrumbs %} @@ -21,6 +21,14 @@ {% include "simple_history/submit_line.html" %} {% endblock %} +{% block object-tools-items %} +{# We override this block from the django template to fix up the history link #} +
  • + {% translate "History" %} +
  • +{% if has_absolute_url %}
  • {% translate "View on site" %}
  • {% endif %} +{% endblock %} + {% block form_top %}

    {% if not revert_disabled %}{% blocktrans %}Press the 'Revert' button below to revert to this version of the object.{% endblocktrans %}{% endif %}{% if change_history %}{% blocktrans %}Press the 'Change History' button below to edit the history.{% endblocktrans %}{% endif %}

    {% endblock %} diff --git a/simple_history/templates/simple_history/object_history_list.html b/simple_history/templates/simple_history/object_history_list.html new file mode 100644 index 0000000..b1398be --- /dev/null +++ b/simple_history/templates/simple_history/object_history_list.html @@ -0,0 +1,82 @@ +{% load i18n %} +{% load url from simple_history_compat %} +{% load admin_urls %} +{% load getattribute from getattributes %} + + + + + + {% for column in history_list_display %} + + {% endfor %} + + + + + + + + + {% for record in page_obj %} + + + {% for column in history_list_display %} + + {% endfor %} + + + + + + + {% endfor %} + +
    {% trans 'Object' %}{% trans column %}{% trans 'Date/time' %}{% trans 'Comment' %}{% trans 'Changed by' %}{% trans 'Change reason' %}{% trans 'Changes' %}
    + + {{ record.history_object }} + + {{ record|getattribute:column }}{{ record.history_date }}{{ record.get_history_type_display }} + {% if record.history_user %} + {% url admin_user_view record.history_user_id as admin_user_url %} + {% if admin_user_url %} + {{ record.history_user }} + {% else %} + {{ record.history_user }} + {% endif %} + {% else %} + {% trans "None" %} + {% endif %} + + {{ record.history_change_reason }} + + {% block history_delta_changes %} + {% if record.history_delta_changes %} +
      + {% for change in record.history_delta_changes %} +
    • + {{ change.field }}: + {{ change.old }} + {# Add some spacing, and prevent having the arrow point to the edge of the page if `new` is wrapped #} +  →  {{ change.new }} +
    • + {% endfor %} +
    + {% endif %} + {% endblock %} +
    + +

    + {% if pagination_required %} + {% for i in page_range %} + {% if i == page_obj.paginator.ELLIPSIS %} + {{ page_obj.paginator.ELLIPSIS }} + {% elif i == page_obj.number %} + {{ i }} + {% else %} + {{ i }} + {% endif %} + {% endfor %} + {% endif %} + {{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %} +

    diff --git a/simple_history/templatetags/simple_history_admin_list.py b/simple_history/templatetags/simple_history_admin_list.py deleted file mode 100644 index e9c5986..0000000 --- a/simple_history/templatetags/simple_history_admin_list.py +++ /dev/null @@ -1,8 +0,0 @@ -from django import template - -register = template.Library() - - -@register.inclusion_tag("simple_history/_object_history_list.html", takes_context=True) -def display_list(context): - return context diff --git a/simple_history/tests/admin.py b/simple_history/tests/admin.py index 24cf5f7..cc6aaf9 100644 --- a/simple_history/tests/admin.py +++ b/simple_history/tests/admin.py @@ -1,6 +1,8 @@ from django.contrib import admin +from django.utils.safestring import SafeString, mark_safe from simple_history.admin import SimpleHistoryAdmin +from simple_history.template_utils import HistoricalRecordContextHelper from simple_history.tests.external.models import ExternalModelWithCustomUserIdField from .models import ( @@ -12,8 +14,10 @@ FileModel, Paper, Person, + Place, Planet, Poll, + PollWithManyToMany, ) @@ -43,14 +47,36 @@ def test_method(self, obj): history_list_display = ["title", "test_method"] -admin.site.register(Poll, SimpleHistoryAdmin) -admin.site.register(Choice, ChoiceAdmin) -admin.site.register(Person, PersonAdmin) +class HistoricalPollWithManyToManyContextHelper(HistoricalRecordContextHelper): + def prepare_delta_change_value(self, change, value): + display_value = super().prepare_delta_change_value(change, value) + if change.field == "places": + assert isinstance(display_value, list) + assert all(isinstance(place, Place) for place in display_value) + + places = sorted(display_value, key=lambda place: place.name) + display_value = list(map(self.place_display, places)) + return display_value + + @staticmethod + def place_display(place: Place) -> SafeString: + return mark_safe(f"{place.name}") + + +class PollWithManyToManyAdmin(SimpleHistoryAdmin): + def get_historical_record_context_helper(self, request, historical_record): + return HistoricalPollWithManyToManyContextHelper(self.model, historical_record) + + admin.site.register(Book, SimpleHistoryAdmin) +admin.site.register(Choice, ChoiceAdmin) +admin.site.register(ConcreteExternal, SimpleHistoryAdmin) admin.site.register(Document, SimpleHistoryAdmin) -admin.site.register(Paper, SimpleHistoryAdmin) admin.site.register(Employee, SimpleHistoryAdmin) -admin.site.register(ConcreteExternal, SimpleHistoryAdmin) admin.site.register(ExternalModelWithCustomUserIdField, SimpleHistoryAdmin) admin.site.register(FileModel, FileModelAdmin) +admin.site.register(Paper, SimpleHistoryAdmin) +admin.site.register(Person, PersonAdmin) admin.site.register(Planet, PlanetAdmin) +admin.site.register(Poll, SimpleHistoryAdmin) +admin.site.register(PollWithManyToMany, PollWithManyToManyAdmin) diff --git a/simple_history/tests/generated_file_checks/check_translations.py b/simple_history/tests/generated_file_checks/check_translations.py index f74eade..05126e3 100644 --- a/simple_history/tests/generated_file_checks/check_translations.py +++ b/simple_history/tests/generated_file_checks/check_translations.py @@ -1,4 +1,4 @@ -import subprocess # nosec +import subprocess import sys from glob import glob from pathlib import Path @@ -44,12 +44,12 @@ def main(): call_command("compilemessages") log("\nRunning 'git status'...") - result = subprocess.run( # nosec + result = subprocess.run( ["git", "status", "--porcelain"], check=True, stdout=subprocess.PIPE, ) - assert result.stderr is None # nosec + assert result.stderr is None stdout = result.stdout.decode() if stdout: log_err(f"Unexpected changes found in the workspace:\n\n{stdout}") @@ -61,7 +61,7 @@ def main(): sys.exit(1) else: # Print the human-readable status to the console - subprocess.run(["git", "status"]) # nosec + subprocess.run(["git", "status"]) if __name__ == "__main__": diff --git a/simple_history/tests/models.py b/simple_history/tests/models.py index f35b5cf..c677130 100644 --- a/simple_history/tests/models.py +++ b/simple_history/tests/models.py @@ -10,7 +10,11 @@ from simple_history import register from simple_history.manager import HistoricalQuerySet, HistoryManager -from simple_history.models import HistoricalRecords, HistoricForeignKey +from simple_history.models import ( + HistoricalRecords, + HistoricForeignKey, + HistoricOneToOneField, +) from .custom_user.models import CustomUser as User from .external.models import AbstractExternal, AbstractExternal2, AbstractExternal3 @@ -983,3 +987,58 @@ class TestHistoricParticipanToHistoricOrganization(models.Model): related_name="historic_participants", ) history = HistoricalRecords() + + +class TestParticipantToHistoricOrganizationOneToOne(models.Model): + """ + Non-historic table with one to one relationship to historic table. + + In this case it should simply behave like ForeignKey because + the origin model (this one) cannot be historic, so foreign key + lookups are always "current". + """ + + name = models.CharField(max_length=15, unique=True) + organization = HistoricOneToOneField( + TestOrganizationWithHistory, on_delete=CASCADE, related_name="participant" + ) + + +class TestHistoricParticipantToOrganizationOneToOne(models.Model): + """ + Historic table with one to one relationship to non-historic table. + + In this case it should simply behave like OneToOneField because + the origin model (this one) cannot be historic, so one to one field + lookups are always "current". + """ + + name = models.CharField(max_length=15, unique=True) + organization = HistoricOneToOneField( + TestOrganization, on_delete=CASCADE, related_name="participant" + ) + history = HistoricalRecords() + + +class TestHistoricParticipanToHistoricOrganizationOneToOne(models.Model): + """ + Historic table with one to one relationship to historic table. + + In this case as_of queries on the origin model (this one) + or on the target model (the other one) will traverse the + one to one field relationship honoring the timepoint of the + original query. This only happens when both tables involved + are historic. + + NOTE: related_name has to be different than the one used in + TestParticipantToHistoricOrganizationOneToOne as they are + sharing the same target table. + """ + + name = models.CharField(max_length=15, unique=True) + organization = HistoricOneToOneField( + TestOrganizationWithHistory, + on_delete=CASCADE, + related_name="historic_participant", + ) + history = HistoricalRecords() diff --git a/simple_history/tests/tests/test_admin.py b/simple_history/tests/tests/test_admin.py index 2b44103..016571a 100644 --- a/simple_history/tests/tests/test_admin.py +++ b/simple_history/tests/tests/test_admin.py @@ -1,8 +1,10 @@ from datetime import datetime, timedelta from unittest.mock import ANY, patch +import django from django.contrib.admin import AdminSite from django.contrib.admin.utils import quote +from django.contrib.admin.views.main import PAGE_VAR from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.messages.storage.fallback import FallbackStorage @@ -10,10 +12,12 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse +from django.utils.dateparse import parse_datetime from django.utils.encoding import force_str from simple_history.admin import SimpleHistoryAdmin from simple_history.models import HistoricalRecords +from simple_history.template_utils import HistoricalRecordContextHelper from simple_history.tests.external.models import ExternalModelWithCustomUserIdField from simple_history.tests.tests.utils import ( PermissionAction, @@ -29,8 +33,10 @@ Employee, FileModel, Person, + Place, Planet, Poll, + PollWithManyToMany, State, ) @@ -86,6 +92,169 @@ def test_history_list(self): self.assertContains(response, "A random test reason") self.assertContains(response, self.user.username) + def test_history_list_contains_diff_changes(self): + self.login() + poll = Poll(question="why?", pub_date=today) + poll._history_user = self.user + poll.save() + + poll_history_url = get_history_url(poll) + response = self.client.get(poll_history_url) + self.assertContains(response, "Changes") + # The poll hasn't had any of its fields changed after creation, + # so these values should not be present + self.assertNotContains(response, "Question:") + self.assertNotContains(response, "why?") + self.assertNotContains(response, "Date published:") + + poll.question = "how?" + poll.save() + response = self.client.get(poll_history_url) + self.assertContains(response, "Question:") + self.assertContains(response, "why?") + self.assertContains(response, "how?") + self.assertNotContains(response, "Date published:") + + poll.question = "when?" + poll.pub_date = parse_datetime("2024-04-04 04:04:04") + poll.save() + response = self.client.get(poll_history_url) + self.assertContains(response, "Question:") + self.assertContains(response, "why?") + self.assertContains(response, "how?") + self.assertContains(response, "when?") + self.assertContains(response, "Date published:") + self.assertContains(response, "2021-01-01 10:00:00") + self.assertContains(response, "2024-04-04 04:04:04") + + def test_history_list_contains_diff_changes_for_foreign_key_fields(self): + self.login() + poll1 = Poll.objects.create(question="why?", pub_date=today) + poll1_pk = poll1.pk + poll2 = Poll.objects.create(question="how?", pub_date=today) + poll2_pk = poll2.pk + choice = Choice(poll=poll1, votes=1) + choice._history_user = self.user + choice.save() + choice_history_url = get_history_url(choice) + + # Before changing the poll: + response = self.client.get(choice_history_url) + self.assertNotContains(response, "Poll:") + expected_old_poll = f"Poll object ({poll1_pk})" + self.assertNotContains(response, expected_old_poll) + expected_new_poll = f"Poll object ({poll2_pk})" + self.assertNotContains(response, expected_new_poll) + + # After changing the poll: + choice.poll = poll2 + choice.save() + response = self.client.get(choice_history_url) + self.assertContains(response, "Poll:") + self.assertContains(response, expected_old_poll) + self.assertContains(response, expected_new_poll) + + # After deleting all polls: + Poll.objects.all().delete() + response = self.client.get(choice_history_url) + self.assertContains(response, "Poll:") + self.assertContains(response, f"Deleted poll (pk={poll1_pk})") + self.assertContains(response, f"Deleted poll (pk={poll2_pk})") + + @patch( + # Test without the customization in PollWithManyToMany's admin class + "simple_history.tests.admin.HistoricalPollWithManyToManyContextHelper", + HistoricalRecordContextHelper, + ) + def test_history_list_contains_diff_changes_for_m2m_fields(self): + self.login() + poll = PollWithManyToMany(question="why?", pub_date=today) + poll._history_user = self.user + poll.save() + place1 = Place.objects.create(name="Here") + place1_pk = place1.pk + place2 = Place.objects.create(name="There") + place2_pk = place2.pk + poll_history_url = get_history_url(poll) + + # Before adding places: + response = self.client.get(poll_history_url) + self.assertNotContains(response, "Places:") + expected_old_places = "[]" + self.assertNotContains(response, expected_old_places) + expected_new_places = ( + f"[Place object ({place1_pk}), Place object ({place2_pk})]" + ) + self.assertNotContains(response, expected_new_places) + + # After adding places: + poll.places.add(place1, place2) + response = self.client.get(poll_history_url) + self.assertContains(response, "Places:") + self.assertContains(response, expected_old_places) + self.assertContains(response, expected_new_places) + + # After deleting all places: + Place.objects.all().delete() + response = self.client.get(poll_history_url) + self.assertContains(response, "Places:") + self.assertContains(response, expected_old_places) + expected_new_places = ( + f"[Deleted place (pk={place1_pk}), Deleted place (pk={place2_pk})]" + ) + self.assertContains(response, expected_new_places) + + def test_history_list_doesnt_contain_too_long_diff_changes(self): + self.login() + + def create_and_change_poll(*, initial_question, changed_question) -> Poll: + poll = Poll(question=initial_question, pub_date=today) + poll._history_user = self.user + poll.save() + poll.question = changed_question + poll.save() + return poll + + repeated_chars = ( + HistoricalRecordContextHelper.DEFAULT_MAX_DISPLAYED_DELTA_CHANGE_CHARS + ) + + # Number of characters right on the limit + poll1 = create_and_change_poll( + initial_question="A" * repeated_chars, + changed_question="B" * repeated_chars, + ) + response = self.client.get(get_history_url(poll1)) + self.assertContains(response, "Question:") + self.assertContains(response, "A" * repeated_chars) + self.assertContains(response, "B" * repeated_chars) + + # Number of characters just over the limit + poll2 = create_and_change_poll( + initial_question="A" * (repeated_chars + 1), + changed_question="B" * (repeated_chars + 1), + ) + response = self.client.get(get_history_url(poll2)) + self.assertContains(response, "Question:") + self.assertContains(response, f"{'A' * 61}[35 chars]AAAAA") + self.assertContains(response, f"{'B' * 61}[35 chars]BBBBB") + + def test_overriding__historical_record_context_helper__with_custom_m2m_string(self): + self.login() + + place1 = Place.objects.create(name="Place 1") + place2 = Place.objects.create(name="Place 2") + place3 = Place.objects.create(name="Place 3") + poll = PollWithManyToMany.objects.create(question="why?", pub_date=today) + poll.places.add(place1, place2) + poll.places.set([place3]) + + response = self.client.get(get_history_url(poll)) + self.assertContains(response, "Places:") + self.assertContains(response, "[]") + self.assertContains(response, "[Place 1, Place 2]") + self.assertContains(response, "[Place 3]") + def test_history_list_custom_fields(self): model_name = self.user._meta.model_name self.assertEqual(model_name, "customuser") @@ -388,6 +557,125 @@ def test_response_change(self): self.assertEqual(response["Location"], "/awesome/url/") + def test_history_view_pagination(self): + """ + Ensure the history_view handles pagination correctly. + The default history_list_per_page is 100 so page 2 should have 1 record. + """ + # Create a Poll object and make more than 100 changes to ensure pagination + poll = Poll.objects.create(question="what?", pub_date=today) + for i in range(100): + poll.question = f"change_{i}" + poll.save() + + # Verify that there are 100+1 (initial creation) historical records + self.assertEqual(poll.history.count(), 101) + + admin_site = AdminSite() + admin = SimpleHistoryAdmin(Poll, admin_site) + + self.login(superuser=True) + + # Simulate a request to the second page + request = RequestFactory().get("/", {PAGE_VAR: "2"}) + request.user = self.user + + # Patch the render function + with patch("simple_history.admin.render") as mock_render: + admin.history_view(request, str(poll.id)) + + # Ensure the render function was called + self.assertTrue(mock_render.called) + + # Extract context passed to render function + action_list_count = len(mock_render.call_args[0][2]["page_obj"].object_list) + + # Check if only 1 (101 - 100 from the first page) + # objects are present in the context + self.assertEqual(action_list_count, 1) + + def test_history_view_pagination_no_pagination(self): + """ + When all records fit on one page because the history_list_per_page is + higher than the number of records, ensure that the pagination is not set. + But it should show the number of entries. + """ + # Create a Poll object and make more than 50 changes to ensure pagination + poll = Poll.objects.create(question="what?", pub_date=today) + for i in range(60): + poll.question = f"change_{i}" + poll.save() + + # Verify that there are 60+1 (initial creation) historical records + self.assertEqual(poll.history.count(), 61) + + # Create an admin with more per page than the number of records + class CustomSimpleHistoryAdmin(SimpleHistoryAdmin): + history_list_per_page = 200 + + admin_site = AdminSite() + admin = CustomSimpleHistoryAdmin(Poll, admin_site) + + self.login(superuser=True) + + # Simulate a request to the second page + request = RequestFactory().get("/", {PAGE_VAR: "2"}) + request.user = self.user + + response = admin.history_view(request, str(poll.id)) + + expected = '

    61 entries

    ' + self.assertInHTML(expected, response.content.decode()) + + def test_history_view_pagination_last_page(self): + """ + With 31 records, the last page should have 1 record. Non-existing pages + also end up on the last page. + """ + # Create a Poll object and make more than 30 changes to ensure pagination + poll = Poll.objects.create(question="what?", pub_date=today) + for i in range(30): + poll.question = f"change_{i}" + poll.save() + + expected_entry_count = 31 + + # Verify that there are 30+1 (initial creation) historical records + self.assertEqual(poll.history.count(), expected_entry_count) + + # Create an admin with less per page than the number of records + class CustomSimpleHistoryAdmin(SimpleHistoryAdmin): + history_list_per_page = 10 + + admin_site = AdminSite() + admin = CustomSimpleHistoryAdmin(Poll, admin_site) + + self.login(superuser=True) + + # Simulate a request to the 4th and last page + request = RequestFactory().get("/", {PAGE_VAR: "4"}) + request.user = self.user + + response = admin.history_view(request, str(poll.id)) + + expected = ( + '

    ' + '1' + '2' + '3' + '4' + f"{expected_entry_count} entries" + "

    " + ) + self.assertInHTML(expected, response.content.decode()) + + # Also a non-existent page should return the last page + request = RequestFactory().get("/", {PAGE_VAR: "5"}) + request.user = self.user + + response = admin.history_view(request, str(poll.id)) + self.assertInHTML(expected, response.content.decode()) + def test_response_change_change_history_setting_off(self): """ Test the response_change method that it works with a _change_history @@ -454,6 +742,7 @@ def test_history_form_view_without_getting_history(self): context = { **admin_site.each_context(request), # Verify this is set for original object + "log_entries": ANY, "original": poll, "change_history": False, "title": "Revert %s" % force_str(poll), @@ -483,9 +772,9 @@ def test_history_form_view_without_getting_history(self): "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } - # This key didn't exist prior to Django 4.2 - if "log_entries" in context: - context["log_entries"] = ANY + # DEV: Remove this when support for Django 4.2 has been dropped + if django.VERSION < (5, 0): + del context["log_entries"] mock_render.assert_called_once_with( request, admin.object_history_form_template, context @@ -513,6 +802,7 @@ def test_history_form_view_getting_history(self): context = { **admin_site.each_context(request), # Verify this is set for history object not poll object + "log_entries": ANY, "original": history.instance, "change_history": True, "title": "Revert %s" % force_str(history.instance), @@ -542,9 +832,9 @@ def test_history_form_view_getting_history(self): "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } - # This key didn't exist prior to Django 4.2 - if "log_entries" in context: - context["log_entries"] = ANY + # DEV: Remove this when support for Django 4.2 has been dropped + if django.VERSION < (5, 0): + del context["log_entries"] mock_render.assert_called_once_with( request, admin.object_history_form_template, context @@ -572,6 +862,7 @@ def test_history_form_view_getting_history_with_setting_off(self): context = { **admin_site.each_context(request), # Verify this is set for history object not poll object + "log_entries": ANY, "original": poll, "change_history": False, "title": "Revert %s" % force_str(poll), @@ -601,9 +892,9 @@ def test_history_form_view_getting_history_with_setting_off(self): "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } - # This key didn't exist prior to Django 4.2 - if "log_entries" in context: - context["log_entries"] = ANY + # DEV: Remove this when support for Django 4.2 has been dropped + if django.VERSION < (5, 0): + del context["log_entries"] mock_render.assert_called_once_with( request, admin.object_history_form_template, context @@ -631,6 +922,7 @@ def test_history_form_view_getting_history_abstract_external(self): context = { **admin_site.each_context(request), # Verify this is set for history object + "log_entries": ANY, "original": history.instance, "change_history": True, "title": "Revert %s" % force_str(history.instance), @@ -662,9 +954,9 @@ def test_history_form_view_getting_history_abstract_external(self): "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } - # This key didn't exist prior to Django 4.2 - if "log_entries" in context: - context["log_entries"] = ANY + # DEV: Remove this when support for Django 4.2 has been dropped + if django.VERSION < (5, 0): + del context["log_entries"] mock_render.assert_called_once_with( request, admin.object_history_form_template, context @@ -695,6 +987,7 @@ def test_history_form_view_accepts_additional_context(self): context = { **admin_site.each_context(request), # Verify this is set for original object + "log_entries": ANY, "anything_else": "will be merged into context", "original": poll, "change_history": False, @@ -725,9 +1018,9 @@ def test_history_form_view_accepts_additional_context(self): "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } - # This key didn't exist prior to Django 4.2 - if "log_entries" in context: - context["log_entries"] = ANY + # DEV: Remove this when support for Django 4.2 has been dropped + if django.VERSION < (5, 0): + del context["log_entries"] mock_render.assert_called_once_with( request, admin.object_history_form_template, context diff --git a/simple_history/tests/tests/test_deprecation.py b/simple_history/tests/tests/test_deprecation.py new file mode 100644 index 0000000..0bfb5e4 --- /dev/null +++ b/simple_history/tests/tests/test_deprecation.py @@ -0,0 +1,10 @@ +import unittest + + +class DeprecationWarningTest(unittest.TestCase): + """Tests that check whether ``DeprecationWarning`` is raised for certain features, + and that compare ``simple_history.__version__`` against the version the features + will be removed in. + + If this class is empty, it normally means that nothing is currently deprecated. + """ diff --git a/simple_history/tests/tests/test_manager.py b/simple_history/tests/tests/test_manager.py index 30e5ec1..acb9e02 100644 --- a/simple_history/tests/tests/test_manager.py +++ b/simple_history/tests/tests/test_manager.py @@ -1,19 +1,73 @@ from datetime import datetime, timedelta from operator import attrgetter -import django from django.contrib.auth import get_user_model from django.db import IntegrityError from django.test import TestCase, override_settings, skipUnlessDBFeature from simple_history.manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME -from ..models import Document, Poll, RankedDocument +from ..models import Choice, Document, Poll, RankedDocument +from .utils import HistoricalTestCase User = get_user_model() -class AsOfTest(TestCase): +class LatestOfEachTestCase(HistoricalTestCase): + def test_filtered_instances_are_as_expected(self): + document1 = RankedDocument.objects.create(rank=10) + document2 = RankedDocument.objects.create(rank=20) + document2.rank = 21 + document2.save() + document3 = RankedDocument.objects.create(rank=30) + document3.rank = 31 + document3.save() + document3.delete() + document4 = RankedDocument.objects.create(rank=40) + document4_pk = document4.pk + document4.delete() + reincarnated_document4 = RankedDocument.objects.create(pk=document4_pk, rank=42) + + record4, record3, record2, record1 = RankedDocument.history.latest_of_each() + self.assertRecordValues( + record1, + RankedDocument, + { + "rank": 10, + "id": document1.id, + "history_type": "+", + }, + ) + self.assertRecordValues( + record2, + RankedDocument, + { + "rank": 21, + "id": document2.id, + "history_type": "~", + }, + ) + self.assertRecordValues( + record3, + RankedDocument, + { + "rank": 31, + "id": document3.id, + "history_type": "-", + }, + ) + self.assertRecordValues( + record4, + RankedDocument, + { + "rank": 42, + "id": reincarnated_document4.id, + "history_type": "+", + }, + ) + + +class AsOfTestCase(TestCase): model = Document def setUp(self): @@ -70,7 +124,7 @@ def test_modified(self): self.assertEqual(as_of_list[0].changed_by, self.obj.changed_by) -class AsOfAdditionalTestCase(TestCase): +class AsOfTestCaseWithoutSetUp(TestCase): def test_create_and_delete(self): document = Document.objects.create() now = datetime.now() @@ -151,42 +205,57 @@ def test_historical_query_set(self): """ Demonstrates how the HistoricalQuerySet works to provide as_of functionality. """ - document1 = RankedDocument.objects.create(rank=42) - document2 = RankedDocument.objects.create(rank=84) - document2.rank = 51 + document1 = RankedDocument.objects.create(rank=10) + document2 = RankedDocument.objects.create(rank=20) + document2.rank = 21 document2.save() document1.delete() + t1 = datetime.now() + document3 = RankedDocument.objects.create(rank=30) # noqa: F841 + document2.rank = 22 + document2.save() t2 = datetime.now() - # look for historical records, get back a queryset - with self.assertNumQueries(1): - queryset = RankedDocument.history.filter(history_date__lte=t2) - self.assertEqual(queryset.count(), 4) + # 4 records before `t1` (for document 1 and 2), 2 after (for document 2 and 3) + queryset = RankedDocument.history.filter(history_date__lte=t1) + self.assertEqual(queryset.count(), 4) + self.assertEqual(RankedDocument.history.filter(history_date__gt=t1).count(), 2) - # only want the most recend records (provided by HistoricalQuerySet) - self.assertEqual(queryset.latest_of_each().count(), 2) + # `latest_of_each()` returns the most recent record of each document + with self.assertNumQueries(1): + self.assertEqual(queryset.latest_of_each().count(), 2) - # want to see the instances as of that time? - self.assertEqual(queryset.latest_of_each().as_instances().count(), 1) + # `as_instances()` returns the historical instances as of each record's time, + # but excludes deletion records (i.e. document 1's most recent record) + with self.assertNumQueries(1): + self.assertEqual(queryset.latest_of_each().as_instances().count(), 1) - # these new methods are idempotent - self.assertEqual( - queryset.latest_of_each() - .latest_of_each() - .as_instances() - .as_instances() - .count(), - 1, - ) + # (Duplicate calls to these methods should not change the number of queries, + # since they're idempotent) + with self.assertNumQueries(1): + self.assertEqual( + queryset.latest_of_each() + .latest_of_each() + .as_instances() + .as_instances() + .count(), + 1, + ) - # that was all the same as calling as_of! - self.assertEqual( + self.assertSetEqual( + # In conclusion, all of these methods combined... set( - RankedDocument.history.filter(history_date__lte=t2) + RankedDocument.history.filter(history_date__lte=t1) .latest_of_each() .as_instances() ), - set(RankedDocument.history.as_of(t2)), + # ...are equivalent to calling `as_of()`! + set(RankedDocument.history.as_of(t1)), + ) + + self.assertEqual(RankedDocument.history.as_of(t1).get().rank, 21) + self.assertListEqual( + [d.rank for d in RankedDocument.history.as_of(t2)], [22, 30] ) @@ -199,13 +268,6 @@ def setUp(self): Poll(id=4, question="Question 4", pub_date=datetime.now()), ] - # DEV: Remove this method when the minimum required Django version is 4.2 - def assertQuerySetEqual(self, *args, **kwargs): - if django.VERSION < (4, 2): - return self.assertQuerysetEqual(*args, **kwargs) - else: - return super().assertQuerySetEqual(*args, **kwargs) - def test_simple_bulk_history_create(self): created = Poll.history.bulk_history_create(self.data) self.assertEqual(len(created), 4) @@ -334,13 +396,6 @@ def setUp(self): Poll(id=4, question="Question 4", pub_date=datetime.now()), ] - # DEV: Remove this method when the minimum required Django version is 4.2 - def assertQuerySetEqual(self, *args, **kwargs): - if django.VERSION < (4, 2): - return self.assertQuerysetEqual(*args, **kwargs) - else: - return super().assertQuerySetEqual(*args, **kwargs) - def test_simple_bulk_history_create(self): created = Poll.history.bulk_history_create(self.data, update=True) self.assertEqual(len(created), 4) @@ -371,3 +426,37 @@ def test_bulk_history_create_with_change_reason(self): ] ) ) + + +class PrefetchingMethodsTestCase(TestCase): + def setUp(self): + d = datetime(3021, 1, 1, 10, 0) + self.poll1 = Poll.objects.create(question="why?", pub_date=d) + self.poll2 = Poll.objects.create(question="how?", pub_date=d) + self.choice1 = Choice.objects.create(poll=self.poll1, votes=1) + self.choice2 = Choice.objects.create(poll=self.poll1, votes=2) + self.choice3 = Choice.objects.create(poll=self.poll2, votes=3) + + def test__select_related_history_tracked_objs__prefetches_expected_objects(self): + num_choices = Choice.objects.count() + self.assertEqual(num_choices, 3) + + def access_related_objs(records): + for record in records: + self.assertIsInstance(record.poll, Poll) + + # Without prefetching: + with self.assertNumQueries(1): + historical_records = Choice.history.all() + self.assertEqual(len(historical_records), num_choices) + with self.assertNumQueries(num_choices): + access_related_objs(historical_records) + + # With prefetching: + with self.assertNumQueries(1): + historical_records = ( + Choice.history.all()._select_related_history_tracked_objs() + ) + self.assertEqual(len(historical_records), num_choices) + with self.assertNumQueries(0): + access_related_objs(historical_records) diff --git a/simple_history/tests/tests/test_middleware.py b/simple_history/tests/tests/test_middleware.py index 53e20fe..d59a41f 100644 --- a/simple_history/tests/tests/test_middleware.py +++ b/simple_history/tests/tests/test_middleware.py @@ -154,7 +154,7 @@ def test_bucket_member_is_set_on_create_view_when_logged_in(self): @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True) @mock.patch("simple_history.tests.view.MockableView.get") def test_request_attr_is_deleted_after_each_response(self, func_mock): - """https://github.com/jazzband/django-simple-history/issues/1189""" + """https://github.com/django-commons/django-simple-history/issues/1189""" def assert_has_request_attr(has_attr: bool): self.assertEqual(hasattr(HistoricalRecords.context, "request"), has_attr) diff --git a/simple_history/tests/tests/test_models.py b/simple_history/tests/tests/test_models.py index d24cb1d..79b4da5 100644 --- a/simple_history/tests/tests/test_models.py +++ b/simple_history/tests/tests/test_models.py @@ -1,9 +1,9 @@ +import dataclasses import unittest import uuid import warnings from datetime import datetime, timedelta -import django from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model @@ -19,8 +19,10 @@ from simple_history.exceptions import RelatedNameConflictError from simple_history.models import ( SIMPLE_HISTORY_REVERSE_ATTR_NAME, + DeletedObject, HistoricalRecords, ModelChange, + ModelDelta, is_historic, to_historic, ) @@ -28,7 +30,6 @@ pre_create_historical_m2m_records, pre_create_historical_record, ) -from simple_history.tests.custom_user.models import CustomUser from simple_history.tests.tests.utils import ( database_router_override_settings, database_router_override_settings_history_in_diff_db, @@ -72,7 +73,6 @@ HistoricalCustomFKError, HistoricalPoll, HistoricalPollWithHistoricalIPAddress, - HistoricalPollWithManyToMany_places, HistoricalState, InheritedRestaurant, Library, @@ -86,7 +86,6 @@ ModelWithSingleNoDBIndexUnique, MultiOneToOne, MyOverrideModelNameRegisterMethod1, - OverrideModelNameAsCallable, OverrideModelNameUsingBaseModel1, Person, Place, @@ -115,10 +114,13 @@ Street, Temperature, TestHistoricParticipanToHistoricOrganization, + TestHistoricParticipanToHistoricOrganizationOneToOne, TestHistoricParticipantToOrganization, + TestHistoricParticipantToOrganizationOneToOne, TestOrganization, TestOrganizationWithHistory, TestParticipantToHistoricOrganization, + TestParticipantToHistoricOrganizationOneToOne, UnicodeVerboseName, UnicodeVerboseNamePlural, UserTextFieldChangeReasonModel, @@ -126,6 +128,12 @@ UUIDModel, WaterLevel, ) +from .utils import ( + HistoricalTestCase, + database_router_override_settings, + database_router_override_settings_history_in_diff_db, + middleware_override_settings, +) get_model = apps.get_model User = get_user_model() @@ -140,18 +148,10 @@ def get_fake_file(filename): return fake_file -class HistoricalRecordsTest(TestCase): +class HistoricalRecordsTest(HistoricalTestCase): def assertDatetimesEqual(self, time1, time2): self.assertAlmostEqual(time1, time2, delta=timedelta(seconds=2)) - def assertRecordValues(self, record, klass, values_dict): - for key, value in values_dict.items(): - self.assertEqual(getattr(record, key), value) - self.assertEqual(record.history_object.__class__, klass) - for key, value in values_dict.items(): - if key not in ["history_type", "history_change_reason"]: - self.assertEqual(getattr(record.history_object, key), value) - def test_create(self): p = Poll(question="what's up?", pub_date=today) p.save() @@ -697,11 +697,13 @@ def test_history_diff_includes_changed_fields(self): new_record, old_record = p.history.all() 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) - self.assertEqual(delta.new_record, new_record) - self.assertEqual(expected_change.field, delta.changes[0].field) + expected_delta = ModelDelta( + [ModelChange("question", "what's up?", "what's up, man?")], + ["question"], + old_record, + new_record, + ) + self.assertEqual(delta, expected_delta) def test_history_diff_does_not_include_unchanged_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) @@ -720,11 +722,148 @@ def test_history_diff_includes_changed_fields_of_base_model(self): new_record, old_record = r.history.all() 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) - self.assertEqual(delta.new_record, new_record) - self.assertEqual(expected_change.field, delta.changes[0].field) + expected_delta = ModelDelta( + [ModelChange("name", "McDonna", "DonnutsKing")], + ["name"], + old_record, + new_record, + ) + self.assertEqual(delta, expected_delta) + + def test_history_diff_arg__foreign_keys_are_objs__returns_expected_fk_values(self): + poll1 = Poll.objects.create(question="why?", pub_date=today) + poll1_pk = poll1.pk + poll2 = Poll.objects.create(question="how?", pub_date=tomorrow) + poll2_pk = poll2.pk + choice = Choice.objects.create(poll=poll1, choice="hmm", votes=3) + choice.poll = poll2 + choice.choice = "idk" + choice.votes = 0 + choice.save() + new_record, old_record = choice.history.all() + + # Test with the default value of `foreign_keys_are_objs` + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record) + expected_pk_changes = [ + ModelChange("choice", "hmm", "idk"), + ModelChange("poll", poll1_pk, poll2_pk), + ModelChange("votes", 3, 0), + ] + expected_pk_delta = ModelDelta( + expected_pk_changes, ["choice", "poll", "votes"], old_record, new_record + ) + self.assertEqual(delta, expected_pk_delta) + + # Test with `foreign_keys_are_objs=True` + with self.assertNumQueries(2): # Once for each poll in the new record + delta = new_record.diff_against(old_record, foreign_keys_are_objs=True) + choice_changes, _poll_changes, votes_changes = expected_pk_changes + # The PKs should now instead be their corresponding model objects + expected_obj_changes = [ + choice_changes, + ModelChange("poll", poll1, poll2), + votes_changes, + ] + expected_obj_delta = dataclasses.replace( + expected_pk_delta, changes=expected_obj_changes + ) + self.assertEqual(delta, expected_obj_delta) + + # --- Delete the polls and do the same tests again --- + + Poll.objects.all().delete() + old_record.refresh_from_db() + new_record.refresh_from_db() + + # Test with the default value of `foreign_keys_are_objs` + with self.assertNumQueries(0): + delta = new_record.diff_against(old_record) + self.assertEqual(delta, expected_pk_delta) + + # Test with `foreign_keys_are_objs=True` + with self.assertNumQueries(2): # Once for each poll in the new record + delta = new_record.diff_against(old_record, foreign_keys_are_objs=True) + # The model objects should now instead be instances of `DeletedObject` + expected_obj_changes = [ + choice_changes, + ModelChange( + "poll", DeletedObject(Poll, poll1_pk), DeletedObject(Poll, poll2_pk) + ), + votes_changes, + ] + expected_obj_delta = dataclasses.replace( + expected_pk_delta, changes=expected_obj_changes + ) + self.assertEqual(delta, expected_obj_delta) + + def test_history_diff_arg__foreign_keys_are_objs__returns_expected_m2m_values(self): + poll = PollWithManyToMany.objects.create(question="why?", pub_date=today) + place1 = Place.objects.create(name="Here") + place1_pk = place1.pk + place2 = Place.objects.create(name="There") + place2_pk = place2.pk + poll.places.add(place1, place2) + new_record, old_record = poll.history.all() + + # Test with the default value of `foreign_keys_are_objs` + with self.assertNumQueries(2): # Once for each record + delta = new_record.diff_against(old_record) + expected_pk_change = ModelChange( + "places", + [], + [ + {"pollwithmanytomany": poll.pk, "place": place1_pk}, + {"pollwithmanytomany": poll.pk, "place": place2_pk}, + ], + ) + expected_pk_delta = ModelDelta( + [expected_pk_change], ["places"], old_record, new_record + ) + self.assertEqual(delta, expected_pk_delta) + + # Test with `foreign_keys_are_objs=True` + with self.assertNumQueries(2 * 2): # Twice for each record + delta = new_record.diff_against(old_record, foreign_keys_are_objs=True) + # The PKs should now instead be their corresponding model objects + expected_obj_change = dataclasses.replace( + expected_pk_change, + new=[ + {"pollwithmanytomany": poll, "place": place1}, + {"pollwithmanytomany": poll, "place": place2}, + ], + ) + expected_obj_delta = dataclasses.replace( + expected_pk_delta, changes=[expected_obj_change] + ) + self.assertEqual(delta, expected_obj_delta) + + # --- Delete the places and do the same tests again --- + + Place.objects.all().delete() + old_record.refresh_from_db() + new_record.refresh_from_db() + + # Test with the default value of `foreign_keys_are_objs` + with self.assertNumQueries(2): # Once for each record + delta = new_record.diff_against(old_record) + self.assertEqual(delta, expected_pk_delta) + + # Test with `foreign_keys_are_objs=True` + with self.assertNumQueries(2 * 2): # Twice for each record + delta = new_record.diff_against(old_record, foreign_keys_are_objs=True) + # The model objects should now instead be instances of `DeletedObject` + expected_obj_change = dataclasses.replace( + expected_obj_change, + new=[ + {"pollwithmanytomany": poll, "place": DeletedObject(Place, place1_pk)}, + {"pollwithmanytomany": poll, "place": DeletedObject(Place, place2_pk)}, + ], + ) + expected_obj_delta = dataclasses.replace( + expected_obj_delta, changes=[expected_obj_change] + ) + self.assertEqual(delta, expected_obj_delta) def test_history_table_name_is_not_inherited(self): def assert_table_name(obj, expected_table_name): @@ -759,8 +898,8 @@ def test_history_diff_with_excluded_fields(self): new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record, excluded_fields=("question",)) - self.assertEqual(delta.changed_fields, []) - self.assertEqual(delta.changes, []) + expected_delta = ModelDelta([], [], old_record, new_record) + self.assertEqual(delta, expected_delta) def test_history_diff_with_included_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) @@ -769,13 +908,17 @@ def test_history_diff_with_included_fields(self): 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, []) + expected_delta = ModelDelta([], [], old_record, new_record) + self.assertEqual(delta, expected_delta) 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) + expected_delta = dataclasses.replace( + expected_delta, + changes=[ModelChange("question", "what's up?", "what's up, man?")], + changed_fields=["question"], + ) + self.assertEqual(delta, expected_delta) def test_history_diff_with_non_editable_field(self): p = PollWithNonEditableField.objects.create( @@ -786,8 +929,13 @@ def test_history_diff_with_non_editable_field(self): new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) - self.assertEqual(delta.changed_fields, ["question"]) - self.assertEqual(len(delta.changes), 1) + expected_delta = ModelDelta( + [ModelChange("question", "what's up?", "what's up, man?")], + ["question"], + old_record, + new_record, + ) + self.assertEqual(delta, expected_delta) def test_history_with_unknown_field(self): p = Poll.objects.create(question="what's up?", pub_date=today) @@ -801,6 +949,45 @@ def test_history_with_unknown_field(self): with self.assertNumQueries(0): new_record.diff_against(old_record, excluded_fields=["unknown_field"]) + def test_history_with_deletion_record(self): + question = "what's up?" + p = Poll.objects.create(question=question, pub_date=today) + poll_pk = p.pk + new_record = p.history.first() + p.delete() + + deletion_record = HistoricalPoll.objects.get(id=poll_pk, history_type="-") + + with self.assertNumQueries(0): + delta = new_record.diff_against( + deletion_record, included_fields=["question"] + ) + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(len(delta.changes), 1) + self.assertEqual(delta.changes[0].new, question) + self.assertEqual(delta.changes[0].old, None) + + with self.assertNumQueries(0): + delta = deletion_record.diff_against( + new_record, included_fields=["question"] + ) + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(len(delta.changes), 1) + self.assertEqual(delta.changes[0].new, None) + self.assertEqual(delta.changes[0].old, question) + + def test_delete_with_deferred_fields(self): + Poll.objects.create(question="what's up bro?", pub_date=today) + Poll.objects.create(question="what's up sis?", pub_date=today) + Poll.objects.only("id").first().delete() + Poll.objects.defer("question").all().delete() + # Make sure bypass logic runs + Place.objects.create(name="cool place") + Place.objects.defer("name").first().delete() + with self.settings(SIMPLE_HISTORY_ENABLED=False): + Place.objects.create(name="cool place") + Place.objects.defer("name").all().delete() + def test_history_with_custom_queryset(self): PollWithQuerySetCustomizations.objects.create( id=1, pub_date=today, question="Question 1" @@ -837,6 +1024,33 @@ def test_history_with_custom_queryset(self): {"Question 1"}, ) + def test_history_with_deletion_record(self): + question = "what's up?" + p = Poll.objects.create(question=question, pub_date=today) + poll_pk = p.pk + new_record = p.history.first() + p.delete() + + deletion_record = HistoricalPoll.objects.get(id=poll_pk, history_type="-") + + with self.assertNumQueries(0): + delta = new_record.diff_against( + deletion_record, included_fields=["question"] + ) + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(len(delta.changes), 1) + self.assertEqual(delta.changes[0].new, question) + self.assertEqual(delta.changes[0].old, None) + + with self.assertNumQueries(0): + delta = deletion_record.diff_against( + new_record, included_fields=["question"] + ) + self.assertEqual(delta.changed_fields, ["question"]) + self.assertEqual(len(delta.changes), 1) + self.assertEqual(delta.changes[0].new, None) + self.assertEqual(delta.changes[0].old, question) + class GetPrevRecordAndNextRecordTestCase(TestCase): def assertRecordsMatch(self, record_a, record_b): @@ -1003,6 +1217,14 @@ def assert_tracked_fields_equal(model, expected_field_names): PollWithHistoricalIPAddress, ["id", "question", "pub_date"], ) + assert_tracked_fields_equal( + PollWithManyToMany, + ["id", "question", "pub_date"], + ) + assert_tracked_fields_equal( + Choice, + ["id", "poll", "choice", "votes"], + ) assert_tracked_fields_equal( ModelWithCustomAttrOneToOneField, ["id", "poll"], @@ -1922,7 +2144,6 @@ def test_self_field(self): class ManyToManyWithSignalsTest(TestCase): def setUp(self): self.model = PollWithManyToManyWithIPAddress - # self.historical_through_model = self.model.history. self.places = ( Place.objects.create(name="London"), Place.objects.create(name="Paris"), @@ -1962,10 +2183,28 @@ def test_diff(self): new = self.poll.history.first() old = new.prev_record - delta = new.diff_against(old) - - self.assertEqual("places", delta.changes[0].field) - self.assertEqual(2, len(delta.changes[0].new)) + with self.assertNumQueries(2): # Once for each record + delta = new.diff_against(old) + expected_delta = ModelDelta( + [ + ModelChange( + "places", + [], + [ + { + "pollwithmanytomanywithipaddress": self.poll.pk, + "place": place.pk, + "ip_address": "192.168.0.1", + } + for place in self.places + ], + ) + ], + ["places"], + old, + new, + ) + self.assertEqual(delta, expected_delta) class ManyToManyCustomIDTest(TestCase): @@ -2203,6 +2442,43 @@ def test_bulk_add_remove(self): historical_place = m2m_record.places.first() self.assertEqual(historical_place.place, self.place) + def test_add_remove_set_and_clear_methods_make_expected_num_queries(self): + for num_places in (1, 2, 4): + with self.subTest(num_places=num_places): + start_pk = 100 + num_places + places = Place.objects.bulk_create( + Place(pk=pk, name=f"Place {pk}") + for pk in range(start_pk, start_pk + num_places) + ) + self.assertEqual(len(places), num_places) + self.assertEqual(self.poll.places.count(), 0) + + # The number of queries should stay the same, regardless of + # the number of places added or removed + with self.assertNumQueries(5): + self.poll.places.add(*places) + self.assertEqual(self.poll.places.count(), num_places) + + with self.assertNumQueries(3): + self.poll.places.remove(*places) + self.assertEqual(self.poll.places.count(), 0) + + with self.assertNumQueries(6): + self.poll.places.set(places) + self.assertEqual(self.poll.places.count(), num_places) + + with self.assertNumQueries(4): + self.poll.places.set([]) + self.assertEqual(self.poll.places.count(), 0) + + with self.assertNumQueries(5): + self.poll.places.add(*places) + self.assertEqual(self.poll.places.count(), num_places) + + with self.assertNumQueries(3): + self.poll.places.clear() + self.assertEqual(self.poll.places.count(), 0) + def test_m2m_relation(self): # Ensure only the correct M2Ms are saved and returned for history objects poll_2 = PollWithManyToMany.objects.create(question="Why", pub_date=today) @@ -2214,12 +2490,12 @@ def test_m2m_relation(self): self.assertEqual(self.poll.history.all()[0].places.count(), 0) self.assertEqual(poll_2.history.all()[0].places.count(), 2) - def test_skip_history(self): + def test_skip_history_when_updating_an_object(self): skip_poll = PollWithManyToMany.objects.create( question="skip history?", pub_date=today ) - self.assertEqual(self.poll.history.all().count(), 1) - self.assertEqual(self.poll.history.all()[0].places.count(), 0) + self.assertEqual(skip_poll.history.all().count(), 1) + self.assertEqual(skip_poll.history.all()[0].places.count(), 0) skip_poll.skip_history_when_saving = True @@ -2227,8 +2503,8 @@ def test_skip_history(self): skip_poll.save() skip_poll.places.add(self.place) - self.assertEqual(self.poll.history.all().count(), 1) - self.assertEqual(self.poll.history.all()[0].places.count(), 0) + self.assertEqual(skip_poll.history.all().count(), 1) + self.assertEqual(skip_poll.history.all()[0].places.count(), 0) del skip_poll.skip_history_when_saving place_2 = Place.objects.create(name="Place 2") @@ -2238,52 +2514,78 @@ def test_skip_history(self): self.assertEqual(skip_poll.history.all().count(), 2) self.assertEqual(skip_poll.history.all()[0].places.count(), 2) + def test_skip_history_when_creating_an_object(self): + initial_poll_count = PollWithManyToMany.objects.count() + + skip_poll = PollWithManyToMany(question="skip history?", pub_date=today) + skip_poll.skip_history_when_saving = True + skip_poll.save() + skip_poll.places.add(self.place) + + self.assertEqual(skip_poll.history.count(), 0) + self.assertEqual(PollWithManyToMany.objects.count(), initial_poll_count + 1) + self.assertEqual(skip_poll.places.count(), 1) + + @override_settings(SIMPLE_HISTORY_ENABLED=False) + def test_saving_with_disabled_history_doesnt_create_records(self): + # 1 from `setUp()` + self.assertEqual(PollWithManyToMany.history.count(), 1) + + poll = PollWithManyToMany.objects.create( + question="skip history?", pub_date=today + ) + poll.question = "huh?" + poll.save() + poll.places.add(self.place) + + self.assertEqual(poll.history.count(), 0) + # The count should not have changed + self.assertEqual(PollWithManyToMany.history.count(), 1) + def test_diff_against(self): self.poll.places.add(self.place) add_record, create_record = self.poll.history.all() - delta = add_record.diff_against(create_record) + with self.assertNumQueries(2): # Once for each record + delta = add_record.diff_against(create_record) expected_change = ModelChange( "places", [], [{"pollwithmanytomany": self.poll.pk, "place": self.place.pk}] ) - self.assertEqual(delta.changed_fields, ["places"]) - self.assertEqual(delta.old_record, create_record) - self.assertEqual(delta.new_record, add_record) - self.assertEqual(expected_change.field, delta.changes[0].field) - - self.assertListEqual(expected_change.new, delta.changes[0].new) - self.assertListEqual(expected_change.old, delta.changes[0].old) + expected_delta = ModelDelta( + [expected_change], ["places"], create_record, add_record + ) + self.assertEqual(delta, expected_delta) - delta = add_record.diff_against(create_record, included_fields=["places"]) - self.assertEqual(delta.changed_fields, ["places"]) - self.assertEqual(delta.old_record, create_record) - self.assertEqual(delta.new_record, add_record) - self.assertEqual(expected_change.field, delta.changes[0].field) + with self.assertNumQueries(2): # Once for each record + delta = add_record.diff_against(create_record, included_fields=["places"]) + self.assertEqual(delta, expected_delta) - delta = add_record.diff_against(create_record, excluded_fields=["places"]) - self.assertEqual(delta.changed_fields, []) - self.assertEqual(delta.old_record, create_record) - self.assertEqual(delta.new_record, add_record) + with self.assertNumQueries(0): + delta = add_record.diff_against(create_record, excluded_fields=["places"]) + expected_delta = dataclasses.replace( + expected_delta, changes=[], changed_fields=[] + ) + self.assertEqual(delta, expected_delta) self.poll.places.clear() # First and third records are effectively the same. del_record, add_record, create_record = self.poll.history.all() - delta = del_record.diff_against(create_record) + with self.assertNumQueries(2): # Once for each record + delta = del_record.diff_against(create_record) self.assertNotIn("places", delta.changed_fields) + with self.assertNumQueries(2): # Once for each record + delta = del_record.diff_against(add_record) # Second and third should have the same diffs as first and second, but with # old and new reversed expected_change = ModelChange( "places", [{"place": self.place.pk, "pollwithmanytomany": self.poll.pk}], [] ) - delta = del_record.diff_against(add_record) - self.assertEqual(delta.changed_fields, ["places"]) - self.assertEqual(delta.old_record, add_record) - self.assertEqual(delta.new_record, del_record) - self.assertEqual(expected_change.field, delta.changes[0].field) - self.assertListEqual(expected_change.new, delta.changes[0].new) - self.assertListEqual(expected_change.old, delta.changes[0].old) + expected_delta = ModelDelta( + [expected_change], ["places"], add_record, del_record + ) + self.assertEqual(delta, expected_delta) @override_settings(**database_router_override_settings) @@ -2291,7 +2593,7 @@ class MultiDBExplicitHistoryUserIDTest(TestCase): databases = {"default", "other"} def setUp(self): - self.user = get_user_model().objects.create( # nosec + self.user = get_user_model().objects.create( username="username", email="username@test.com", password="top_secret" ) @@ -2332,10 +2634,10 @@ def test_history_user_does_not_exist(self): class RelatedNameTest(TestCase): def setUp(self): - self.user_one = get_user_model().objects.create( # nosec + self.user_one = get_user_model().objects.create( username="username_one", email="first@user.com", password="top_secret" ) - self.user_two = get_user_model().objects.create( # nosec + self.user_two = get_user_model().objects.create( username="username_two", email="second@user.com", password="top_secret" ) @@ -2603,7 +2905,7 @@ def test_historic_to_historic(self): # test querying directly from the history table and converting # to an instance, it should chase the foreign key properly # in this case if _as_of is not present we use the history_date - # https://github.com/jazzband/django-simple-history/issues/983 + # https://github.com/django-commons/django-simple-history/issues/983 pt1h = TestHistoricParticipanToHistoricOrganization.history.all()[0] pt1i = pt1h.instance self.assertEqual(pt1i.organization.name, "modified") @@ -2612,3 +2914,132 @@ def test_historic_to_historic(self): )[0] pt1i = pt1h.instance self.assertEqual(pt1i.organization.name, "original") + + +class HistoricOneToOneFieldTest(TestCase): + """ + Tests chasing OneToOne foreign keys across time points naturally with + HistoricForeignKey. + """ + + def test_non_historic_to_historic(self): + """ + Non-historic table with one to one relationship to historic table. + + In this case it should simply behave like OneToOneField because + the origin model (this one) cannot be historic, so OneToOneField + lookups are always "current". + """ + org = TestOrganizationWithHistory.objects.create(name="original") + part = TestParticipantToHistoricOrganizationOneToOne.objects.create( + name="part", organization=org + ) + before_mod = timezone.now() + self.assertEqual(part.organization.id, org.id) + self.assertEqual(org.participant, part) + + historg = TestOrganizationWithHistory.history.as_of(before_mod).get( + name="original" + ) + self.assertEqual(historg.participant, part) + + self.assertEqual(org.history.count(), 1) + org.name = "modified" + org.save() + self.assertEqual(org.history.count(), 2) + + # drop internal caches, re-select + part = TestParticipantToHistoricOrganizationOneToOne.objects.get(name="part") + self.assertEqual(part.organization.name, "modified") + + def test_historic_to_non_historic(self): + """ + Historic table OneToOneField to non-historic table. + + In this case it should simply behave like OneToOneField because + the origin model (this one) can be historic but the target model + is not, so foreign key lookups are always "current". + """ + org = TestOrganization.objects.create(name="org") + part = TestHistoricParticipantToOrganizationOneToOne.objects.create( + name="original", organization=org + ) + self.assertEqual(part.organization.id, org.id) + self.assertEqual(org.participant, part) + + histpart = TestHistoricParticipantToOrganizationOneToOne.objects.get( + name="original" + ) + self.assertEqual(histpart.organization.id, org.id) + + def test_historic_to_historic(self): + """ + Historic table with one to one relationship to historic table. + + In this case as_of queries on the origin model (this one) + or on the target model (the other one) will traverse the + foreign key relationship honoring the timepoint of the + original query. This only happens when both tables involved + are historic. + + At t1 we have one org, one participant. + At t2 we have one org, one participant, however the org's name has changed. + """ + org = TestOrganizationWithHistory.objects.create(name="original") + + p1 = TestHistoricParticipanToHistoricOrganizationOneToOne.objects.create( + name="p1", organization=org + ) + t1 = timezone.now() + org.name = "modified" + org.save() + p1.name = "p1_modified" + p1.save() + t2 = timezone.now() + + # forward relationships - see how natural chasing timepoint relations is + p1t1 = TestHistoricParticipanToHistoricOrganizationOneToOne.history.as_of( + t1 + ).get(name="p1") + self.assertEqual(p1t1.organization, org) + self.assertEqual(p1t1.organization.name, "original") + p1t2 = TestHistoricParticipanToHistoricOrganizationOneToOne.history.as_of( + t2 + ).get(name="p1_modified") + self.assertEqual(p1t2.organization, org) + self.assertEqual(p1t2.organization.name, "modified") + + # reverse relationships + # at t1 + ot1 = TestOrganizationWithHistory.history.as_of(t1).all()[0] + self.assertEqual(ot1.historic_participant.name, "p1") + + # at t2 + ot2 = TestOrganizationWithHistory.history.as_of(t2).all()[0] + self.assertEqual(ot2.historic_participant.name, "p1_modified") + + # current + self.assertEqual(org.historic_participant.name, "p1_modified") + + self.assertTrue(is_historic(ot1)) + self.assertFalse(is_historic(org)) + + self.assertIsInstance( + to_historic(ot1), TestOrganizationWithHistory.history.model + ) + self.assertIsNone(to_historic(org)) + + # test querying directly from the history table and converting + # to an instance, it should chase the foreign key properly + # in this case if _as_of is not present we use the history_date + # https://github.com/django-commons/django-simple-history/issues/983 + pt1h = TestHistoricParticipanToHistoricOrganizationOneToOne.history.all()[0] + pt1i = pt1h.instance + self.assertEqual(pt1i.organization.name, "modified") + pt1h = ( + TestHistoricParticipanToHistoricOrganizationOneToOne.history.all().order_by( + "history_date" + )[0] + ) + pt1i = pt1h.instance + self.assertEqual(pt1i.organization.name, "original") diff --git a/simple_history/tests/tests/test_template_utils.py b/simple_history/tests/tests/test_template_utils.py new file mode 100644 index 0000000..9a4e8b5 --- /dev/null +++ b/simple_history/tests/tests/test_template_utils.py @@ -0,0 +1,313 @@ +from datetime import datetime + +from django.test import TestCase +from django.utils.dateparse import parse_datetime +from django.utils.safestring import mark_safe + +from simple_history.models import ModelChange, ModelDelta +from simple_history.template_utils import HistoricalRecordContextHelper, is_safe_str + +from ...tests.models import Choice, Place, Poll, PollWithManyToMany + + +class HistoricalRecordContextHelperTestCase(TestCase): + + def test__context_for_delta_changes__basic_usage_works_as_expected(self): + # --- Text and datetimes --- + + old_date = "2021-01-01 12:00:00" + poll = Poll.objects.create(question="old?", pub_date=parse_datetime(old_date)) + new_date = "2021-01-02 12:00:00" + poll.question = "new?" + poll.pub_date = parse_datetime(new_date) + poll.save() + + new, old = poll.history.all() + expected_context_list = [ + { + "field": "Date published", + "old": old_date, + "new": new_date, + }, + { + "field": "Question", + "old": "old?", + "new": "new?", + }, + ] + self.assert__context_for_delta_changes__equal( + Poll, old, new, expected_context_list + ) + + # --- Foreign keys and ints --- + + poll1 = Poll.objects.create(question="1?", pub_date=datetime.now()) + poll2 = Poll.objects.create(question="2?", pub_date=datetime.now()) + choice = Choice.objects.create(poll=poll1, votes=1) + choice.poll = poll2 + choice.votes = 10 + choice.save() + + new, old = choice.history.all() + expected_context_list = [ + { + "field": "Poll", + "old": f"Poll object ({poll1.pk})", + "new": f"Poll object ({poll2.pk})", + }, + { + "field": "Votes", + "old": "1", + "new": "10", + }, + ] + self.assert__context_for_delta_changes__equal( + Choice, old, new, expected_context_list + ) + + # --- M2M objects, text and datetimes (across 3 records) --- + + poll = PollWithManyToMany.objects.create( + question="old?", pub_date=parse_datetime(old_date) + ) + poll.question = "new?" + poll.pub_date = parse_datetime(new_date) + poll.save() + place1 = Place.objects.create(name="Place 1") + place2 = Place.objects.create(name="Place 2") + poll.places.add(place1, place2) + + newest, _middle, oldest = poll.history.all() + expected_context_list = [ + # (The dicts should be sorted by the fields' attribute names) + { + "field": "Places", + "old": "[]", + "new": f"[Place object ({place1.pk}), Place object ({place2.pk})]", + }, + { + "field": "Date published", + "old": old_date, + "new": new_date, + }, + { + "field": "Question", + "old": "old?", + "new": "new?", + }, + ] + self.assert__context_for_delta_changes__equal( + PollWithManyToMany, oldest, newest, expected_context_list + ) + + def assert__context_for_delta_changes__equal( + self, model, old_record, new_record, expected_context_list + ): + delta = new_record.diff_against(old_record, foreign_keys_are_objs=True) + context_helper = HistoricalRecordContextHelper(model, new_record) + context_list = context_helper.context_for_delta_changes(delta) + self.assertListEqual(context_list, expected_context_list) + + def test__context_for_delta_changes__with_string_len_around_character_limit(self): + now = datetime.now() + + def test_context_dict( + *, initial_question, changed_question, expected_old, expected_new + ) -> None: + poll = Poll.objects.create(question=initial_question, pub_date=now) + poll.question = changed_question + poll.save() + new, old = poll.history.all() + + expected_context_dict = { + "field": "Question", + "old": expected_old, + "new": expected_new, + } + self.assert__context_for_delta_changes__equal( + Poll, old, new, [expected_context_dict] + ) + # Flipping the records should produce the same result (other than also + # flipping the expected "old" and "new" values, of course) + expected_context_dict = { + "field": "Question", + "old": expected_new, + "new": expected_old, + } + self.assert__context_for_delta_changes__equal( + Poll, new, old, [expected_context_dict] + ) + + # Check the character limit used in the assertions below + self.assertEqual( + HistoricalRecordContextHelper.DEFAULT_MAX_DISPLAYED_DELTA_CHANGE_CHARS, 100 + ) + + # Number of characters right on the limit + test_context_dict( + initial_question=f"Y{'A' * 99}", + changed_question=f"W{'A' * 99}", + expected_old=f"Y{'A' * 99}", + expected_new=f"W{'A' * 99}", + ) + + # Over the character limit, with various ways that a shared prefix affects how + # the shortened strings are lined up with each other + test_context_dict( + initial_question=f"Y{'A' * 100}", + changed_question=f"W{'A' * 100}", + expected_old=f"Y{'A' * 60}[35 chars]AAAAA", + expected_new=f"W{'A' * 60}[35 chars]AAAAA", + ) + test_context_dict( + initial_question=f"{'A' * 100}Y", + changed_question=f"{'A' * 100}W", + expected_old=f"AAAAA[13 chars]{'A' * 82}Y", + expected_new=f"AAAAA[13 chars]{'A' * 82}W", + ) + test_context_dict( + initial_question=f"{'A' * 100}Y", + changed_question=f"{'A' * 199}W", + expected_old="AAAAA[90 chars]AAAAAY", + expected_new=f"AAAAA[90 chars]{'A' * 66}[34 chars]AAAAW", + ) + test_context_dict( + initial_question=f"{'A' * 50}Y{'E' * 100}", + changed_question=f"{'A' * 50}W{'E' * 149}", + expected_old=f"AAAAA[40 chars]AAAAAY{'E' * 60}[35 chars]EEEEE", + expected_new=f"AAAAA[40 chars]AAAAAW{'E' * 60}[84 chars]EEEEE", + ) + test_context_dict( + initial_question=f"{'A' * 50}Y{'E' * 149}", + changed_question=f"{'A' * 149}W{'E' * 50}", + expected_old=f"AAAAA[40 chars]AAAAAY{'E' * 60}[84 chars]EEEEE", + expected_new=f"AAAAA[40 chars]{'A' * 66}[84 chars]EEEEE", + ) + + # Only similar prefixes are detected and lined up; + # similar parts later in the strings are not + test_context_dict( + initial_question=f"{'Y' * 100}{'A' * 50}", + changed_question=f"{'W' * 100}{'A' * 50}{'H' * 50}", + expected_old=f"{'Y' * 61}[84 chars]AAAAA", + expected_new=f"{'W' * 61}[134 chars]HHHHH", + ) + + # Both "old" and "new" under the character limit + test_context_dict( + initial_question="A" * 10, + changed_question="A" * 100, + expected_old="A" * 10, + expected_new="A" * 100, + ) + # "new" just over the limit, but with "old" too short to be shortened + test_context_dict( + initial_question="A" * 10, + changed_question="A" * 101, + expected_old="A" * 10, + expected_new=f"{'A' * 71}[25 chars]AAAAA", + ) + # Both "old" and "new" under the character limit + test_context_dict( + initial_question="A" * 99, + changed_question="A" * 100, + expected_old="A" * 99, + expected_new="A" * 100, + ) + # "new" just over the limit, and "old" long enough to be shortened (which is + # done even if it's shorter than the character limit) + test_context_dict( + initial_question="A" * 99, + changed_question="A" * 101, + expected_old=f"AAAAA[13 chars]{'A' * 81}", + expected_new=f"AAAAA[13 chars]{'A' * 83}", + ) + + def test__context_for_delta_changes__preserves_html_safe_strings(self): + def get_context_dict_old_and_new(old_value, new_value) -> tuple[str, str]: + # The field doesn't really matter, as long as it exists on the model + # passed to `HistoricalRecordContextHelper` + change = ModelChange("question", old_value, new_value) + # (The record args are not (currently) used in the default implementation) + delta = ModelDelta([change], ["question"], None, None) + context_helper = HistoricalRecordContextHelper(Poll, None) + (context_dict,) = context_helper.context_for_delta_changes(delta) + return context_dict["old"], context_dict["new"] + + # Strings not marked as safe should be escaped + old_string = "Hey" + new_string = "Hello" + old, new = get_context_dict_old_and_new(old_string, new_string) + self.assertEqual(old, "<i>Hey</i>") + self.assertEqual(new, "<b>Hello</b>") + # The result should still be marked safe as part of being escaped + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # Strings marked as safe should be kept unchanged... + old_safe_string = mark_safe("Hey") + new_safe_string = mark_safe("Hello") + old, new = get_context_dict_old_and_new(old_safe_string, new_safe_string) + self.assertEqual(old, old_safe_string) + self.assertEqual(new, new_safe_string) + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # ...also if one is safe and the other isn't... + old_string = "Hey" + new_safe_string = mark_safe("Hello") + old, new = get_context_dict_old_and_new(old_string, new_safe_string) + self.assertEqual(old, "<i>Hey</i>") + self.assertEqual(new, new_safe_string) + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # ...unless at least one of them is too long, in which case they should both be + # properly escaped - including mangled tags + old_safe_string = mark_safe(f"

    {'A' * 1000}

    ") + new_safe_string = mark_safe("

    Hello

    ") + old, new = get_context_dict_old_and_new(old_safe_string, new_safe_string) + # (`` has been mangled) + expected_old = f"<p><strong>{'A' * 61}[947 chars]></p>" + self.assertEqual(old, expected_old) + self.assertEqual(new, "<p><strong>Hello</strong></p>") + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # Unsafe strings inside lists should also be escaped + old_list = ["Hey", "Hey"] + new_list = ["Hello", "Hello"] + old, new = get_context_dict_old_and_new(old_list, new_list) + self.assertEqual(old, "[Hey, <i>Hey</i>]") + self.assertEqual(new, "[<b>Hello</b>, Hello]") + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # Safe strings inside lists should be kept unchanged... + old_safe_list = [mark_safe("Hey"), mark_safe("Hey")] + new_safe_list = [mark_safe("Hello"), mark_safe("Hello")] + old, new = get_context_dict_old_and_new(old_safe_list, new_safe_list) + self.assertEqual(old, "[Hey, Hey]") + self.assertEqual(new, "[Hello, Hello]") + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # ...but not when not all elements are safe... + old_half_safe_list = [mark_safe("Hey"), "Hey"] + new_half_safe_list = [mark_safe("Hello"), "Hello"] + old, new = get_context_dict_old_and_new(old_half_safe_list, new_half_safe_list) + self.assertEqual(old, "[Hey, <i>Hey</i>]") + self.assertEqual(new, "[<b>Hello</b>, Hello]") + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # ...and also not when some of the elements are too long + old_safe_list = [mark_safe("Hey"), mark_safe(f"{'A' * 1000}")] + new_safe_list = [mark_safe("Hello"), mark_safe(f"{'B' * 1000}")] + old, new = get_context_dict_old_and_new(old_safe_list, new_safe_list) + self.assertEqual(old, f"[Hey, <i>{'A' * 53}[947 chars]</i>]") + self.assertEqual(new, f"[<b>Hello</b>, {'B' * 47}[949 chars]BBBB]") + self.assertTrue(is_safe_str(old) and is_safe_str(new)) + + # HTML tags inside too long strings should be properly escaped - including + # mangled tags + old_safe_list = [mark_safe(f"

    {'A' * 1000}

    ")] + new_safe_list = [mark_safe(f"{'B' * 1000}")] + old, new = get_context_dict_old_and_new(old_safe_list, new_safe_list) + # (Tags have been mangled at the end of the strings) + self.assertEqual(old, f"[<h1><i>{'A' * 55}[950 chars]/h1>]") + self.assertEqual(new, f"[<strong>{'B' * 54}[951 chars]ong>]") + self.assertTrue(is_safe_str(old) and is_safe_str(new)) diff --git a/simple_history/tests/tests/test_utils.py b/simple_history/tests/tests/test_utils.py index e7da5af..7db701d 100644 --- a/simple_history/tests/tests/test_utils.py +++ b/simple_history/tests/tests/test_utils.py @@ -1,3 +1,4 @@ +import unittest from datetime import datetime from unittest import skipUnless from unittest.mock import Mock, patch @@ -14,9 +15,16 @@ Document, Place, Poll, + PollChildBookWithManyToMany, + PollChildRestaurantWithManyToMany, PollWithAlternativeManager, PollWithExcludeFields, PollWithHistoricalSessionAttr, + PollWithManyToMany, + PollWithManyToManyCustomHistoryID, + PollWithManyToManyWithIPAddress, + PollWithSelfManyToMany, + PollWithSeveralManyToMany, PollWithUniqueQuestion, Street, ) @@ -24,12 +32,71 @@ bulk_create_with_history, bulk_update_with_history, get_history_manager_for_model, + get_history_model_for_model, + get_m2m_field_name, + get_m2m_reverse_field_name, update_change_reason, ) User = get_user_model() +class GetM2MFieldNamesTestCase(unittest.TestCase): + def test__get_m2m_field_name__returns_expected_value(self): + def field_names(model): + history_model = get_history_model_for_model(model) + # Sort the fields, to prevent flaky tests + fields = sorted(history_model._history_m2m_fields, key=lambda f: f.name) + return [get_m2m_field_name(field) for field in fields] + + self.assertListEqual(field_names(PollWithManyToMany), ["pollwithmanytomany"]) + self.assertListEqual( + field_names(PollWithManyToManyCustomHistoryID), + ["pollwithmanytomanycustomhistoryid"], + ) + self.assertListEqual( + field_names(PollWithManyToManyWithIPAddress), + ["pollwithmanytomanywithipaddress"], + ) + self.assertListEqual( + field_names(PollWithSeveralManyToMany), ["pollwithseveralmanytomany"] * 3 + ) + self.assertListEqual( + field_names(PollChildBookWithManyToMany), + ["pollchildbookwithmanytomany"] * 2, + ) + self.assertListEqual( + field_names(PollChildRestaurantWithManyToMany), + ["pollchildrestaurantwithmanytomany"] * 2, + ) + self.assertListEqual( + field_names(PollWithSelfManyToMany), ["from_pollwithselfmanytomany"] + ) + + def test__get_m2m_reverse_field_name__returns_expected_value(self): + def field_names(model): + history_model = get_history_model_for_model(model) + # Sort the fields, to prevent flaky tests + fields = sorted(history_model._history_m2m_fields, key=lambda f: f.name) + return [get_m2m_reverse_field_name(field) for field in fields] + + self.assertListEqual(field_names(PollWithManyToMany), ["place"]) + self.assertListEqual(field_names(PollWithManyToManyCustomHistoryID), ["place"]) + self.assertListEqual(field_names(PollWithManyToManyWithIPAddress), ["place"]) + self.assertListEqual( + field_names(PollWithSeveralManyToMany), ["book", "place", "restaurant"] + ) + self.assertListEqual( + field_names(PollChildBookWithManyToMany), ["book", "place"] + ) + self.assertListEqual( + field_names(PollChildRestaurantWithManyToMany), ["place", "restaurant"] + ) + self.assertListEqual( + field_names(PollWithSelfManyToMany), ["to_pollwithselfmanytomany"] + ) + + class BulkCreateWithHistoryTestCase(TestCase): def setUp(self): self.data = [ diff --git a/simple_history/tests/tests/utils.py b/simple_history/tests/tests/utils.py index 88d6d92..16a7e6b 100644 --- a/simple_history/tests/tests/utils.py +++ b/simple_history/tests/tests/utils.py @@ -1,9 +1,8 @@ from enum import Enum -import django from django.conf import settings - -from simple_history.tests.models import HistoricalModelWithHistoryInDifferentDb +from django.db.models import Model +from django.test import TestCase request_middleware = "simple_history.middleware.HistoryRequestMiddleware" @@ -14,6 +13,27 @@ } +class HistoricalTestCase(TestCase): + def assertRecordValues(self, record, klass: type[Model], values_dict: dict): + """ + Fail if ``record`` doesn't contain the field values in ``values_dict``. + ``record.history_object`` is also checked. + History-tracked fields in ``record`` that are not in ``values_dict``, are not + checked. + + :param record: A historical record. + :param klass: The type of the history-tracked class of ``record``. + :param values_dict: Field names of ``record`` mapped to their expected values. + """ + for key, value in values_dict.items(): + self.assertEqual(getattr(record, key), value) + + self.assertEqual(record.history_object.__class__, klass) + for key, value in values_dict.items(): + if key not in ("history_type", "history_change_reason"): + self.assertEqual(getattr(record.history_object, key), value) + + class TestDbRouter: def db_for_read(self, model, **hints): if model._meta.app_label == "external": @@ -46,16 +66,25 @@ def allow_migrate(self, db, app_label, model_name=None, **hints): class TestModelWithHistoryInDifferentDbRouter: def db_for_read(self, model, **hints): + # Avoids circular importing + from ..models import HistoricalModelWithHistoryInDifferentDb + if model == HistoricalModelWithHistoryInDifferentDb: return OTHER_DB_NAME return None def db_for_write(self, model, **hints): + # Avoids circular importing + from ..models import HistoricalModelWithHistoryInDifferentDb + if model == HistoricalModelWithHistoryInDifferentDb: return OTHER_DB_NAME return None def allow_relation(self, obj1, obj2, **hints): + # Avoids circular importing + from ..models import HistoricalModelWithHistoryInDifferentDb + if isinstance(obj1, HistoricalModelWithHistoryInDifferentDb) or isinstance( obj2, HistoricalModelWithHistoryInDifferentDb ): @@ -63,6 +92,9 @@ def allow_relation(self, obj1, obj2, **hints): return None def allow_migrate(self, db, app_label, model_name=None, **hints): + # Avoids circular importing + from ..models import HistoricalModelWithHistoryInDifferentDb + if model_name == HistoricalModelWithHistoryInDifferentDb._meta.model_name: return db == OTHER_DB_NAME return None diff --git a/simple_history/utils.py b/simple_history/utils.py index 321ec14..0fdfc62 100644 --- a/simple_history/utils.py +++ b/simple_history/utils.py @@ -57,6 +57,30 @@ def get_app_model_primary_key_name(model): return model._meta.pk.name +def get_m2m_field_name(m2m_field: ManyToManyField) -> str: + """ + Returns the field name of an M2M field's through model that corresponds to the model + the M2M field is defined on. + + E.g. for a ``votes`` M2M field on a ``Poll`` model that references a ``Vote`` model + (and with a default-generated through model), this function would return ``"poll"``. + """ + # This method is part of Django's internal API + return m2m_field.m2m_field_name() + + +def get_m2m_reverse_field_name(m2m_field: ManyToManyField) -> str: + """ + Returns the field name of an M2M field's through model that corresponds to the model + the M2M field references. + + E.g. for a ``votes`` M2M field on a ``Poll`` model that references a ``Vote`` model + (and with a default-generated through model), this function would return ``"vote"``. + """ + # This method is part of Django's internal API + return m2m_field.m2m_reverse_field_name() + + def bulk_create_with_history( objs, model, @@ -71,7 +95,8 @@ def bulk_create_with_history( Bulk create the objects specified by objs while also bulk creating their history (all in one transaction). Because of not providing primary key attribute after bulk_create on any DB except - Postgres (https://docs.djangoproject.com/en/2.2/ref/models/querysets/#bulk-create) + Postgres + (https://docs.djangoproject.com/en/stable/ref/models/querysets/#bulk-create) Divide this process on two transactions for other DB's :param objs: List of objs (not yet saved to the db) of type model :param model: Model class that should be created @@ -114,7 +139,7 @@ def bulk_create_with_history( if second_transaction_required: with transaction.atomic(savepoint=False): # Generate a common query to avoid n+1 selections - # https://github.com/jazzband/django-simple-history/issues/974 + # https://github.com/django-commons/django-simple-history/issues/974 cumulative_filter = None obj_when_list = [] for i, obj in enumerate(objs_with_id): diff --git a/tox.ini b/tox.ini index dc692c6..eee2497 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,13 @@ [tox] envlist = - py{38,39,310}-dj32-{sqlite3,postgres,mysql,mariadb}, - py{38,39,310,311,312}-dj42-{sqlite3,postgres,mysql,mariadb}, - py{310,311,312}-dj50-{sqlite3,postgres,mysql,mariadb}, - py{310,311,312}-djmain-{sqlite3,postgres,mysql,mariadb}, - # DEV: Add `313` to the Python versions above (so that postgres is tested with 3.13) - # when `psycopg2-binary` supports 3.13 - py313-dj{42,50,main}-{sqlite3,mysql,mariadb}, + py3{9-13}-dj42-{sqlite3,postgres,mysql,mariadb}, + py3{10-13}-dj{50-52}-{sqlite3,postgres,mysql,mariadb}, + py3{12-13}-dj{main}-{sqlite3,postgres,mysql,mariadb}, docs, lint [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311, docs, lint @@ -21,9 +16,10 @@ python = [gh-actions:env] DJANGO = - 3.2: dj32 4.2: dj42 5.0: dj50 + 5.1: dj51 + 5.2: dj52 main: djmain [flake8] @@ -35,9 +31,10 @@ exclude = __init__.py,simple_history/registry_tests/migration_test_app/migration [testenv] deps = -rrequirements/test.txt - dj32: Django>=3.2,<3.3 dj42: Django>=4.2,<4.3 dj50: Django>=5.0,<5.1 + dj51: Django>=5.1,<5.2 + dj52: Django>=5.2a1,<5.3 # Use a1 to allow testing of the release candidates djmain: https://github.com/django/django/tarball/main postgres: -rrequirements/postgres.txt mysql: -rrequirements/mysql.txt @@ -53,8 +50,8 @@ commands = [testenv:format] deps = -rrequirements/lint.txt commands = - isort docs simple_history runtests.py setup.py - black docs simple_history runtests.py setup.py + isort docs simple_history runtests.py + black docs simple_history runtests.py flake8 simple_history [testenv:lint]