pax_global_header00006660000000000000000000000064146256763610014532gustar00rootroot0000000000000052 comment=2a2bec97e0415feee7ab8d80e4492480506f3a09 jazzband-django-simple-history-2a2bec9/000077500000000000000000000000001462567636100202545ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/.codeclimate.yml000066400000000000000000000016511462567636100233310ustar00rootroot00000000000000--- version: "2" # required to adjust maintainability checks checks: argument-count: config: threshold: 7 complex-logic: config: threshold: 7 file-lines: config: threshold: 500 method-complexity: config: threshold: 7 method-count: config: threshold: 20 method-lines: config: threshold: 25 nested-control-flow: config: threshold: 4 return-statements: config: threshold: 4 similar-code: config: threshold: 50 # language-specific defaults. an override will affect all languages. identical-code: config: threshold: # language-specific defaults. an override will affect all languages. plugins: bandit: enabled: true pep8: enabled: false radon: enabled: true threshold: "C" sonar-python: enabled: true exclude_patterns: - "simple_history/tests/" - "simple_history/registry_tests/" jazzband-django-simple-history-2a2bec9/.coveragerc000066400000000000000000000001121462567636100223670ustar00rootroot00000000000000[run] include = simple_history/* omit = simple_history/tests/* branch = 1 jazzband-django-simple-history-2a2bec9/.editorconfig000066400000000000000000000006641462567636100227370ustar00rootroot00000000000000; This file is for unifying the coding style for different editors and IDEs. ; More information at http://EditorConfig.org root = true [*] charset = utf-8 indent_style = space end_of_line = lf insert_final_newline = true max_line_length = 88 trim_trailing_whitespace = true [*.{py,rst,ini}] indent_size = 4 [*.{html,yml}] indent_size = 2 [LICENSE.txt] end_of_line = crlf insert_final_newline = false [Makefile] indent_style = tab jazzband-django-simple-history-2a2bec9/.github/000077500000000000000000000000001462567636100216145ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/.github/ISSUE_TEMPLATE/000077500000000000000000000000001462567636100237775ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013761462567636100265000ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - OS: [e.g. Ubuntu 18.04] - Browser (if applicable): [e.g. chrome, safari] - Django Simple History Version: [e.g. 1.9.1] - Django Version: [e.g. 1.11.11] - Database Version: [e.g. PostgreSQL 10.5.0] **Additional context** Add any other context about the problem here. jazzband-django-simple-history-2a2bec9/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000010031462567636100275160ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project --- **Problem Statement** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. jazzband-django-simple-history-2a2bec9/.github/dependabot.yml000066400000000000000000000004501462567636100244430ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: "pip" directory: "/requirements" schedule: interval: "daily" - package-ecosystem: "github-actions" # Workflow files stored in the default location of `.github/workflows` directory: "/" schedule: interval: "weekly" jazzband-django-simple-history-2a2bec9/.github/release.yml000066400000000000000000000001201462567636100237500ustar00rootroot00000000000000--- changelog: exclude: authors: - dependabot - pre-commit-ci jazzband-django-simple-history-2a2bec9/.github/workflows/000077500000000000000000000000001462567636100236515ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/.github/workflows/release.yml000066400000000000000000000016601462567636100260170ustar00rootroot00000000000000--- name: Release on: push: tags: - '*' jobs: build: if: github.repository == 'jazzband/django-simple-history' 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 build twine - name: Build package run: | python -m build twine check dist/* - name: Upload packages to Jazzband if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: user: jazzband password: ${{ secrets.JAZZBAND_RELEASE_KEY }} repository-url: https://jazzband.co/projects/django-simple-history/upload jazzband-django-simple-history-2a2bec9/.github/workflows/test.yml000066400000000000000000000066451462567636100253660ustar00rootroot00000000000000--- name: Test on: [push, pull_request] jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13-dev'] django-version: ['4.2', '5.0', 'main'] exclude: # Exclude py3.8 and py3.9 for Django main and 5.0 - python-version: '3.8' django-version: '5.0' - python-version: '3.9' django-version: '5.0' - python-version: '3.8' django-version: 'main' - python-version: '3.9' django-version: 'main' services: postgres: image: postgres:latest env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: github_actions ports: - 5432:5432 # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 mysql: image: mysql:latest env: MYSQL_DATABASE: mysql MYSQL_ROOT_PASSWORD: mysql ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 mariadb: image: mariadb:latest env: MARIADB_DATABASE: mariadb MARIADB_ROOT_PASSWORD: mariadb ports: - 3307:3306 options: --health-cmd="mariadb-admin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: | pyproject.toml tox.ini requirements/*.txt - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements/tox.txt - name: Tox tests run: | tox -v env: DJANGO: ${{ matrix.django-version }} - name: Upload coverage uses: codecov/codecov-action@v3 with: name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} generated_file_checks: name: Check generated files runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install gettext run: | sudo apt-get update sudo apt-get install -y gettext - name: Set up newest stable Python version uses: actions/setup-python@v5 with: python-version: 3.11 cache: 'pip' # Invalidate the cache when this file updates, as the dependencies' versions # are pinned in the step below cache-dependency-path: '.github/workflows/test.yml' - name: Install dependencies run: | python -m pip install --upgrade pip # 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 - name: Check translation files are updated run: python -m simple_history.tests.generated_file_checks.check_translations jazzband-django-simple-history-2a2bec9/.gitignore000066400000000000000000000002551462567636100222460ustar00rootroot00000000000000*.egg *.egg-info/ *.eggs *.pyc .coverage .idea .tox/ .venv/ /.project /.pydevproject /.ve build/ dist/ docs/_build htmlcov/ MANIFEST test_files/ venv/ .DS_Store env .vscode jazzband-django-simple-history-2a2bec9/.pre-commit-config.yaml000066400000000000000000000026161462567636100245420ustar00rootroot00000000000000--- repos: - repo: https://github.com/PyCQA/bandit rev: 1.7.8 hooks: - id: bandit exclude: /.*tests/ - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.4.2 hooks: - id: black language_version: python3.8 - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 args: - "--config=tox.ini" - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: requirements-txt-fixer files: requirements/.*\.txt$ - id: trailing-whitespace - id: check-added-large-files - id: fix-byte-order-marker - id: check-docstring-first - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-toml - id: debug-statements - id: detect-private-key - repo: https://github.com/tox-dev/pyproject-fmt rev: 2.1.3 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject rev: v0.18 hooks: - id: validate-pyproject - repo: https://github.com/adrienverge/yamllint rev: v1.35.1 hooks: - id: yamllint args: - "--strict" - repo: https://github.com/asottile/pyupgrade rev: v3.15.2 hooks: - id: pyupgrade args: [--py38-plus] jazzband-django-simple-history-2a2bec9/.readthedocs.yaml000066400000000000000000000013201462567636100234770ustar00rootroot00000000000000--- # Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the OS, Python version and other tools you might need build: os: ubuntu-22.04 tools: python: "3.12" # Build documentation in the "docs/" directory with Sphinx sphinx: configuration: docs/conf.py # Optional but recommended, declare the Python requirements required # to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - requirements: requirements/docs.txt # Install this project locally, so that its package metadata can be queried - method: pip path: . jazzband-django-simple-history-2a2bec9/.yamllint000066400000000000000000000004431462567636100221070ustar00rootroot00000000000000# allow "on" until yamllint stops checking keys for truthy! # https://github.com/adrienverge/yamllint/issues/158 --- extends: default rules: comments-indentation: disable braces: disable line-length: max: 120 truthy: level: error allowed-values: ['true', 'false', 'on'] jazzband-django-simple-history-2a2bec9/AUTHORS.rst000066400000000000000000000143441462567636100221410ustar00rootroot00000000000000Maintainers =========== - Brian Armstrong (`barm `_) - David Grochowski (`ThePumpingLemma `_) - Kyle Seever (`kseever `_) - Micah Denbraver (`macro1 `_) - Ross Mechanic (`rossmechanic `_) - Trey Hunner (`treyhunner `_) Authors ======= - Adnan Umer (`uadnan `_) - Aleksey Kladov - Alexander Anikeev - Amanda Ng (`AmandaCLNg `_) - Amartis Gladius (`Amartis `_) - Anton Kulikov (`bigtimecriminal `_) - Ben Lawson (`blawson `_) - Benjamin Mampaey (`bmampaey `_) - Bheesham Persaud (`bheesham `_) - `bradford281 `_ - Brian Armstrong (`barm `_) - Brian Dixon - Brian Mesick (`bmedx `_) - Buddy Lindsey, Jr. - Carlos San Emeterio (`Carlos-San-Emeterio `_) - Christopher Broderick (`uhurusurfa `_) - Christopher Johns (`tyrantwave `_) - Conrad (`creyD `_) - Corey Bertram - Craig Maloney (`craigmaloney `_) - Damien Nozay - Daniel Gilge - Daniel Levy - Daniel Roschka - Daniil Skrobov (`yetanotherape `_) - David Grochowski (`ThePumpingLemma `_) - David Hite - David Smith - `ddabble `_ - Dmytro Shyshov (`xahgmah `_) - Edouard Richard (`vied12 ` _) - Eduardo Cuducos - Erik van Widenfelt (`erikvw `_) - Fábio Capuano (`fabiocapsouza `_) - Frank Sachsenheim - George Kettleborough (`georgek `_) - George Vilches - Gregory Bataille - Grzegorz Bialy - Guillermo Eijo (`guilleijo `_) - Hamish Downer - Hans de Jong (`sult `_) - Hanyin Zhang - Héctor Durán (`hector97i `) - Hernan Esteves (`sevetseh28 `_) - Hielke Walinga (`hwalinga `_) - Hugo van Kemenade (`hugovk `_) - Jack Cushman (`jcushman `_) - Jake Howard (`RealOrangeOne `_) - James Muranga (`jamesmura `_) - James Pulec - Jeppe Fihl-Pearson (`Tenzer `_) - Jesse Shapiro - Jihoon Baek (`jihoon796 `_) - Jim Gomez - Jim King (`jeking3 `_) - Joao Junior (`joaojunior `_) - Joao Pedro Francese - `jofusa `_ - John Whitlock - Jonathan Leroy - Jonathan Loo (`alpha1d3d `_) - Jonathan Sanchez - Jonathan Zvesper (`zvesp `_) - Jordon Wing (`jordonwii `_) - Keith Hackbarth - Kevin Foster - Kira (`kiraware `_) - Klaas van Schelven - Kris Neuharth - Kyle Seever (`kseever `_) - Léni Gauffier (`legau `_) - Leticia Portella - Lucas Wiman - Maciej "RooTer" Urbański - Marcelo Canina (`marcanuy `_) - Marco Sirabella - Mark Davidoff - Martin Bachwerk - Marty Alchin - Matheus Cansian (`mscansian `_) - Matthew Somerville (`dracos `_) - Mauricio de Abreu Antunes - Maxim Zemskov (`MaximZemskov `_) - Micah Denbraver - Michael England - Miguel Vargas - Mike Spainhower - Muneeb Shahid (`muneeb706 `_) - Nathan Villagaray-Carski (`ncvc `_) - Nianpeng Li - Nick Träger - Noel James (`NoelJames `_) - Ofek Lev (`ofek `_) - Phillip Marshall - Prakash Venkatraman (`dopatraman `_) - Rajesh Pappula - Ray Logel - Raynald de Lahondes - Renaud Perrin (`leminaw `_) - Roberto Aguilar - Rod Xavier Bondoc - Ross Lote - Ross Mechanic (`rossmechanic `_) - Ross Rogers - Sergey Ozeranskiy (`ozeranskiy `_) - Shane Engelman - Steeve Chailloux - Stefan Borer (`sbor23 `_) - Steven Buss (`sbuss `_) - Steven Klass - Thijs Kramer (`thijskramer `_) - Tim Schilling (`tim-schilling `_) - Todd Wolfson (`twolfson `_) - Tommy Beadle (`tbeadle `_) - Trey Hunner (`treyhunner `_) - Ulysses Vilela - `vnagendra `_ - `yakimka `_ - `Paulo Peres `_ - `Alex Todorov `_ - David Smith (`smithdc1 `_) - Shi Han Ng (`shihanng `_) - `ddusi `_ - `DanialErfanian `_ - `Sridhar Marella `_ - `Mattia Fantoni `_ Background ========== This code originally comes from Pro Django, published by Apress, Inc. in December 2008. The author of the book and primary author of the code is Marty Alchin , who may be found online at . As part of the technical review process, additional code modifications were provided by the technical reviewer, George Vilches . This code was originally extended, licensed, and improved by Corey Bertram with the permission of Marty Alchin. jazzband-django-simple-history-2a2bec9/CHANGES.rst000066400000000000000000000501521462567636100220610ustar00rootroot00000000000000Changes ======= Unreleased ---------- 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) ------------------ - Fixed ``FieldError`` when creating historical records for many-to-many fields with ``to="self"`` (gh-1218) - Allow ``HistoricalRecords.m2m_fields`` as str (gh-1243) - Fixed ``HistoryRequestMiddleware`` deleting non-existent ``HistoricalRecords.context.request`` in very specific circumstances (gh-1256) - Added ``custom_historical_attrs`` to ``bulk_create_with_history()`` and ``bulk_update_with_history()`` for setting additional fields on custom history models (gh-1248) - Passing an empty list as the ``fields`` argument to ``bulk_update_with_history()`` is now allowed; history records will still be created (gh-1248) - Added temporary requirement on ``asgiref>=3.6`` while the minimum required Django version is lower than 4.2 (gh-1261) - Small performance optimization of the ``clean-duplicate_history`` command (gh-1015) - Support Simplified Chinese translation (gh-1281) - Added support for Django 5.0 (gh-1283) - Added support for Python 3.13 (gh-1289) 3.4.0 (2023-08-18) ------------------ - Fixed typos in the docs - Added feature to evaluate ``history`` model permissions explicitly when ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``True`` in ``settings`` (gh-1017). - Fixed ``SimpleHistoryAdmin`` not properly integrating with custom user models (gh-1177) - Support Indonesian translation (gh-1198) - Support Urdu translation (gh-1199) - Support Norwegian Bokmål translation (gh-1210) - Dropped support for Python 3.7, which reached end-of-life on 2023-06-27 (gh-1202) - Dropped support for Django 4.0, which reached end-of-life on 2023-04-01 (gh-1202) - Added support for Django 4.2 (gh-1202) - Made ``bulk_update_with_history()`` return the number of model rows updated (gh-1206) - Fixed ``HistoryRequestMiddleware`` not cleaning up after itself (i.e. deleting ``HistoricalRecords.context.request``) under some circumstances (gh-1188) - Made ``HistoryRequestMiddleware`` async-capable (gh-1209) - Fixed error when setting ``table_name`` with ``inherit=True`` (gh-1195) 3.3.0 (2023-03-08) ------------------ - Made it possible to use the new ``m2m_fields`` with model inheritance (gh-1042) - Added two signals: ``pre_create_historical_m2m_records`` and ``post_create_historical_m2m_records`` (gh-1042) - Added ``tracked_fields`` attribute to historical models (gh-1038) - Fixed ``KeyError`` when running ``clean_duplicate_history`` on models with ``excluded_fields`` (gh-1038) - Added support for Python 3.11 (gh-1053) - Added Arabic translations (gh-1056) - Fixed a code example under "Tracking many to many relationships" (gh-1069) - Added a ``--base-manager`` option to the ``clean_duplicate_history`` management command (gh-1115) 3.2.0 (2022-09-28) ------------------ - Fixed typos in the docs - Removed n+1 query from ``bulk_create_with_history`` utility (gh-975) - Started using ``exists`` query instead of ``count`` in ``populate_history`` command (gh-982) - Add basic support for many-to-many fields (gh-399) - Added support for Django 4.1 (gh-1021) 3.1.1 (2022-04-23) ------------------ Full list of changes: - Fix py36 references in pyproject.toml (gh-960) - Fix local setup.py install versioning issue (gh-960) - Remove py2 universal wheel cfg - only py3 needed now (gh-960) 3.1.0 (2022-04-09) ------------------ Breaking Changes: - Dropped support for Django 2.2 (gh-968) - Dropped support for Django 3.1 (gh-952) - Dropped support for Python 3.6, which reached end-of-life on 2021-12-23 (gh-946) Upgrade Implications: - Run `makemigrations` after upgrading to realize the benefit of indexing changes. Full list of changes: - Added queryset-based filtering with ``as_of`` (gh-397) - Added index on `history_date` column; opt-out with setting `SIMPLE_HISTORY_DATE_INDEX` (gh-565) - RecordModels now support a ``no_db_index`` setting, to drop indices in historical models, default stays the same (gh-720) - Support ``included_fields`` for ``history.diff_against`` (gh-776) - Improve performance of ``history.diff_against`` by reducing number of queries to 0 in most cases (gh-776) - Fixed ``prev_record`` and ``next_record`` performance when using ``excluded_fields`` (gh-791) - Fixed `update_change_reason` in pk (gh-806) - Fixed bug where serializer of djangorestframework crashed if used with ``OrderingFilter`` (gh-821) - Fixed `make format` so it works by using tox (gh-859) - Fixed bug where latest() is not idempotent for identical ``history_date`` records (gh-861) - Added ``excluded_field_kwargs`` to support custom ``OneToOneField`` that have additional arguments that don't exist on ``ForeignKey``. (gh-870) - Added Czech translations (gh-885) - Added ability to break into debugger on unit test failure (gh-890) - Added pre-commit for better commit quality (gh-896) - Russian translations update (gh-897) - Added support for Django 4.0 (gh-898) - Added Python 3.10 to test matrix (gh-899) - Fix bug with ``history.diff_against`` with non-editable fields (gh-923) - Added HistoricForeignKey (gh-940) - Support change reason formula feature. Change reason formula can be defined by overriding ``get_change_reason_for_object`` method after subclassing ``HistoricalRecords`` (gh-962) 3.0.0 (2021-04-16) ------------------ Breaking changes: - Removed support for Django 3.0 - Removed `changeReason` in favor of `_change_reason` (see 2.10.0) Full list of changes: - Removed support for Django versions prior to 2.2 (gh-652) - Migrate from TravisCI to Github Actions (gh-739) - Add Python 3.9 support (gh-745) - Support ``ignore_conflicts`` in ``bulk_create_with_history`` (gh-733) - Use ``asgiref`` when available instead of thread locals (gh-747) - Sort imports with isort (gh-751) - Queryset ``history.as_of`` speed improvements by calculating in the DB (gh-758) - Increase `black` and `isort` python version to 3.6 (gh-817) - Remove Django 3.0 support (gh-817) - Add Django 3.2 support (gh-817) - Improve French translations (gh-811) - Remove support for changeReason (gh-819) 2.12.0 (2020-10-14) ------------------- - Add default date to ``bulk_create_with_history`` and ``bulk_update_with_history`` (gh-687) - Exclude ManyToManyFields when using ``bulk_create_with_history`` (gh-699) - Added ``--excluded_fields`` argument to ``clean_duplicate_history`` command (gh-674) - Exclude ManyToManyFields when fetching excluded fields (gh-707) - Use default model manager for ``bulk_create_with_history`` and ``bulk_update_with_history`` instead of ``objects`` (gh-703) - Add optional ``manager`` argument to ``bulk_update_with_history`` to use instead of the default manager (gh-703) - Add support for Django 3.1 (gh-713) - Fix a bug with ``clean_old_history`` command's `--days` argument (gh-722) \* NOTE: This will be the last minor release before 3.0.0. 2.11.0 (2020-06-20) ------------------- - Added ``clean_old_history`` management command (gh-675) - Added ``user_db_constraint`` param to history to avoid circular reference on delete (gh-676) - Leverages ``get_user`` from ``HistoricalRecords`` in order to set a fallback user on bulk update and bulk create (gh-677) 2.10.0 (2020-04-27) ------------------- - Added ``bulk_update_with_history`` utility function (gh-650) - Add default user and default change reason to ``bulk_create_with_history`` and ``bulk_update_with_history`` (gh-653) - Add french translation (gh-654) - Start using ``_change_reason`` instead of ``changeReason`` to add change reasons to historical objects. ``changeReason`` is deprecated and will be removed in version ``3.0.0`` (gh-655) 2.9.0 (2020-04-23) ------------------ - Add simple filtering if provided a minutes argument in ``clean_duplicate_history`` (gh-606) - Add setting to convert ``FileField`` to ``CharField`` instead of ``TextField`` (gh-625) - Added notes on BitBucket Pipelines (gh-627) - import model ``ContentType`` in ``SimpleHistoryAdmin`` using ``django_apps.get_model`` to avoid possible ``AppRegistryNotReady`` exception (gh-630) - Fix ``utils.update_change_reason`` when user specifies excluded_fields (gh-637) - Changed how ``now`` is imported from ``timezone`` (``timezone`` module is imported now) (gh-643) - ``settings.SIMPLE_HISTORY_REVERT_DISABLED`` if True removes the Revert button from the history form for all historical models (gh-632)) 2.8.0 (2019-12-02) ------------------ - Fixed ``bulk_create_with_history support`` for HistoryRecords with ``relation_name`` attribute (gh-591) - Added support for ``bulk_create_with_history`` for databases different from PostgreSQL (gh-577) - Fixed ``DoesNotExist`` error when trying to get instance if object is deleted (gh-571) - Fix ``model_to_dict`` to detect changes in a parent model when using ``inherit=True`` (backwards-incompatible for users who were directly using previous version) (gh-576) - Use an iterator for ``clean_duplicate_history`` (gh-604) - Add support for Python 3.8 and Django 3.0 (gh-610) 2.7.3 (2019-07-15) ------------------ - Fixed ``BigAutoField`` not mirrored as ``BigInt`` (gh-556) - Fixed ``most_recent()`` bug with ``excluded_fields`` (gh-561) - Added official Django 2.2 support (gh-555) 2.7.2 (2019-04-17) ------------------ - Fixed ModuleNotFound issue for ``six`` (gh-553) 2.7.1 (2019-04-16) ------------------ - Added the possibility to create a relation to the original model (gh-536) - Fix router backward-compatibility issue with 2.7.0 (gh-539, gh-547) - Fix hardcoded history manager (gh-542) - Replace deprecated ``django.utils.six`` with ``six`` (gh-526) - Allow ``custom_model_name`` parameter to be a callable (gh-489) 2.7.0 (2019-01-16) ------------------ - \* Add support for ``using`` chained manager method and save/delete keyword argument (gh-507) - Added management command ``clean_duplicate_history`` to remove duplicate history entries (gh-483) - Updated most_recent to work with excluded_fields (gh-477) - Fixed bug that prevented self-referential foreign key from using ``'self'`` (gh-513) - Added ability to track custom user with explicit custom ``history_user_id_field`` (gh-511) - Don't resolve relationships for history objects (gh-479) - Reorganization of docs (gh-510) \* NOTE: This change was not backward compatible for users using routers to write history tables to a separate database from their base tables. This issue is fixed in 2.7.1. 2.6.0 (2018-12-12) ------------------ - Add ``app`` parameter to the constructor of ``HistoricalRecords`` (gh-486) - Add ``custom_model_name`` parameter to the constructor of ``HistoricalRecords`` (gh-451) - Fix header on history pages when custom site_header is used (gh-448) - Modify ``pre_create_historical_record`` to pass ``history_instance`` for ease of customization (gh-421) - Raise warning if ``HistoricalRecords(inherit=False)`` is in an abstract model (gh-341) - Ensure custom arguments for fields are included in historical models' fields (gh-431) - Add german translations (gh-484) - Add ``extra_context`` parameter to history_form_view (gh-467) - Fixed bug that prevented ``next_record`` and ``prev_record`` to work with custom manager names (gh-501) 2.5.1 (2018-10-19) ------------------ - Add ``'+'`` as the ``history_type`` for each instance in ``bulk_history_create`` (gh-449) - Add support for ``history_change_reason`` for each instance in ``bulk_history_create`` (gh-449) - Add ``history_change_reason`` in the history list view under the ``Change reason`` display name (gh-458) - Fix bug that caused failures when using a custom user model (gh-459) 2.5.0 (2018-10-18) ------------------ - Add ability to cascade delete historical records when master record is deleted (gh-440) - Added Russian localization (gh-441) 2.4.0 (2018-09-20) ------------------ - Add pre and post create_historical_record signals (gh-426) - Remove support for ``django_mongodb_engine`` when converting AutoFields (gh-432) - Add support for Django 2.1 (gh-418) 2.3.0 (2018-07-19) ------------------ - Add ability to diff ``HistoricalRecords`` (gh-244) 2.2.0 (2018-07-02) ------------------ - Add ability to specify alternative user_model for tracking (gh-371) - Add util function ``bulk_create_with_history`` to allow bulk_create with history saved (gh-412) 2.1.1 (2018-06-15) ------------------ - Fixed out-of-memory exception when running populate_history management command (gh-408) - Fix TypeError on populate_history if excluded_fields are specified (gh-410) 2.1.0 (2018-06-04) ------------------ - Add ability to specify custom ``history_reason`` field (gh-379) - Add ability to specify custom ``history_id`` field (gh-368) - Add HistoricalRecord instance properties ``prev_record`` and ``next_record`` (gh-365) - Can set admin methods as attributes on object history change list template (gh-390) - Fixed compatibility of >= 2.0 versions with old-style middleware (gh-369) 2.0 (2018-04-05) ---------------- - Added Django 2.0 support (gh-330) - Dropped support for Django<=1.10 (gh-356) - Fix bug where ``history_view`` ignored user permissions (gh-361) - Fixed ``HistoryRequestMiddleware`` which hadn't been working for Django>1.9 (gh-364) 1.9.1 (2018-03-30) ------------------ - Use ``get_queryset`` rather ``than model.objects`` in ``history_view``. (gh-303) - Change ugettext calls in models.py to ugettext_lazy - Resolve issue where model references itself (gh-278) - Fix issue with tracking an inherited model (abstract class) (gh-269) - Fix history detail view on django-admin for abstract models (gh-308) - Dropped support for Django<=1.6 and Python 3.3 (gh-292) 1.9.0 (2017-06-11) ------------------ - Add ``--batchsize`` option to the ``populate_history`` management command. (gh-231) - Add ability to show specific attributes in admin history list view. (gh-256) - Add Brazilian Portuguese translation file. (gh-279) - Fix locale file packaging issue. (gh-280) - Add ability to specify reason for history change. (gh-275) - Test against Django 1.11 and Python 3.6. (gh-276) - Add ``excluded_fields`` option to exclude fields from history. (gh-274) 1.8.2 (2017-01-19) ------------------ - Add Polish locale. - Add Django 1.10 support. 1.8.1 (2016-03-19) ------------------ - Clear the threadlocal request object when processing the response to prevent test interactions. (gh-213) 1.8.0 (2016-02-02) ------------------ - History tracking can be inherited by passing ``inherit=True``. (gh-63) 1.7.0 (2015-12-02) ------------------ - Add ability to list history in admin when the object instance is deleted. (gh-72) - Add ability to change history through the admin. (Enabled with the ``SIMPLE_HISTORY_EDIT`` setting.) - Add Django 1.9 support. - Support for custom tables names. (gh-196) 1.6.3 (2015-07-30) ------------------ - Respect ``to_field`` and ``db_column`` parameters (gh-182) 1.6.2 (2015-07-04) ------------------ - Use app loading system and fix deprecation warnings on Django 1.8 (gh-172) - Update Landscape configuration 1.6.1 (2015-04-21) ------------------ - Fix OneToOneField transformation for historical models (gh-166) - Disable cascading deletes from related models to historical models - Fix restoring historical instances with missing one-to-one relations (gh-162) 1.6.0 (2015-04-16) ------------------ - Add support for Django 1.8+ - Deprecated use of ``CustomForeignKeyField`` (to be removed) - Remove default reverse accessor to ``auth.User`` for historical models (gh-121) 1.5.4 (2015-01-03) ------------------ - Fix a bug when models have a ``ForeignKey`` with ``primary_key=True`` - Do NOT delete the history elements when a user is deleted. - Add support for ``latest`` - Allow setting a reason for change. [using option changeReason] 1.5.3 (2014-11-18) ------------------ - Fix migrations while using ``order_with_respsect_to`` (gh-140) - Fix migrations using south - Allow history accessor class to be overridden in ``register()`` 1.5.2 (2014-10-15) ------------------ - Additional fix for migrations (gh-128) 1.5.1 (2014-10-13) ------------------ - Removed some incompatibilities with non-default admin sites (gh-92) - Fixed error caused by ``HistoryRequestMiddleware`` during anonymous requests (gh-115 fixes gh-114) - Added workaround for clashing related historical accessors on User (gh-121) - Added support for MongoDB AutoField (gh-125) - Fixed CustomForeignKeyField errors with 1.7 migrations (gh-126 fixes gh-124) 1.5.0 (2014-08-17) ------------------ - Extended availability of the ``as_of`` method to models as well as instances. - Allow ``history_user`` on historical objects to be set by middleware. - Fixed error that occurs when a foreign key is designated using just the name of the model. - Drop Django 1.3 support 1.4.0 (2014-06-29) ------------------ - Fixed error that occurs when models have a foreign key pointing to a one to one field. - Fix bug when model verbose_name uses unicode (gh-76) - Allow non-integer foreign keys - Allow foreign keys referencing the name of the model as a string - Added the ability to specify a custom ``history_date`` - Note that ``simple_history`` should be added to ``INSTALLED_APPS`` (gh-94 fixes gh-69) - Properly handle primary key escaping in admin URLs (gh-96 fixes gh-81) - Add support for new app loading (Django 1.7+) - Allow specifying custom base classes for historical models (gh-98) 1.3.0 (2013-05-17) ------------------ - Fixed bug when using ``django-simple-history`` on nested models package - Allow history table to be formatted correctly with ``django-admin-bootstrap`` - Disallow calling ``simple_history.register`` twice on the same model - Added Python 3 support - Added support for custom user model (Django 1.5+) 1.2.3 (2013-04-22) ------------------ - Fixed packaging bug: added admin template files to PyPI package 1.2.1 (2013-04-22) ------------------ - Added tests - Added history view/revert feature in admin interface - Various fixes and improvements Oct 22, 2010 ------------ - Merged setup.py from Klaas van Schelven - Thanks! Feb 21, 2010 ------------ - Initial project creation, with changes to support ForeignKey relations. jazzband-django-simple-history-2a2bec9/CODE_OF_CONDUCT.md000066400000000000000000000045071462567636100230610ustar00rootroot00000000000000# 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/ jazzband-django-simple-history-2a2bec9/CONTRIBUTING.rst000066400000000000000000000056341462567636100227250ustar00rootroot00000000000000Contributing 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 `_. Pull Requests ------------- Feel free to open pull requests before you've finished your code or tests. Opening your pull request soon will allow others to comment on it sooner. A checklist of things to remember when making a feature: - Write tests if applicable - Note important changes in the `CHANGES`_ file - Update the `README`_ file if needed - Update the documentation if needed - Add yourself to the `AUTHORS`_ file .. _AUTHORS: AUTHORS.rst .. _CHANGES: CHANGES.rst .. _README: README.rst Requirements ------------ The Makefile can be used for generating documentation and running tests. To install the requirements necessary for generating the documentation and running tests:: make init This will install: - `tox`_: used for running the tests against all supported versions of Django and Python as well as running tasks like lint, format, docs - `coverage`_: used for analyzing test coverage for tests If not using a virtualenv, the command should be prepended with ``sudo``. .. _tox: http://testrun.org/tox/latest// .. _coverage: http://nedbatchelder.com/code/coverage/ Documentation ------------- To regenerate the documentation run:: make docs Testing ------- Please add tests for your pull requests and make sure your changes don't break existing tests. To run tox and generate an HTML code coverage report (available in the ``htmlcov`` directory):: make test To quickly run the tests against a single version of Python and Django (note: you must ``pip install django`` beforehand):: python runtests.py Code Formatting --------------- We make use of `black`_ for code formatting. .. _black: https://black.readthedocs.io/en/stable/installation_and_usage.html You can install and run it along with other linters through pre-commit:: pre-commit install pre-commit run Once you install pre-commit it will sanity check any commit you make. Additionally, the CI process runs this check as well. Translations ------------ In order to add translations, refer to Django's `translation docs`_ and follow these steps: 1. Ensure that Django is installed 2. Invoke ``django-admin makemessages -l `` in the repository's root directory. 3. Add translations to the created ``simple_history/locale//LC_MESSAGES/django.po`` file. 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 jazzband-django-simple-history-2a2bec9/LICENSE.txt000066400000000000000000000030011462567636100220710ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2008, Marty Alchin All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the author nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.jazzband-django-simple-history-2a2bec9/Makefile000066400000000000000000000012751462567636100217210ustar00rootroot00000000000000all: init docs clean test clean: clean-build clean-pyc rm -fr htmlcov/ clean-build: rm -fr dist/ clean-pyc: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + init: pip install "tox>=1.8" coverage test: coverage erase tox coverage html docs: documentation documentation: tox -e docs dist: clean pip install -U build python -m build for file in dist/* ; do gpg --detach-sign -a "$$file" ; done ls -l dist test-release: dist pip install -U twine gpg --detach-sign -a dist/* twine upload -r pypitest dist/* release: dist pip install -U twine gpg --detach-sign -a dist/* twine upload dist/* format: tox -e format jazzband-django-simple-history-2a2bec9/PULL_REQUEST_TEMPLATE.md000066400000000000000000000033271462567636100240620ustar00rootroot00000000000000 ## Description ## Related Issue ## Motivation and Context ## How Has This Been Tested? ## Screenshots (if appropriate): ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) ## Checklist: - [ ] I have run the `pre-commit run` command to format and lint. - [ ] My change requires a change to the documentation. - [ ] I have updated the documentation accordingly. - [ ] I have read the **CONTRIBUTING** document. - [ ] I have added tests to cover my changes. - [ ] I have added my name and/or github handle to `AUTHORS.rst` - [ ] I have added my change to `CHANGES.rst` - [ ] All new and existing tests passed. jazzband-django-simple-history-2a2bec9/README.rst000066400000000000000000000046671462567636100217600ustar00rootroot00000000000000django-simple-history |pypi-version| ==================================== .. Start of PyPI readme |jazzband| |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 .. |jazzband| image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ :alt: Jazzband .. |build-status| 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 :alt: Build Status .. |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 .. |coverage| 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 :alt: Test Coverage .. |maintainability| image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability :target: https://codeclimate.com/github/jazzband/django-simple-history/maintainability :alt: Maintainability .. |code-style| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black :alt: Code Style .. |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. This app supports the following combinations of Django and Python: ========== ======================== Django Python ========== ======================== 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 ========== ======================== Getting Help ------------ Documentation is available at https://django-simple-history.readthedocs.io/en/stable/ 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 License ------- This project is licensed under the `BSD 3-Clause license `_. jazzband-django-simple-history-2a2bec9/codecov.yml000066400000000000000000000002501462567636100224160ustar00rootroot00000000000000--- coverage: status: patch: default: informational: true project: default: informational: true ignore: - "requirements/*.txt" jazzband-django-simple-history-2a2bec9/doc-requirements.txt000066400000000000000000000000461462567636100243030ustar00rootroot00000000000000Sphinx==1.2.3 sphinx-autobuild==0.3.0 jazzband-django-simple-history-2a2bec9/docs/000077500000000000000000000000001462567636100212045ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/docs/Makefile000066400000000000000000000152461462567636100226540ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-simple-history.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-simple-history.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/django-simple-history" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-simple-history" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." jazzband-django-simple-history-2a2bec9/docs/_static/000077500000000000000000000000001462567636100226325ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/docs/_static/.keep000066400000000000000000000000001462567636100235450ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/docs/admin.rst000066400000000000000000000160471462567636100230360ustar00rootroot00000000000000Admin Integration ----------------- To allow viewing previous model versions on the Django admin site, inherit from the ``simple_history.admin.SimpleHistoryAdmin`` class when registering your model with the admin site. This will replace the history object page on the admin site and allow viewing and reverting to previous model versions. Changes made in admin change forms will also accurately note the user who made the change. .. image:: screens/1_poll_history.png Clicking on an object presents the option to revert to that version of the object. .. image:: screens/2_revert.png (The object is reverted to the selected state) .. image:: screens/3_poll_reverted.png Reversions like this are added to the history. .. image:: screens/4_history_after_poll_reverted.png An example of admin integration for the ``Poll`` and ``Choice`` models: .. code-block:: python from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin from .models import Poll, Choice admin.site.register(Poll, SimpleHistoryAdmin) admin.site.register(Choice, SimpleHistoryAdmin) Changing a history-tracked model from the admin interface will automatically record the user who made the change (see :doc:`/user_tracking`). Displaying custom columns in the admin history list view ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default, the history log displays one line per change containing * a link to the detail of the object at that point in time * the date and time the object was changed * a comment corresponding to the change * the author of the change 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 .. code-block:: python from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin from .models import Poll, Choice class PollHistoryAdmin(SimpleHistoryAdmin): list_display = ["id", "name", "status"] history_list_display = ["status"] search_fields = ['name', 'user__username'] admin.site.register(Poll, PollHistoryAdmin) admin.site.register(Choice, SimpleHistoryAdmin) .. image:: screens/5_history_list_display.png 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default, an object can be reverted to its previous version. To disable this option globally, update your settings with the following: .. code-block:: python SIMPLE_HISTORY_REVERT_DISABLED = True When ``SIMPLE_HISTORY_REVERT_DISABLED`` is set to ``True``, the revert button is removed from the form. .. image:: screens/10_revert_disabled.png Enforcing history model permissions in Admin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To make the Django admin site evaluate history model permissions explicitly, update your settings with the following: .. code-block:: python SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True By default, ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``False``. When set to ``False``, permissions applied to the ``Poll`` model (from the examples above), also apply to the history model. That is, granting view and change permissions to the ``Poll`` model implicitly grants view and change permissions to the ``Poll`` history model. The user below has view and change permissions to the ``Poll`` model and the ``Poll`` history model in admin. .. code-block:: python user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_poll"), Permission.objects.get(codename="change_poll"), ) The user below has view permission to the ``Poll`` model and the ``Poll`` history model in admin. .. code-block:: python user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_poll"), ) When ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``True``, permissions to history models are assigned and evaluated explicitly. The user below *does not have* view permission to the ``Poll`` history model in admin, even though they *have* view permission to the ``Poll`` model. .. code-block:: python # SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_poll"), ) The user below has view permission to the ``Poll`` model and the ``Poll`` history model. .. code-block:: python # SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_poll"), Permission.objects.get(codename="view_historicalpoll"), ) The user below has view permission to the ``Poll`` history model, but will need to access the page with a direct URL, since the ``Poll`` model will not be listed on the admin application index page, nor the ``Poll`` changelist. .. code-block:: python # SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_historicalpoll"), ) jazzband-django-simple-history-2a2bec9/docs/common_issues.rst000066400000000000000000000262401462567636100246250ustar00rootroot00000000000000Common Issues ============= Bulk Creating and Queryset Updating ----------------------------------- ``django-simple-history`` functions by saving history using a ``post_save`` signal every time that an object with history is saved. However, for certain bulk operations, such as bulk_create_, bulk_update_, and `queryset updates`_, signals are not sent, and the history is not saved automatically. However, ``django-simple-history`` provides utility functions to work around this. Bulk Creating a Model with History ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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 .. code-block:: pycon >>> from simple_history.utils import bulk_create_with_history >>> from simple_history.tests.models import Poll >>> from django.utils.timezone import now >>> >>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)] >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> Poll.objects.count() 1000 >>> Poll.history.count() 1000 If you want to specify a change reason or history user for each record in the bulk create, you can add `_change_reason`, `_history_user` or `_history_date` on each instance: .. code-block:: pycon >>> for poll in data: poll._change_reason = 'reason' poll._history_user = my_user poll._history_date = some_date >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> Poll.history.get(id=data[0].id).history_change_reason 'reason' You can also specify a default user or default change reason responsible for the change (`_change_reason`, `_history_user` and `_history_date` take precedence). .. code-block:: pycon >>> user = User.objects.create_user("tester", "tester@example.com") >>> objs = bulk_create_with_history(data, Poll, batch_size=500, default_user=user) >>> Poll.history.get(id=data[0].id).history_user == user True If you're using `additional fields in historical models`_ and have custom fields to batch-create into the history, pass the optional dict argument ``custom_historical_attrs`` containing the field names and values. A field ``session`` would be passed as ``custom_historical_attrs={'session': 'training'}``. .. _additional fields in historical models: historical_model.html#adding-additional-fields-to-historical-models .. code-block:: pycon >>> from simple_history.tests.models import PollWithHistoricalSessionAttr >>> data = [ PollWithHistoricalSessionAttr(id=x, question=f'Question {x}') for x in range(10) ] >>> objs = bulk_create_with_history( data, PollWithHistoricalSessionAttr, custom_historical_attrs={'session': 'training'} ) >>> data[0].history.get().session 'training' Bulk Updating a Model with History (New) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Bulk update was introduced with Django 2.2. We can use the utility function ``bulk_update_with_history`` in order to bulk update objects using Django's ``bulk_update`` function while saving the object history: .. code-block:: pycon >>> from simple_history.utils import bulk_update_with_history >>> from simple_history.tests.models import Poll >>> from django.utils.timezone import now >>> >>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)] >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> for obj in objs: obj.question = 'Duplicate Questions' >>> bulk_update_with_history(objs, Poll, ['question'], batch_size=500) >>> Poll.objects.first().question 'Duplicate Question`` If your models require the use of an alternative model manager (usually because the default manager returns a filtered set), you can specify which manager to use with the ``manager`` argument: .. code-block:: pycon >>> from simple_history.utils import bulk_update_with_history >>> from simple_history.tests.models import PollWithAlternativeManager >>> >>> data = [PollWithAlternativeManager(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)] >>> objs = bulk_create_with_history(data, PollWithAlternativeManager, batch_size=500, manager=PollWithAlternativeManager.all_polls) If you're using `additional fields in historical models`_ and have custom fields to batch-update into the history, pass the optional dict argument ``custom_historical_attrs`` containing the field names and values. A field ``session`` would be passed as ``custom_historical_attrs={'session': 'jam'}``. .. _additional fields in historical models: historical_model.html#adding-additional-fields-to-historical-models .. code-block:: pycon >>> bulk_update_with_history( data, PollWithHistoricalSessionAttr, [], custom_historical_attrs={'session': 'jam'} ) >>> data[0].history.latest().session 'jam' QuerySet Updates with History (Updated in Django 2.2) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Unlike with ``bulk_create``, `queryset updates`_ perform an SQL update query on the queryset, and never return the actual updated objects (which would be necessary for the inserts into the historical table). Thus, we tell you that queryset updates will not save history (since no ``post_save`` signal is sent). As the Django documentation says:: If you want to update a bunch of records for a model that has a custom ``save()`` method, loop over them and call ``save()``, like this: .. code-block:: python for e in Entry.objects.filter(pub_date__year=2010): e.comments_on = False e.save() .. _queryset updates: https://docs.djangoproject.com/en/2.2/ref/models/querysets/#update Note: Django 2.2 now allows ``bulk_update``. No ``pre_save`` or ``post_save`` signals are sent still. Tracking Custom Users --------------------- - ``fields.E300``:: ERRORS: custom_user.HistoricalCustomUser.history_user: (fields.E300) Field defines a relation with model 'custom_user.CustomUser', which is either not installed, or is abstract. Use ``register()`` to track changes to the custom user model instead of setting ``HistoricalRecords`` on the model directly. The reason for this, is that unfortunately ``HistoricalRecords`` cannot be set directly on a swapped user model because of the user foreign key to track the user making changes. Using F() expressions --------------------- ``F()`` expressions, as described here_, do not work on models that have history. Simple history inserts a new record in the historical table for any model being updated. However, ``F()`` expressions are only functional on updates. 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 Reserved Field Names -------------------- For each base model that has its history tracked using ``django-simple-history``, an associated historical model is created. Thus, if we have: .. code-block:: python class BaseModel(models.Model): history = HistoricalRecords() a Django model called ``HistoricalBaseModel`` is also created with all of the fields from ``BaseModel``, plus a few extra fields and methods that are on all historical models. Since these fields and methods are on all historical models, any field or method names on a base model that clash with those names will not be on the historical model (and, thus, won't be tracked). The reserved historical field and method names are below: - ``history_id`` - ``history_date`` - ``history_change_reason`` - ``history_type`` - ``history_object`` - ``history_user`` - ``history_user_id`` - ``instance`` - ``instance_type`` - ``next_record`` - ``prev_record`` - ``revert_url`` - ``__str__`` So if we have: .. code-block:: python class BaseModel(models.Model): instance = models.CharField(max_length=255) history = HistoricalRecords() the ``instance`` field will not actually be tracked on the history table because it's in the reserved set of terms. Multi-table Inheritance ----------------------- ``django-simple-history`` supports tracking history on models that use multi-table inheritance, such as: .. code-block:: python class ParentModel(models.Model): parent_field = models.CharField(max_length=255) history = HistoricalRecords() class ChildModel(ParentModel): child_field = models.CharField(max_length=255) history = HistoricalRecords() A few notes: - On the child model, the ``HistoricalRecords`` instance is not inherited from the parent model. This means that you can choose to track changes on just the parent model, just the child model, or both. - The child's history table contains all fields from the child model as well as all the fields from the parent model. - Updating a child instance only updates the child's history table, not the parent's history table. Usage with django-modeltranslation ---------------------------------- If you have ``django-modeltranslation`` installed, you will need to use the ``register()`` method to model translation, as described `here `__. Pointing to the model --------------------- Sometimes you have to point to the model of the historical records. Examples are Django's generic views or Django REST framework's serializers. You can get there through your HistoricalRecords manager you defined in your model. According to our example: .. code-block:: python class PollHistoryListView(ListView): # or PollHistorySerializer(ModelSerializer): class Meta: model = Poll.history.model # ... Working with BitBucket Pipelines -------------------------------- When using BitBucket Pipelines to test your Django project with the django-simple-history middleware, you will run into an error relating to missing migrations relating to the historic User model from the auth app. This is because the migration file is not held within either your project or django-simple-history. In order to bypass the error you need to add a ```python manage.py makemigrations auth``` step into your YML file prior to running the tests. Using custom OneToOneFields --------------------------- If you are using a custom OneToOneField that has additional arguments and receiving the following ``TypeError``:: TypeError: __init__() got an unexpected keyword argument This is because Django Simple History coerces ``OneToOneField`` into ``ForeignKey`` on the historical model. You can work around this by excluded those additional arguments using ``excluded_field_kwargs`` as follows: .. code-block:: python class Poll(models.Model): organizer = CustomOneToOneField(Organizer, ..., custom_argument="some_value") history = HistoricalRecords( excluded_field_kwargs={"organizer": set(["custom_argument"])} ) jazzband-django-simple-history-2a2bec9/docs/conf.py000066400000000000000000000202541462567636100225060ustar00rootroot00000000000000# # django-simple-history documentation build configuration file, created by # sphinx-quickstart on Sun May 5 16:10:02 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys from importlib import metadata # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath("..")) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "sphinx.ext.autodoc", "sphinx_rtd_theme", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "django-simple-history" copyright = "2013, Corey Bertram" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # release = metadata.version("django-simple-history") # for example take major/minor version = ".".join(release.split(".")[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "django-simple-historydoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ( "index", "django-simple-history.tex", "django-simple-history Documentation", "Corey Bertram", "manual", ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( "index", "django-simple-history", "django-simple-history Documentation", ["Corey Bertram"], 1, ) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( "index", "django-simple-history", "django-simple-history Documentation", "Corey Bertram", "django-simple-history", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False jazzband-django-simple-history-2a2bec9/docs/historical_model.rst000066400000000000000000000462231462567636100252660ustar00rootroot00000000000000Historical Model Customizations =============================== Custom ``history_id`` --------------------- By default, the historical table of a model will use an ``AutoField`` for the table's ``history_id`` (the history table's primary key). However, you can specify a different type of field for ``history_id`` by passing a different field to ``history_id_field`` parameter. The example below uses a ``UUIDField`` instead of an ``AutoField``: .. code-block:: python import uuid from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords( history_id_field=models.UUIDField(default=uuid.uuid4) ) Since using a ``UUIDField`` for the ``history_id`` is a common use case, there is a ``SIMPLE_HISTORY_HISTORY_ID_USE_UUID`` setting that will set all instances of ``history_id`` to UUIDs. Set this with the following line in your ``settings.py`` file: .. code-block:: python SIMPLE_HISTORY_HISTORY_ID_USE_UUID = True This setting can still be overridden using the ``history_id_field`` parameter on a per model basis. You can use the ``history_id_field`` parameter with both ``HistoricalRecords()`` or ``register()`` to change this behavior. Note: regardless of what field type you specify as your history_id field, that field will automatically set ``primary_key=True`` and ``editable=False``. Custom ``history_date`` ----------------------- You're able to set a custom ``history_date`` attribute for the historical record, by defining the property ``_history_date`` in your model. That's helpful if you want to add versions to your model, which happened before the current model version, e.g. when batch importing historical data. The content of the property ``_history_date`` has to be a ``datetime``-object, but setting the value of the property to a ``DateTimeField``, which is already defined in the model, will work too. .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey('auth.User') history = HistoricalRecords() __history_date = None @property def _history_date(self): return self.__history_date @_history_date.setter def _history_date(self, value): self.__history_date = value .. code-block:: python from datetime import datetime from models import Poll my_poll = Poll(question="what's up?") my_poll._history_date = datetime.now() my_poll.save() Indexed ``history_date`` ------------------------ Many queries use ``history_date`` as a filter. The as_of queries combine this with the original model's primary key to extract point-in-time snapshots of history. By default the ``history_date`` field is indexed. You can control this behavior using settings.py. .. code-block:: python # disable indexing on history_date SIMPLE_HISTORY_DATE_INDEX = False # enable indexing on history_date (default setting) SIMPLE_HISTORY_DATE_INDEX = True # enable composite indexing on history_date and model pk (to improve as_of queries) # the string is case-insensitive SIMPLE_HISTORY_DATE_INDEX = "Composite" Custom history table name ------------------------- By default, the table name for historical models follow the Django convention and just add ``historical`` before model name. For instance, if your application name is ``polls`` and your model name ``Question``, then the table name will be ``polls_historicalquestion``. You can use the ``table_name`` parameter with both ``HistoricalRecords()`` or ``register()`` to change this behavior. .. code-block:: python class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords(table_name='polls_question_history') .. code-block:: python class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') register(Question, table_name='polls_question_history') Custom model name ----------------- By default, historical model is named as 'Historical' + model name. For example, historical records for ``Choice`` is called ``HistoricalChoice``. Users can specify a custom model name via the constructor on ``HistoricalRecords``. The common use case for this is avoiding naming conflict if the user already defined a model named as 'Historical' + model name. This feature provides the ability to override the default model name used for the generated history model. To configure history models to use a different name for the history model class, use an option named ``custom_model_name``. The value for this option can be a `string` or a `callable`. A simple string replaces the default name of `'Historical' + model name` with the defined string. The most simple use case is illustrated below using a simple string: .. code-block:: python class ModelNameExample(models.Model): history = HistoricalRecords( custom_model_name='SimpleHistoricalModelNameExample' ) If you are using a base class for your models and want to apply a name change for the historical model for all models using the base class then a callable can be used. The callable is passed the name of the model for which the history model will be created. As an example using the callable mechanism, the below changes the default prefix `Historical` to `Audit`: .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(custom_model_name=lambda x:f'Audit{x}') class Opinion(models.Model): opinion = models.CharField(max_length=2000) register(Opinion, custom_model_name=lambda x:f'Audit{x}') The resulting history class names would be `AuditPoll` and `AuditOpinion`. If the app the models are defined in is `yoda` then the corresponding history table names would be `yoda_auditpoll` and `yoda_auditopinion` IMPORTANT: Setting `custom_model_name` to `lambda x:f'{x}'` is not permitted. An error will be generated and no history model created if they are the same. Custom History Manager and Historical QuerySets ----------------------------------------------- To manipulate the history ``Manager`` or the historical ``QuerySet`` of ``HistoricalRecords``, you can specify the ``history_manager`` and ``historical_queryset`` options. The values must be subclasses of ``simple_history.manager.HistoryManager`` and ``simple_history.manager.HistoricalQuerySet``, respectively. Keep in mind, you can use either or both of these options. To understand the difference between a ``Manager`` and a ``QuerySet``, see `Django's Manager documentation`_. .. code-block:: python from datetime import timedelta from django.db import models from django.utils import timezone from simple_history.manager import HistoryManager, HistoricalQuerySet from simple_history.models import HistoricalRecords class HistoryQuestionManager(HistoryManager): def published(self): return self.filter(pub_date__lte=timezone.now()) class HistoryQuestionQuerySet(HistoricalQuerySet): def question_prefixed(self): return self.filter(question__startswith="Question: ") class Question(models.Model): pub_date = models.DateTimeField("date published") history = HistoricalRecords( history_manager=HistoryQuestionManager, historical_queryset=HistoryQuestionQuerySet, ) # This is now possible: queryset = Question.history.published().question_prefixed() To reuse a ``QuerySet`` from the model, see the following code example: .. code-block:: python from datetime import timedelta from django.db import models from django.utils import timezone from simple_history.models import HistoricalRecords from simple_history.manager import HistoryManager, HistoricalQuerySet class QuestionQuerySet(models.QuerySet): def question_prefixed(self): return self.filter(question__startswith="Question: ") class HistoryQuestionQuerySet(QuestionQuerySet, HistoricalQuerySet): """Redefine ``QuerySet`` with base class ``HistoricalQuerySet``.""" class Question(models.Model): pub_date = models.DateTimeField("date published") history = HistoricalRecords(historical_queryset=HistoryQuestionQuerySet) manager = QuestionQuerySet.as_manager() .. _Django's Manager documentation: https://docs.djangoproject.com/en/stable/topics/db/managers/ TextField as `history_change_reason` ------------------------------------ The ``HistoricalRecords`` object can be customized to accept a ``TextField`` model field for saving the `history_change_reason` either through settings or via the constructor on the model. The common use case for this is for supporting larger model change histories to support changelog-like features. .. code-block:: python SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD=True or .. code-block:: python class TextFieldExample(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords( history_change_reason_field=models.TextField(null=True) ) Change Base Class of HistoricalRecord Models -------------------------------------------- To change the auto-generated HistoricalRecord models base class from ``models.Model``, pass in the abstract class in a list to ``bases``. .. code-block:: python class RoutableModel(models.Model): class Meta: abstract = True class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey('auth.User') history = HistoricalRecords(bases=[RoutableModel]) Excluded Fields -------------------------------- It is possible to use the parameter ``excluded_fields`` to choose which fields will be stored on every create/update/delete. For example, if you have the model: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') And you don't want to store the changes for the field ``pub_date``, it is necessary to update the model to: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords(excluded_fields=['pub_date']) By default, django-simple-history stores the changes for all fields in the model. Adding additional fields to historical models --------------------------------------------- Sometimes it is useful to be able to add additional fields to historical models that do not exist on the source model. This is possible by combining the ``bases`` functionality with the ``pre_create_historical_record`` signal. .. code-block:: python # in models.py class IPAddressHistoricalModel(models.Model): """ Abstract model for history models tracking the IP address. """ ip_address = models.GenericIPAddressField(_('IP address')) class Meta: abstract = True class PollWithExtraFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords(bases=[IPAddressHistoricalModel,]) .. code-block:: python # define your signal handler/callback anywhere outside of models.py def add_history_ip_address(sender, **kwargs): history_instance = kwargs['history_instance'] # context.request for use only when the simple_history middleware is on and enabled history_instance.ip_address = HistoricalRecords.context.request.META['REMOTE_ADDR'] .. code-block:: python # in apps.py class TestsConfig(AppConfig): def ready(self): from simple_history.tests.models \ import HistoricalPollWithExtraFields pre_create_historical_record.connect( add_history_ip_address, sender=HistoricalPollWithExtraFields ) More information on signals in ``django-simple-history`` is available in :doc:`/signals`. Change Reason ------------- Change reason is a message to explain why the change was made in the instance. It is stored in the field ``history_change_reason`` and its default value is ``None``. By default, the django-simple-history gets the change reason in the field ``_change_reason`` of the instance. Also, it is possible to pass the ``_change_reason`` explicitly. For this, after a save or delete in an instance, it is necessary to call the function ``utils.update_change_reason``. The first argument of this function is the instance and the second is the message that represents the change reason. For instance, for the model: .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords() You can create an instance with an implicit change reason. .. code-block:: python poll = Poll(question='Question 1') poll._change_reason = 'Add a question' poll.save() Or you can pass the change reason explicitly: .. code-block:: python from simple_history.utils import update_change_reason poll = Poll(question='Question 1') poll.save() update_change_reason(poll, 'Add a question') Deleting historical record -------------------------- In some circumstances you may want to delete all the historical records when the master record is deleted. This can be accomplished by setting ``cascade_delete_history=True``. .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(cascade_delete_history=True) Allow tracking to be inherited --------------------------------- By default history tracking is only added for the model that is passed to ``register()`` or has the ``HistoricalRecords`` descriptor. By passing ``inherit=True`` to either way of registering, you can change that behavior so that any child model inheriting from it will have historical tracking as well. Be careful though, in cases where a model can be tracked more than once, ``MultipleRegistrationsError`` will be raised. .. code-block:: python from django.contrib.auth.models import User from django.db import models from simple_history import register from simple_history.models import HistoricalRecords # register() example register(User, inherit=True) # HistoricalRecords example class Poll(models.Model): history = HistoricalRecords(inherit=True) Both ``User`` and ``Poll`` in the example above will cause any model inheriting from them to have historical tracking as well. **Note:** For parent models having a ``HistoricalRecords`` field with ``inherit=True`` *and* a ``table_name``, the latter option will not be inherited by child models. History Model In Different App ------------------------------ By default the app_label for the history model is the same as the base model. In some circumstances you may want to have the history models belong in a different app. This will support creating history models in a different database to the base model using database routing functionality based on app_label. To configure history models in a different app, add this to the HistoricalRecords instantiation or the record invocation: ``app="SomeAppName"``. .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(app="SomeAppName") class Opinion(models.Model): opinion = models.CharField(max_length=2000) register(Opinion, app="SomeAppName") `FileField` as a `CharField` ---------------------------- By default a ``FileField`` in the base model becomes a ``TextField`` in the history model. This is a historical choice that django-simple-history preserves for backwards compatibility; it is more correct for a ``FileField`` to be converted to a ``CharField`` instead. To opt into the new behavior, set the following line in your ``settings.py`` file: .. code-block:: python SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD = True Drop Database Indices -------------------------------- It is possible to use the parameter ``no_db_index`` to choose which fields that will not create a database index. For example, if you have the model: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200, db_index=True) And you don't want to create database index for ``question``, it is necessary to update the model to: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200, db_index=True) history = HistoricalRecords(no_db_index=['question']) By default, django-simple-history keeps all indices. and even forces them on unique fields and relations. WARNING: This will drop performance on historical lookups Tracking many to many relationships ----------------------------------- By default, many to many fields are ignored when tracking changes. If you want to track many to many relationships, you need to define them explicitly: .. code-block:: python class Category(models.Model): name = models.CharField(max_length=200) class Poll(models.Model): question = models.CharField(max_length=200) categories = models.ManyToManyField(Category) history = HistoricalRecords(m2m_fields=[categories]) This will create a historical intermediate model that tracks each relational change between `Poll` and `Category`. You may use either the name of the field or the field instance itself. You may also define these fields in a class attribute (by default on `_history_m2m_fields`). This is mainly used by inherited models not declaring their own `HistoricalRecord`. You can override the attribute name by setting your own `m2m_fields_model_field_name` argument on the `HistoricalRecord` instance. You will see the many to many changes when diffing between two historical records: .. code-block:: python informal = Category.objects.create(name="informal questions") official = Category.objects.create(name="official questions") p = Poll.objects.create(question="what's up?") p.categories.add(informal, official) p.categories.remove(informal) last_record = p.history.latest() previous_record = last_record.prev_record delta = last_record.diff_against(previous_record) for change in delta.changes: print("{} changed from {} to {}".format(change.field, change.old, change.new)) # Output: # categories changed from [{'poll': 1, 'category': 1}, { 'poll': 1, 'category': 2}] to [{'poll': 1, 'category': 2}] jazzband-django-simple-history-2a2bec9/docs/history_diffing.rst000066400000000000000000000110161462567636100251240ustar00rootroot00000000000000History Diffing =============== 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: - ``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: - ``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 poll = Poll.objects.create(question="what's up?") poll.question = "what's up, man?" poll.save() new_record, old_record = poll.history.all() delta = new_record.diff_against(old_record) for change in delta.changes: 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': }] # 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)}] jazzband-django-simple-history-2a2bec9/docs/index.rst000066400000000000000000000042671462567636100230560ustar00rootroot00000000000000django-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 :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 :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 :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 :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. This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= 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 ========== ======================= Contribute ---------- - Issue Tracker: https://github.com/jazzband/django-simple-history/issues - Source Code: https://github.com/jazzband/django-simple-history Pull requests are welcome. Documentation ------------- .. toctree:: :maxdepth: 2 quick_start querying_history admin historical_model user_tracking signals history_diffing multiple_dbs utils common_issues .. include:: ../CHANGES.rst jazzband-django-simple-history-2a2bec9/docs/make.bat000066400000000000000000000145311462567636100226150ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-simple-history.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-simple-history.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %BUILDDIR%/.. echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end jazzband-django-simple-history-2a2bec9/docs/multiple_dbs.rst000066400000000000000000000037701462567636100244300ustar00rootroot00000000000000Multiple databases ================== Interacting with Multiple Databases ----------------------------------- `django-simple-history` follows the Django conventions for interacting with multiple databases. .. code-block:: python >>> # This will create a new historical record on the 'other' database. >>> poll = Poll.objects.using('other').create(question='Question 1') >>> # This will also create a new historical record on the 'other' database. >>> poll.save(using='other') When interacting with ``QuerySets``, use ``using()``: .. code-block:: python >>> # This will return a QuerySet from the 'other' database. Poll.history.using('other').all() When interacting with manager methods, use ``db_manager()``: .. code-block:: python >>> # This will call a manager method on the 'other' database. >>> poll.history.db_manager('other').as_of(datetime(2010, 10, 25, 18, 4, 0)) See the Django documentation for more information on how to interact with multiple databases. Tracking User in a Separate Database ------------------------------------ When using ``django-simple-history`` in app with multiple database, you may run into an issue where you want to track the history on a table that lives in a separate database to your user model. Since Django does not support cross-database relations, you will have to manually track the ``history_user`` using an explicit ID. The full documentation on this feature is in :ref:`Manually Track User Model`. Tracking History Separate from the Base Model --------------------------------------------- You can choose whether or not to track models' history in the same database by setting the flag `use_base_model_db`. .. code-block:: python class MyModel(models.Model): ... history = HistoricalRecords(use_base_model_db=False) If set to `True`, migrations and audit events will be sent to the same database as the base model. If `False`, they will be sent to the place specified by the database router. The default value is `False`. jazzband-django-simple-history-2a2bec9/docs/querying_history.rst000066400000000000000000000174551462567636100253760ustar00rootroot00000000000000Querying History ================ Querying history on a model instance ------------------------------------ The ``HistoricalRecords`` object on a model instance can be used in the same way as a model manager: .. code-block:: pycon >>> from polls.models import Poll, Choice >>> from datetime import datetime >>> poll = Poll.objects.create(question="what's up?", pub_date=datetime.now()) >>> >>> poll.history.all() [] Whenever a model instance is saved a new historical record is created: .. code-block:: pycon >>> poll.pub_date = datetime(2007, 4, 1, 0, 0) >>> poll.save() >>> poll.history.all() [, ] Querying history on a model class --------------------------------- Historical records for all instances of a model can be queried by using the ``HistoricalRecords`` manager on the model class. For example historical records for all ``Choice`` instances can be queried by using the manager on the ``Choice`` model class: .. code-block:: pycon >>> choice1 = poll.choice_set.create(choice_text='Not Much', votes=0) >>> choice2 = poll.choice_set.create(choice_text='The sky', votes=0) >>> >>> Choice.history >>> Choice.history.all() [, ] Because the history is model, you can also filter it like regularly QuerySets, e.g. ``Choice.history.filter(choice_text='Not Much')`` will work! Getting previous and next historical record ------------------------------------------- If you have a historical record for an instance and would like to retrieve the previous historical record (older) or next historical record (newer), `prev_record` and `next_record` read-only attributes can be used, respectively. .. code-block:: pycon >>> from polls.models import Poll, Choice >>> from datetime import datetime >>> poll = Poll.objects.create(question="what's up?", pub_date=datetime.now()) >>> >>> record = poll.history.first() >>> record.prev_record None >>> record.next_record None >>> poll.question = "what is up?" >>> poll.save() >>> record.next_record If a historical record is the first record, `prev_record` will be `None`. Similarly, if it is the latest record, `next_record` will be `None` Reverting the Model ------------------- ``SimpleHistoryAdmin`` allows users to revert back to an old version of the model through the admin interface. You can also do this programmatically. To do so, you can take any historical object, and save the associated instance. For example, if we want to access the earliest ``HistoricalPoll``, for an instance of ``Poll``, we can do: .. code-block:: pycon >>> poll.history.earliest() And to revert to that ``HistoricalPoll`` instance, we can do: .. code-block:: pycon >>> earliest_poll = poll.history.earliest() >>> earliest_poll.instance.save() This will change the ``poll`` instance to have the data from the ``HistoricalPoll`` object and it will create a new row in the ``HistoricalPoll`` table indicating that a new change has been made. as_of ----- The HistoryManager allows you to query a point in time for the latest historical records or instances. When called on an instance's history manager, the ``as_of`` method will return the instance from the specified point in time, if the instance existed at that time, or raise DoesNotExist. When called on a model's history manager, the ``as_of`` method will return instances from a specific date and time that you specify, returning a queryset that you can use to further filter the result. .. code-block:: pycon >>> t0 = datetime.now() >>> document1 = RankedDocument.objects.create(rank=42) >>> document2 = RankedDocument.objects.create(rank=84) >>> t1 = datetime.now() >>> RankedDocument.history.as_of(t1) , ]> >>> RankedDocument.history.as_of(t1).filter(rank__lte=50) ]> ``as_of`` is a convenience: the following two queries are identical. .. code-block:: pycon RankedDocument.history.as_of(t1) RankedDocument.history.filter(history_date__lte=t1).latest_of_each().as_instances() If you filter by `pk` the behavior depends on whether the queryset is returning instances or historical records. When the queryset is returning instances, `pk` is mapped to the original model's primary key field. When the queryset is returning historical records, `pk` refers to the `history_id` primary key. is_historic and to_historic --------------------------- If you use `as_of` to query history, the resulting instance will have an attribute named `_history` added to it. This property will contain the historical model record that the instance was derived from. Calling is_historic is an easy way to check if an instance was derived from a 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 ------------------ If you have two historic tables linked by foreign key, you can change it to use a HistoricForeignKey so that chasing relations from an `as_of` acquired instance (at a specific point in time) will honor that point in time when accessing the related object(s). This works for both forward and reverse relationships. See the `HistoricForeignKeyTest` code and models for an example. most_recent ----------- This method will return the most recent copy of the model available in the model history. .. code-block:: pycon >>> from datetime import datetime >>> poll.history.most_recent() Save without a historical record -------------------------------- If you want to save a model without a historical record, you can use the following: .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords() 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 poll = Poll(question='something') poll.save_without_historical_record() Or disable history records for all models by putting following lines in your ``settings.py`` file: .. code-block:: python SIMPLE_HISTORY_ENABLED = False Filtering data using a relationship to the model ------------------------------------------------ To filter changes to the data, a relationship to the history can be established. For example, all data records in which a particular user was involved. .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) log = HistoricalRecords(related_name='history') Poll.objects.filter(history__history_user=4) You can also prefetch the objects with this relationship using something like this for example to prefetch order by history_date descending: .. code-block:: python Poll.objects.filter(something).prefetch_related(Prefetch('history', queryset=Poll.history.order_by('-history_date'), to_attr='ordered_histories') jazzband-django-simple-history-2a2bec9/docs/quick_start.rst000066400000000000000000000113731462567636100242740ustar00rootroot00000000000000Quick Start =========== Install ------- Install from `PyPI`_ with ``pip``: .. code-block:: bash $ pip install django-simple-history .. _pypi: https://pypi.python.org/pypi/django-simple-history/ Configure --------- Settings ~~~~~~~~ Add ``simple_history`` to your ``INSTALLED_APPS`` .. code-block:: python INSTALLED_APPS = [ # ... 'simple_history', ] The historical models can track who made each change. To populate the history user automatically you can add ``HistoryRequestMiddleware`` to your Django settings: .. code-block:: python MIDDLEWARE = [ # ... 'simple_history.middleware.HistoryRequestMiddleware', ] If you do not want to use the middleware, you can explicitly indicate the user making the change as documented in :doc:`/user_tracking`. Track History ~~~~~~~~~~~~~ To track history for a model, create an instance of ``simple_history.models.HistoricalRecords`` on the model. An example for tracking changes on the ``Poll`` and ``Choice`` models in the Django tutorial: .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords() class Choice(models.Model): poll = models.ForeignKey(Poll) choice_text = models.CharField(max_length=200) votes = models.IntegerField(default=0) history = HistoricalRecords() Now all changes to ``Poll`` and ``Choice`` model instances will be tracked in the database. Track History for a Third-Party Model ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To track history for a model you didn't create, use the ``simple_history.register`` function. You can use this to track models from third-party apps you don't have control over. Here's an example of using ``simple_history.register`` to history-track the ``User`` model from the ``django.contrib.auth`` app: .. code-block:: python from simple_history import register from django.contrib.auth.models import User register(User) If you want to separate the migrations of the historical model into an app other than the third-party model's app, you can set the ``app`` parameter in ``register``. For instance, if you want the migrations to live in the migrations folder of the package you register the model in, you could do: .. code-block:: python register(User, app=__package__) Run Migrations -------------- With your model changes in place, create and apply the database migrations: .. code-block:: bash $ python manage.py makemigrations $ python manage.py migrate Existing Projects ~~~~~~~~~~~~~~~~~ For existing projects, you can call the populate command to generate an initial change for preexisting model instances: .. code-block:: bash $ python manage.py populate_history --auto By default, history rows are inserted in batches of 200. This can be changed if needed for large tables by using the ``--batchsize`` option, for example ``--batchsize 500``. What Now? --------- By adding ``HistoricalRecords`` to a model or registering a model using ``register``, you automatically start tracking any create, update, or delete that occurs on that model. Now you can :doc:`query the history programmatically ` and :doc:`view the history in Django admin `. What is ``django-simple-history`` Doing Behind the Scenes? ---------------------------------------------------------- If you tried the code `above`_ and ran the migrations on it, you'll see the following tables in your database: - ``app_choice`` - ``app_historicalchoice`` - ``app_historicalpoll`` - ``app_poll`` .. _above: `Track History`_ The two extra tables with ``historical`` prepend to their names are tables created by ``django-simple-history``. These tables store every change that you make to their respective base tables. Every time a create, update, or delete occurs on ``Choice`` or ``Poll`` a new row is created in the historical table for that model including all of the fields in the instance of the base model, as well as other metadata: - ``history_user``: the user that made the create/update/delete - ``history_date``: the ``datetime`` at which the create/update/delete occurred - ``history_change_reason``: the reason the create/update/delete occurred (null by default) - ``history_id``: the primary key for the historical table (note the base table's primary key is not unique on the historical table since there are multiple versions of it on the historical table) - ``history_type``: ``+`` for create, ``~`` for update, and ``-`` for delete Now try saving an instance of ``Choice`` or ``Poll``. Check the historical table to see that the history is being tracked. jazzband-django-simple-history-2a2bec9/docs/screens/000077500000000000000000000000001462567636100226465ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/docs/screens/10_revert_disabled.png000066400000000000000000000370111462567636100270140ustar00rootroot00000000000000PNG  IHDRY pHYs  iTXtXML:com.adobe.xmp INԧ7IDATxms.;O$[eʗͷX/V6Rd "Q1Md$0:eﱳdHryz^z~ɲ%UwQrg&3=Omu("""""ЋB/""""ЋB/""""" (""""" ("""""Ћ("""""ЋB/""""" B/""""" (m^KBO~p!ƅzgq䍗~T\ +ϲC{7_49fJU? ßvwrsᇟܣ7 ŷ[Kz<q M~#rEvrk^#rDDDd5z⵿υ,քB>_[;ѻ0Z*tW9;YJN_k\#aB_Q蓌=ij^FЯF޹pt$穣ҟ?|—mcM9p}sa,bqS.y #>loW]m!lƿVhl桨m!7Ff6~0̇%^nojKFfϗ7^>|\cJrxQmp*7Z;Y߁fN_m|q.l|1ɯno.3=_aL [ѫߪm3Ǽ;U}ڌtb36S\sM+"r*ӵB Gߩ%nXhƿmcN <=[V?||/)io_s]*펭B?Vդ;5+rkwfW;%oŅm }(㍓t7v9>IH]uס6o{:Ps=’O/6Q ۏCu3F}ZU/=B_yUۖ3ʄ>?Ww>.Om{r‰wǛ~}{3ŏn|vɓc#G;;7;?sC{My糟<ܸӰG}ЮO?>~c蹡gKv㧏n$Wj_:荣/pdx ~;S=HfQ#/_9:@aLyvc .yiơ=W#h""m_Hh:/edaV.fO KZ}W<#?[uz=DU>TbQۭ[x2ɟy㥇jݿxsMۛ6z}kwk|G=aSk'?<9Ñɍ{[z!Y'wuz{7/|wP.^8>ٱO~qoO9xԦG//NMRA4C+Gq]C_d؇ЇZ7Cnz?rdjI%O4p8'gc[:zϲBay>5"mcMQx2#)Ѷ_.퇥J梔{o0m]r\VlS| @ʋs񜢭{?zd#po\ r񣡰ٍ&2Ӗ~a79{Bئc/,iLR?yn }Kt-5Nȩ#i)IG/ }¸з}%Ԇk>ߡ7;zpp}o]|ycOKy4fO[۴d&tkVrG[r)}Tv[ Gsh4&s.F|0 3'wg;%;Z F<\]ve }h/c_iЗd+D7^ٿ5gg/<}h?(m/L~' K'S4ҪOzO2qWo\X<>'zZwQZOoy?Ї .gO>& ?-fB8R_Ddd1#IՎ+{)7%r--7єZNvc }ïp~)7~kgtdyBߜ_9&avֱ1ieemم?۩Gٌ>FC _fXg%grƋ7ONM)>n~MV.n]SM&+}O/''T<]mtoՄCє]N:G/ 4&󋪶/泅R.J [>L龓K,'_1/y ;K9!F v[k5s|*;@m\ }Tě4NZ^w荋~\[P u^ϕ6vtHWЗ\( .LyϴV֙+E-ɵt*\|?&x*СC>zzM'Am沕Rkпt|23~9=z?Cz󅧷<4PT_mWVng=O;}Clyl(t~qکK;j̅tR;^D~1tBBR/{k\'>x%ϯC2۩r3GTQvvIk7>02zхc. L^DT|PDXTܯ5_kp?/UոJZ>{hd8_fO0ط7/)~4D^:m|5Z'QnIyQEDD6j̹CVw:(R=5.8|GK-Bh#'2|X~y}OgFK }Jt<]ɩdMn]=v }rOe<,Is}^zܳ6~|dݽ<*=G^yٓ.tpO<3^97 Snmn:LL 88ƱW4vgݛm߸ѡ-%;0 9{xUP:]=_ܗ|^ywwLJ7wyϋB/"""\V/\rO.}jcWzj8B{qY6{gӭ}wnU%}w`dOOӽy.H xy5o? ?=Ptj]'WFևb{~ˋ?y?Mo/FpǤe>/ (?_DDDDD_b9ݼ׼PDDDDD <BwQ/Qo?yaw}a򓟶"}^](oN?|ۼc|p#?Y̕6O<ҀW(+G{ٸ0ߍܱ<#;ǛZ{_("""""ЋB/""""ЋB/""""" B/""""" ("""""Ћ_DDDDDdFQEDDDDD^DDDDD^DDDDDzQEDDDDzQEDDDDDQEDDDDD^DDDDD~ٱx3go.ت.|{vkWqVa.cɑY}XBCq{wG){vn-dXݵw(Ч5a*s R?2Ij3w%ťgBf/'痬ݔ(wZӮ++hOlW܉GYKq}/q˿z_zQlgE}Hd$K "҅K+*:v&WwmFWdITrŮ*,,>ĒhͽGQ|*[mm{'>\xτIp._eRsAGW/;<.}A6v72uWgN5|}kKX~Z+g~/:ChSgOZw~ӭ%Ȯxdv)ٍo[pJSDDdmq+M A%F:?])7mLcc)tFm;6^?nisʝ!ˏ̱!ByL>ڥAO;L BRjѽec˭R{::qsZytK+/.R(,rzi,Lsէld* }n"MXϟp={>- "" }Ք򦘹GZ2'P:at3:Ϊ֥vMzp QelLn.'OS)V€nɟ,X攛xtKrSzMRx6{4gk_ߎ؋G>NP]{q ;z`i}.zŃ\o3ŁtFZMi={*/5B;ڻ?vV?Di9ݟ=1pvs*Ru7ߦBj6-GEbT%hw-$<~٦w6ִ|7S߷ࡸ-?-;?Z-B|uO0?o?y=dCs~h0M[vWƆ7~Xoo`[goϿ$ Wmfj6M6KOvUs7+rSc/q/6όf̖ G_og{[ߺ7h[SoM},nRs".[6>.OM[w~w wS7si?-l}[5 k_݊yvBsj@ߎ?|8f_}VTw' wXB愙t }T!`u^F2wL/vȍ_-s}]/UÃO/ ;r}km~#~hM`CvwǩSɒBr3drSn_6&ϖ]rs}~xC~ }řc{cK{ѩ[Sи%~J+iwYʭ{w\ZXx͉@Tg_\z`VC]>[_n5ޖfNsحB-zȳ|Rν7'v4&;\u5Pr( -ͩ k<ě;W,af/LzXVםrm3Z(_-лl7{kO~peL)HLն8Ro݉m>YvbWlnuBjj`s =nb)>'Pē(SƙNz}ÎSiӡomԹ[&Ҟ{|+k|y 8NN;ƿLf|G涧(]ݬu`K^Obb: 7}> ;>eo3PoV-wqbSlhuVM/>_sؑlņ9PBEFD_ Ooe;sʛ;_O;$?۹gpF~kc[L. xk҇=7[Smٟ^y;3le89dk.ؽHחN/h9{dot>?<м$BSVTtw[o|x{ B_bB탏6_j K?tޭى;=ֳᑾޞ =}۷~վӮ7Xjn }ÿ?q==>׭ݜXmz`U7;,kܩm=rC3??N;} g u7fg(-b>~ V`rʻJ3[*3xaڹӏ=iFȯr7 l+$98QKM_B6߷MG~.ʾWmwY+~#U lO P" 7T+ْWԷZxZki:xk^g'})V3*((K.F7oFn FҺ93{%^mIK{sEOxOsr̋ɌЧE3t:ӦgZ[_ UnZo = = =T;ePA;ozN[Pz@gbbbaaqn7Kx8 =!333ׯ_w2Bk͛7'&&BM1Nm7Hx7Kx8 =N?33Bx6+ =( =(B(+ =( kB(+ =BB(+ =BB(Bw͛7gff&&&P-p±VU FN =!333:] *1zX+&&&w/pPK@#^z#a'6훭^>oS.]d9sAp@3v;zG %f9ntxl-b_z~(d}Ⱦdr[~*df@d W ƙ@Ͼnj}}yz8 )<:3[_?YU3zB }Tg,DžB_'?Ѱ7=M)b/_ZB߼~yB~B:b ;;2z0_z B }c;[gs9~>U~Bc&?g77u.H[+;bBvPOC}a,.єQB@qNa7O]͔Sni̜tvй̇9;6d \W+zL񡵇?=XO9?|Я^oV w/2o{fTsIHB]x_ }bnr۽!u>z׌ϞX_mtfl7˹uҏaϪC_^@˅RFCg*/;١q/\ 6ۚp?ϡ,`:wˣtDOhEXfWINZwQ{yVO/])6oAOӷ}5'6_/B~U }ԍÖMf.^yBB%Snw,B_8Uo6݇Gr-x}~h0;F.жӿ^e9]3ZX[+]lW -?B0>6f7E} }:&oS!BB/YYBXT7 >Nmيp2V?KBo̼*֙ӃdWXWoX诿1Zvhc~Q%?;WI|̹AЧWIgɟkGcӅa5^g }cMv}<&3ksз&;w~rS/Y7%~Cqܜ6#k.gŪBQ|ws }(X4oķܴ&צݟz쿚7i_XhK_&zzXBP3ŽVi1ִš%.י ޟFsboͫI|m ayU= =B_z)KԧW©٫W0ӽz>wM}_ n/r?GM{21$۹6 = B,+x<-w 5;Onkhm b/w/v\FPX:ǦyE7W ?B_UnPb~́>4\ C-4BB)wկtY~%{SnPzP#V/n|{0$P@5wBSWc8cjaeO =8bÚ4118t)pPa~Хps@͛o/p± GV U GIzz@zX=b}zXm~rQBZQ511[zXΜ9 8Ѓ"8 =(( =((:B(ܻ>?p{E6}BNgƯj5o~.9[X_c[=L9Ѧ͇^ ?( =Gw$%>?xaXɆvu Q+Zx{͡mM~NӅ }|.o\tBsdSwGBNi6B R7 7n$?>X'ԯo%^+V>*vzPzB_/9Y~|:"[]OmNiΉ//Yug<+􋋟ݘxcۛ֬-ʍ۶-'χ>Xi^=v&3^(ƔB`(qo719>9hN&f5F%:G16>TslJ }}? BϽ]wLD\;}t1'ܖ>Zyw7J|BͿ=dNiڊBϨwzB_: =܋xˤ>:\>BΡ?rd'㓉ͅeWũjěѪ)7?Z9Cة3jBOP: =ܓ@f/^~1t}cB}6fj]Zo|5f}T>I@_^Cwo+]^eza_2|W.[ BЃB ߤ~`߱KyjPzPqH;}.< B 9<у!;ˤPzPP^711ppqb֟ׯ!p@͛ޟQ@8^ z333ԞG6 =BB(B B =BB(B B =BB(B /_ڠ =( =(B(+ =( =(B(+ =BB(+ =BB(B B =p[nBC T_~qnnn~~;B ]44UЇpXE^/G826 4R~w[JB+ = B B BB+( =(_ti׮]7o['®M>uTs^PBk׮u+ B_u:<{(u[Oz^@Wzz^Pz^WPz@Wz@WzЏF7;\6BB }7J|((k88?s+(k}>,IoZSS@WL? }ͧ<ܤdyǏj^Oz~E }~?:qůoaɍ@WWtM'hTօqr< @WhL…rUns' BY73VV͡oM+ BwX*3UYrVhaT˾JP;X^WPz^Wz^@Wzz^Pz^P͛76v@ ]o;}ׅҥKt>vy.Nk׮uTîzzz^WPzPzz^@W@W@fgg罀XE^/Ǎﹹ9!VQh*q֭p2qzBC 4R~>%].m^(p?S@z@^Pzz@^Pzz@zPzXmrL.@$IENDB`jazzband-django-simple-history-2a2bec9/docs/screens/1_poll_history.png000066400000000000000000000464511462567636100263350ustar00rootroot00000000000000PNG  IHDRCr pHYs  iTXtXML:com.adobe.xmp BGIDATxSWU7ϯ{o7SdIr,D勒ΚW,KpѽA7!F-Ũ `(->gzջ,zOyO7{!B!4. B!'B! B!B!B!B!BzB!BBO!BBO!BA !B!=!B!=!B!'B! B!B!B!B!B6Gݨk!BzWn1^ t6&:}SVS=jk'B!dMj?pJq$B +/]|}yv?<{_NӇG~!@!KKKSO,9X?X/"Їmʽ{=I$%7fF*ԊϐB!\x$:Boz;-=Wc7XB_r!d- -^( ~|3㑥WN~̅_y|+R=g?>eo|ܥs:gن*"LʛΔDV⏓ZTw{W+-&?dZ}-{kh-KTə EMo\;UN6mk{T4%yݙSs+R/}^b76BŰ1ݫa5uWJ}c~%yŌsӱ%7R醇w7^AzBj'iG?nssX]EA܅9).Wkkyp ,˟{:z[U5B+.c(E펁fNLYf[_\#?E_zKIKЌg5hg6۴h\^(v?cfd_8θ™m=, qJ^(/WA_BʶCk[{~[S;nnwTi˞v۱wtYos873ߚr#-ԚNJo{m?zMщ/>u[;K(R|a ~tɞRdo{ߝz(wERi|0`^h[7~o[~fl|zt|]A\s}MW>1ϰwcڇcA]!1cgwx(]QռZiRumPEw}k\,IB/t(Ofw)j4Bo|ׯ<8Q3b _.3O+: Jå |7ط CJ a35uWBoQW@{M2Ͷw:t͎fƷ=;@1сc[]ׯ=₲4}ul=ߞJQnaKȶn)}XTT.K_~+=u]ouO9:Uݽ*uw#J}j#]zkMNYF߽o>l7MBoiWn}/oPw~o4zOt3T>vgUa9a;wv^n BHT]wwm!FA:ZIE0,H5y_WocGqUz]8_ߞ(;zQ6Mbz+=JX)ViJS{Qgاh{ %4\,_#ި3:++fLt[J=# u`$^= ^NaJ}ъЯݱ]1ZA;Ƿif_~4Vn<1ԝQ#Ėbvlu:_xs\)jff-po{) ;UJ1RYj#cRRT_锗x;Kz?2X5?AI2/-IO]Yg7(Sv e A"v3-j0B^>4~Rj#N3|}Pܟ N wvBR."nj_..:(w>䢯KI7B/nhEw9^1OY1@*QH=Q_q;ϯZ/k_ozmG6qJe&% ږolפhe_E~0rKX;7i|iDkע-Џ.gwEP6\V5#oޚaB/h4B~Xȉd5/!Y tVmQٓJn47jF(rlVm^5O( 4IB+ƪf8wwD2u:o+[ћ$-½B3+ ۅ'}&*J=B/-My*B΀F׃oL3BM/aX/4}Z\)"B/D% eI/aXMIa_zz5{\s)**zAP]:pd Bֳ/[vf/+%G^P@f\o ř#J穗[D^;6X5vL#8uhQU6.]+>Hy Qjc7*A#6eNt5׷~wnU9u*B5}geN\2ԭ =_w37;Ay2_{Sf4+(M#}5Ͼ6X)Kn]'ibz`BO1y*Łp1HHby}@Pŗ_GG*|uU ?7M kƒbɥK)KnO<]?nz EׅG@.$} __/OzBCzxb<X|sxuO^y巵GKz?h`-2Wr2qT>h.^gBݴrbp1BO*Pr#nIbѐbNy)K-_?c^8oI} m3-68͗ F 7Ş~y=z} "Wڵ{>:ITzO')oM]zB!,Ck?M㭬m79f? =k88=5&7?^зK[+ܤ"G+Rk>rFecu3_4)\xj>P)J]zBYǑ3%nl{ˇW;_~_BS6͞;;\k=wrz3[ӥ[φ?`hi\/awǷ=lpUsׅ]Mrgw\htВ}oO̍/֮v  Uׄ'~nxas6D;Þ BO!Mw|}F͍(Xyo^겜@YBb;FfW4[r⋽ne6fz_H[5]wh[1]kKzyk[MO޻viKO}6_;}a'BB UB! %ufj.ѩ?ʎB!Bv;K7>y9^sB! ϛ_WjK`ob!B!}|?R]73}mN<+oyӽy[kK!BB_n>>e˱w|Ҳb<-G=*|η{ !BA !B!=!B!'B!'B! B!B!B!B!BzB!BBO!B&F!B٠A !BA !B!=!B!'B!'B! B!B!B!B!BzB!BBO!BBO!BA !B!=!B!d }ף.ߍXz4=5R/7;}SaJT0v+Ҷ.]p{}~Un#Zނl C_opۗ~8:7q#B@54*)ߜ$9_F{{ % }ҋk ]Cez }l>> !~ 3|;յBo/7"%n,_m^9͋zB!g_\wv>APއ"T\c,go 홰֢# K\VE/X]D+yKX鄴6TX<ѽQLC̅nq:ЛI*;WW0(ASIWcS{WKnl}4[ Nοs֋7:eٿJs@!&8@__{?oI!Fu#֡HR%l(if4cK%4Fpz}%Z}hGVY/r }@yk/6h?ɟfyB%Q$KILJ Fw-Ӕ$ iҍ\݈о2yȟB!/I&VkґpWF렡$:i8_a*O%7釸PC*‰ }9뽳-1A;%z&q_2T E8KЫ/*sX͎=OО-q^,Zd UzB!}=f^/C?K<0pSl}}€zB_iu :%HؗRЇK;p YtNM;BzdnB!4eFO|_z'e )T;.Зv БֶA縺 7=}Ip5SnN?=SJ_C/ocm4O G5wB*$!=K) n BBOHdDB! *GsI%=yB!'B! B! B!B!B!BzB!BzB!BBO!BA !B!=!B!=!B!d ~i<~x z@Gz@zz@Gz@zz~ (:0z @fɡ?5fw󻲙 Qv嶶^[ٺL?`d@PjNMS NIwnW]U-~[~^wIc\r 17~.w&n:k5Lo|Ca0Bbk%YT56뿹_lk6ktgپWZ1[O4'k^ɟ(CY; a{eu2,]au{q_+]Mps5V 2f󒵽>k{!5AN&r] {f'|^rqUɻ0%70w@QJaYe_'{bZޚ~ ҦM'BsJ}1\ppgSCC>K f;pn B#-M.iXGmp9`qx6!ͶPz"VGzm`gd9B4|$'ǟ-y57ob`#iS`/:[kO-O76@Sua(8_?)o"lk9`֖~j1k?|]nBx2qrOSXQ-A,C)U=s;n-qF}eVx erJ팪Џw曾ӋMyr2xlAꆁ ]cRlXi87!1`ӝj΋bm\ǘs GX'~5zЫ6o/A@îj7wU]^3DԻ&y~ ~B1ل,En8;:^SL}S|`: |\Cumm(Ws;UtՌδVDgk)j~|0MBEӜ̉ixgN[׾u{z{p eci:ԗ]Qr:BbN.Я:X,RGlF&FC9˩(/&N5j gGkg֖M 3Gt:ş/ؗ+Bν), n5&lclaз)hxh&msͺ\<5Bjiw%gD6f">Tɹ %/.% }=gᄄ+*QX.ֲ/X>U̯6X""%<4&4qBTDSr_peVGīe)c&[pvV'R-C=-vOz)`vS2y@dzK[Q| !9 8\ \h=RH;B>c$/qrw<<=-h-.=ZgۗJw{-:ېo=<5'6⭹LkbS.r@ׁл t{聺\6ݕn4}KT5:, J_C݁{Ykwg?,婜|Mӟ&&U w5U{˭;U!bKW&KyWO㲷:rvffvXK<y7|ܮKyݦ~tTu.~;_@O m{^:57yФ]i 'K3-!8}. K}5"'k7-!FڶTlMismtuZ܎'z [FVUcЖuiVXR ;Lo>CL.9A+gkIK]Xw뛾I>W''k)@"zcAVЮμ>{u!Ozw$uU8UPj6Ǚ2bME->QWG__unE79T B\ӝ VaKྺ&釾՗isВh ѠUB5Uz{^ɍ+CTQ#4(F؞88وxU^~~Q[n˅NPDmW;>D"q{{d ^uoMm-M{(Xpk=ݢ?gtuǚx $rZ+F>%n|dQP(Io&{z̉N02:K1Nr&v(,f=>ќx-}8:5ȓT;-~z^~De鶾!9"EOz7Z#B^RTGt&iVۣw)riԛT= M'PN!_E|KpՏ,BrrW)jzULӖ31l# 9Ml |mӘm/Bhy0~d*W{RF--@vDLyѸH,nه>#YӚrJn4oLbW=Ô[L/Ē//+#+uw[ĵB?Y| Cɀ8rEyBڒ 1ܰu1on6{3kIw+?)6X w~pja,iG\0ץZ@.j.7V"2TUAOy;m~R!>ÙRKk`RA҈_L7B8.OË ȷuҗep]VBS4U/Q2FܼiF i;B]wc'}⪒P#n*^n;p #]v731BG2*!3ck&FB9|# AOC(c+rRjâؚw8@WCTNVBm{Ћ sz7YXf_e8i,^jk=\^ЧOށ}[%Zvf,1E3oM8d ]VU>z?t@ }ekm'A>zT}G 3KR0DwUX ]_nS"J qKzg4 Y&4PCe21e4X*=Sexʍ4FRtSjS'r5v'o}DRKbi(|^!=GoM yz@a|z@z@@zz@zߠB : = =B=BBB##= =BBЯ z\>Xd}Xxdwo_4A&sr~FK;og$76CS=rݧr?ݩ^}Cc~{6!ifo56Ff ZO 󏎝͵0ug^\yg4ݾm<%{JLf5[m1Bw׽7TꭦSAG{ ύ*- 'ؚ;nCčP_^/]& 78n΄m:mGsB>"{8ٛkLjʽsִ}lYwՂs[5lI~]|~^=>̧B:LݏNswS}>;`}g= }AԉB.otʚ׽bg-9Z_\n>}n^ؘi:?Bts[!(\.SU2KD>HGvhs\s g/<3+.v׹P/fe757[?XrkAYbVwrG~ <rTs̝?fr]7X%]wD;uvݠ{Vnl-؝5PCýy,Űޅٯt~ܙ>Vd}Wvo;}&|oev*]O }{Vg.S}8|0zUPs{Om<~c>I큖ڞG }? 4sQBo} a}C >?v/ut=n܋7,盄ڨЋ5~UEqBwےQcnփj%7ݬ lP䉯$w=f+[rwaMM<-ޙ;ӕG}s?rM#^mhI@KnQyX#448پygZ1[-Ol\ <5Lu/RVJx ^:N`4c]%]|\{v}ƵJT+ѓws>z>R #'>􃃗Aǿև8B?|<مTB`tۇ?cH n`oz۽,;Bo Ov}ke>j{z~zk{5~04U@dyGwYu?0{ˮ0|v9~7cn弍tH9g}CPwk;#{?r\>F^z4ٸ;uGŧBom`-{ү1'<ʊ>8}z*).k(q.t Bej3A>Ԟ=Bd we# 7܎8}r?Gܾsrΐj{q_+QrThDFM%7~w|4> %}X@l>2vJn ?0SamNyUyKnS? c'3}SHɍWlz;Si{Tr=9 M~Sl}GMOwzzNN9ߦmp׼3~7_?Tz/YoE.޻[ѭA`n9Gw{/ܽ)akJ]Ӝɺ7iZA) pϱT055lyj1$tՙl Zx@O+&.[/cIsb#,s}6)k7kuwzg[[1SvO9"87w;wz7Şy&=tnpv*]O~{#䏭;X>OR[WugٱspocWn=sP``z{}Z-}azkOdziΝvS_켢n [jk蝣Id׷V)73 kwxGSn˄%d1MYRQ-?3/c+*-.; uapNЅv9P03vwϕ[SՃӵ3 12ܹ3?дns$k_> #֩Xn^W';s~ { a)XklyҖ3 =BB##= = =B=BB c T^szz@zz@_l>SW]7>]\G>ڶw}`0[mt-ɪ.iCb~5Wa[cXk{V<ݱ%dG5-F =獑,G^ 穟R>QgqH``k#I.oD~A] 2^4iK{ ۖҘz BtOI_+۶F|;|-ail𢻇Qٴ@m 6l;X2v v"^.| N{- 1(֗NEg9B/h="h3훴ICO5Ln_ ,14LWkdtLKwɿPE9Q~VJЦ68e8gg56_xr%(i ! \Ϋ]ONw tN+uvҝ!BJBo}C_m-Wt'(Wœ Iі˷ 4j_/5ie OQ,iLZ8gPNq"hXB 5B/|Blr^oҜ#{R?Ӿu 9x#sge%Xzzag ZݓZ)v#B߁SY ,lTK }6+ 9 ɷݺչr\Ї|cnUCv(;4b@6%_2O#4O&>f8Eڦθ,BhJ.[*%<8Ha+qzh|@Bi[k-9 }by5q )$ݟ{UW䮠X\q/IGWd>SPqӉVhk#SEK>^+ܺՏˋ^1Oh(B9z~~#]uz@-i^zVʙďFkJM0`FdV& zt-NסЏ\fy]gckoƮauͷP}NL sޘz\zQ;"5bSsSk([zp5yt^)trNpgrjgz#]S+bP- vW)7^w/J5|qEEroX 2zƼv]_CnDQm vOXX4%0--wՇ gosj,.߅{AoVʧ jnZ.+@tGғ|-+x+ҵ]~PG6:{k4q:>f38l/䛄^ޱ+9oi^w3e }vmc B/)l1^R\Ҏ{mVљ&uunB]9B\Z>#VSh(AK}+l>_l)VK~}^p>P}B9#J%F+E>͚Q cz8 #VK'H?^5k +Ig=ƛbςr#A4лЯ?MUYNH~hێ-ºjwSU/| ЋM5mYB1`އF:_>&x1c^gjAzZ|#]uGۨsPEd)+ еYG0⎎/DaggYm;Tq"=zFxEk)pH!== b G6 cLp=BB=B B#= =BB=B  =B= )@ש/GGz@zzzGGzzGGWц-כl?~[Z}3FNTSoZ?-#k"7qT+ٞ穡^_m$c>=5]"^= oL6ݳ{5~Ja%7E'W6__oѩ1|u 9kGBЯ+EKvz{_[4^л2}WPqie۶g5zc;k0)%׏nm#EHmiûl"tHB}{V=EG7Џj o}WC%roD_^rϝ$A@\ᔢ 晪M!j_VWTd./h]4}()XMMG(xOpݿ {{vZyY<2D4W9q}gGɑ=ApuN8RtS ;|#7[JÒ [3x(lrB9>pw7"GDܞ>uٵL־^`q8Q4nz]jUUGhJL~3y?o^VrDD(8 r Fu{8fw@٧ԁهw*B lg\ؔЋשm4yoA2BЯzɪs{=Z`zeqEBԋke OQ,iLZ8gPNJ.! F U:vpElr^_t!YnfX=U#{]rc\1P~VF腫ykn[8%.SVyb@3B/̈ԽW(w!6+-],?־?-Z7OY!Ttk~VK#K#[;}#ʞ* x8B/z( tLUaZwlLBЯL ogon*fd>2ŷD+j5)h*BF[C:D^jo5Y~Y)7 w.ѩf_w¦kjO4aH2^\Tgj 5=aUk.ޕC#ѹeB_C=B*ϡJn'ό OܥևsPkzB3Ӭy4{*_7eѦʐVSFm5FMB=zPA\='/(En#3yMs$[3(uJɍpS r{LiryA"^jz~RHJMm=l75, Z{z@z\m^ ~tj @"=B@zzzGGzzGdB :zz@Gz@zz@0IENDB`jazzband-django-simple-history-2a2bec9/docs/screens/2_revert.png000066400000000000000000000426641462567636100251200ustar00rootroot00000000000000PNG  IHDR8g pHYs  iTXtXML:com.adobe.xmp D1?IDATxs}/')j*7]LM,`G H ϠLxƖ Lv0ØPe É$Q(^Ok- xcRO? Y߾ؐ7] """"BEDDDDЋ!"""""BEDDDDЋ!"""""BEDDDDЋz!"""""BEDDDDЋz!"""""B<7x%̻ߛΎ O<;/=Klerdƽ|&*w_?3vbx/_~%ҷzGo|weq~pSN|)}ܓWyskC3ADDDV#>͐EHBe`?\K䦷Ecʞ^M?EsE k'+z5[p{VUEsshsJw*[B~+)Ι/VNɵW܍z<1ksBozZ6s_d!:锅x%m_>j\yu^\Ÿ+^V\mi{kFKauH4Otŕ$ns8juN_yn]wl^Q&(o~WR_z- 9]я>}d;[ZNSiw^cC}΃n/=Lީ0[㷻F4{S~o 'w?>Pэ~n:]lZ*ms>>Kԯpǥݍ^z!p0$-m*#㟕ebb{A'Ô }k?_dx׿<'/>&SN9^jBz~]<7X&$͈z }f#AI賭$I}7{Q۷ gC6ZE;~uBT>({Y N<'|NF~`nG'^x}'xxe܅mǞt|҃7`屟5l]Oys Ϸp&gɜn{dޜxpSgy`/6/9_x-O(/_|l>x8!j~r`^{{kaBl}3&Cy-NVrכ鮎 yfr#I꽞jE$8vṉvkc]z5D,Mwo۴#X>T*L~ d6~d\~&'G?f;ZяUBixWϻcQ"Z7Mb:TzĕÊboVSUԢT׽Wg=3_}˚+"x5Ꙝ6v6)fVRuWDٯ%/^?N7j[]=,o넆>\6D/[sloξR;끰݁S]㚢G~b{p?0K[ HNp֧s\RY|!'_-Nm;EU$BWY|"=龹; s?U{RIN~Xg߬?FMcaiqo;zc`xз.>;US)F;з?^VJ*c#G.u)Gd!Csbr<.*^4;WϥZW BUz!nmw}0sWUW~} }E~z57Lc{r2{P6~Cs=rO9Yn96%'^ɟo~vƥst_J?oN|hЇ.AZ=I/Bq C^>.苈T,:vJn*nrfaۉj&.R 7]JnvWI9M2з U7A; }ñڎXfk-Y;xGo7nٌ ѰthxG}m7S1zŒ|O䦼 unnòj,=R96e/dDl.No+9zWY޹NhEusyRx7Q)W{!3)gv.OzE|DDZ[Yͥz䛽 }z+\spSg85JkzHۣ_.2oJknx]資tD>J~+==ۇ*F}kD`pգf>gq.A6SW/<=Gu5)M||NӹR5NէEM=\uWO*Eq\CEv;T .?22sFEVN3T<ɏGUAzvZBWk8䓊E).;/X9tbGK7~Zy|=zWC^\_]˅.ZUQKG7$|oeB߬7;duׁ߷o ɣ5.o-5?ֹ c#S?r&-WsՏG}<7J])%{eۍ\g`}gp }#Ok{TP|q!""w+=mzɺ;=6mm }=ϼ?r6w3z|k[m'^H׶8D v%WK_`47;qj`닫9<77.=8{rW󋖒[~ ̾7GN ? *wWG3Ouhpo}R#K;.^DDD6\V_/Ew/E  }e{7G=$obh6]ۻǒo݁=~Zm:ǟ}Ϝ~{\*L7 ?>̞uSl߯rshtB7_N O+\q!"""BW7SW_DDDD/R.x7بΉ"""""37߽-秷rʶɟ ޵->wDDDDD}<#P_~pm^~c|{oxxg晓<03޳b<{wdy3GSDDDDDz!"""""^DDDDDzB/"""""^DDDDDz .-"""""4^DDDDЋB/""""BEDDDDЋB/""""BEDDDDЋ!"""""BEDDDDЋ?-ܜ<1ukOVGFN':Ο5|q¥חq7¤g HwֽKY{7#}uWUyqSiې( k""BЧZYe/VgVIz5n@Jov.9cΎBS"S?l8vʧ1PX?now""BohM'W&T蓝-m}9{`%.p,T\BpeF eSظ%s<Ȇ$SJEąn^2Ԙ/m{#jUcI2%l7% ˻/,VnUf(llY9\څƕ0i77*=5HNȲ }.;'d{mS_ݸV鞮v)Nӌw?=t0]68gۧU*!h?A"lIQMڒ"Jkhn"BB_Al$В5Ayb.s'k>/$vlLAc7zTQ /7wjuԜ WS9t5T }!s]>jRi맞(_d3$RNޔ֚Z:'nO_+{8?+#sƥHtoXmcx]9[ҜsjEN֙Z~ gd?ʊ%Hw$<B#!LrS6KLaU ,,1989_}U)Hܼ-ޭdWPF7KǨZyZӼv8*w{=B_s8BmGRB%: }jYN lC#OghuB_lm2F'g:[~.DDЯ/II)JC$%  ;+k{-I/9N( j5F J^6W .4[#ŋE%ܬ-㎔ΐlcT33x9\KSvp7^Mv82ژVO$3RЋu2B\1\-N[4B_4^SaQ5]Fk}n%>@谹NF꽍aǺ.pt)v]#s){{V_nt}EM[ܫnÜK.*G;Vg}BB/""k듳{: .Qs[˾5Bjn{,xb:+o* ]~le:-g }wiGjk;V^VCהdlP,s(j>^m.r }Ti`~)5^DD־ ?}E< [cr5K*k_zي[YJ[G]xP<^S _Ul@\*905KfbV5wK)C󑪄pc(ISj J]5g[T}=ͱ"?ccmgf)= SnzY3B//mP+`*acd5xxVDD {&݊B/kƄ^DDz!"""""BEDDDDz!"""""^DDDDDz!"""""_>lj@B '@zz '@zz =@ =W/OuzB{uWiko=V #K:bSxXG6sǧoSGm`_C?ԿUj7loWVx+ۻ' o VBgOڿ[Ԟ7vp^8o]y;jK{o兑Nfr};w}o{ٹGӟ^<1kL&=z9Wyt&{lq+Z;v/7VOC{LtKRoM ?+ʉRxjp>96H@mzgSD<dzg'f&V|MUlLUr|GG='O<,B wh ESv/П|TrC;~,cG: bvaٯ/~dW%Zǁ7>w[W zBRǒַokBc(}?|\pΤ۟]d%[>EN<= }afMQc(iF 6c!(N-_]8mKfѿ?K|Ҷo ;O^^8}7n/鶂><<\6B>Ej_غ7{N{m}oF_g&.~_:;@ E۽~z7Jr }5킙\~qB_TVس7@vO ۇ'_ >|XG/ozlOުwy%ⷫe&ot vM;]smu[1}c@Y[oz\%7|\!S(]&K~JnVVV xӦ0˕s[IX.{|0<~kqB?}~%m%3뉦z)vSlBMq bLnzHgHu?і?zy{۶??cs_yWT:qBV3: }u[9>~+oKվxBR뱕s'Jn:s~8֏^u$ҿoWD_x֧xSG/mko7X?7oUK_=q`Ls\U(:uЛ퇲B o>Bm÷^?imAZeZ?>=Kn=czYb|G_8Ϣ"oāǿ_<^/Ow51\]AwW?z`sŽ]jH?sίM>>Z `٘Bv/'=2sΤEo^9}5Sx_>4|ӎB#uaG4=2i[~Bl/<߿c,}秇>w {w􁖳'vnm;N5kd=VxlǢ B_~y|؅ R/[9}|GԿ}8}_ߜ>[߯tIj[{Щg'HmܽGPhEZH{wsbdG>52=2օF#w:z›-(Byvx#{O|t[W{u١En]8oᩛ;ڛU!X[LOOս `rAxG}t-|hFhLhRh*>oBz==B==B@BOB@BOBGL14u6<9K[p_GPetvOc[ڝ&MJwV,;KG /[Ʈg/WRm-Ч&}AYr=^~\մRs2/(.{BNK_qN6G =_Ӊ_PiP%?+w)O1]AM'rs9(`яjvl4iCoE%ItUi2:K=끨g:Xބګ]+J]ڞpm٭XaEkGlR͖y;zvVٔh=6"jv _6:Ui6oĪ5kG=<ELF{T)^ќބ>ng}&]U5@UV{sv$/O-ҖTtH=anbۛ;k _B_h^NݼV-yʷ֝ɵkXJM7WJJUq{!iް ++UG[dJâ)=XQ5 ]PU6W*sOvgB_YRw݅~uZsQaNaІV׵C'ԯXbۻ_*W,VK"|qBNJ.uYT݅C܉QJTdvgT=B NF[..qjJnZԂTj\c#%YG#UZ;Bw-[M:Wvuqhj>Y/Z6Tm){?0}Rw:B]SjL!=ЗWn@;ɡ-) ;ˉ ے <҄>sdTk{0y՝ U{ڡ=PTԘjNdJ j; O*GGEXCeD0wל5+?cLbEP l*n:AY_W#.OxRJgtd\@EUM lz)7ԼNZk}캒/*O5ōHm|-ڱ`l){{~K͕Ii/z|kut~5oâ5qMn~R6`YO VzK*_ejzz =@ =@z  =@ =@z  =BzBz LLLPCxIx=l>ʵkPGxIxpz bff&h~z!Y[F? o@5u-Ѓ2B[@vx= o M)[FgWd#Skb'tڴihA 9CJ,i%Ҷ*,c ދvx:umqLAOt~Td/x5-˥p-M1g'5^Q x,ɩelt1B5Aßm/y4tosz ?ּTH}OLxQ[lI߶v_*,l2_ll ;P]; uZl´:?lx+aMxK3EA}!Dj>W>?s:%[+) }UK pRZI\#ɩ:zr#'NAν:k' BxU7xTU3o wd^UTd!wJ/A^-9rM6?7s"Ѓ}-<^,넾Ps{M:tH>g~By~j(ή:]Oy@zzb㺔udK_З[K_W;YB }.+5BOA[YP}hn5Y9uoB_qeiCS+WQ|d)7@dz%t_ҧj^]C|XMe =̼Ndr&]lJO Y/Bm:WבA\_!]puTǖ)7$O7Ob' { @vx=No -Ѓ2B[@E1111??^o@5̵k 2=uD@$M›%ez`9L0jo6a@ =@zz =@ =@zЯ[ Xz  =BzBz  =BzBzV[nLLLG=B/r@聵eAU]6??7:'R+N333SC = Zabb| = L mlчB#Oݒ4thjd[Ʈ;ϟ z B2B7Y)'z Bhoq˕7m7UDsU,N Bl7LKq&sC (u%5ԾtmuF+EHiʤMmΟL̈́>6S=_WBldm9. }qb%L˰<,+>_/ }x#:8S=֍S7x໇&gO9B5xзKY }yNBm1xs\}{Us{ B 6/S‹,_mJn*G~S'o˃w()_]r>Pȏʜ:bf{~bxszB_ó^=t:ǁSЯз7t}!Gs]G!>_xOЗ>ac2>Xv_Xy]Mu~+̓ކ'Ϫ כi }cH*̈́,yD}4zJwCЗAj{)í,`SQsѡ'Gp8> ̹~'=\lP{ &\'gB_j]F]u\W}5ā7>H~U>rܠxV }{ GdV*З.MԽЧS}rl0?J{`C9Y%F3k/Ky6fs^ 5įW^[zQ_&Oklz@7K0}MZ%>NoM'=zзneqM&u^>+):wq<3H XA>9=Wr[, gdIUGRk[cSBӑ0s@ =_B4XuS!u8w"#s.eSB'7rv -w_h z/&YMV|(k?,|$X{g&z`}SOrޜi =?欸Bs{h}- }VW|c azxz`m }kQ> qP554gR^?B_xME4B_^Fc=mI}kH>+zB SVox<>{Te*M9 }5aɡ3j.UOh~!BO7jiZ_sS]#iMo|S r 7Ův;BL abpD#BSܝk>IJn~XS?EHp010=@Oz,}?]f 3;dBS1=&=*~kk׮ }zL?@聵­[&&&LKB =~ff&y'@^/ݮz`Jϣ- ebb·Jd~ BO" DV?B'6<ā "{/7x:^' w*Ƕlڴet605ŖŹgGlJI>P"7iC7w #W!BON`֋l[FbmN=Ē'vo"SC_5fk/^W\-F?ԟӟťnabD /@ =ַ7G ltbxs:lz9"Z 52чZӧ'-odu2fN.ڃ"-9_[9o|:el{s=8'7HVbB >-iy}Ga:UC޻kۡڋ{c[IBeeSӫzB!́<[E^K9ʑEu8' *i;}KÜ) oZЏM~|}`T=u> @s#񭒚XB+x˜v|r._hYaj }-xaW#}*|YcS)! BO豱~xb9DwuUU'Lܽ[BߒBͿ2+P'F㊚7 }>؈B_~e\C?5x }ZC?Y M\>)O Sf͡Cx]M㊚ i* ӯO5-/ďo&hw{ǷnxMIt[o<9UW؅[+. }C[ IR_ /etJ m~CWw幅5O:HB_݄>?ׂGBx_&Ϟm_"jw= n }By~o ]`kC&~7l>Hdz϶m? }&WCLӓTs5M˯[(~rXkXzB4o y!h ΐ:IDATx]Wg?Z9s7wך칚Ilح8&&ꆭ!FN_D~&aV[|< 4Uմx8Ao} Xrˡ狞{gfI~-7&_9 w""".ggg guFʺt)"rͿn+DDDD}?>+!n'^}tvgo ?KSǗt <6;\uS3b5o|S}ܮcz]5ثu6]y.ݢ/`\:}Rɀާ[K-X=4R;p^@yybALNIc )<՚QU玳OnyJ鸱XvHJ+ϙ~P-ϺjTK[K>z4ë<žݠ龮cn}u>KfZ*ڷ̺iz*]?v(?Wz_NLAlq֓EwōRKk>ylJN;3~ mZK'6Uo+ޮݎNgw˭u}!2;.g%>g-G :\ wdOsg~i/߼}t[{D՞dsj?i>~q潱=7vU֛sU{G @M[on07*Hy{;nxvF5_oyv'fmG*=QuݿzPb1 [b&2k*=O+w\~[h~q޷{|uSnu.1 ^<0OwGvAĐ1SfŚ c*n%6h5vh)!q]qןwZ?zi|A/OA/7e-A/|ݯtpޠ߱|G zJ@Vؽ|xlv=G{0t"}?,DЋO|}2[^8ݹ%(F]rx:>n^nO(u72Z?yt۳z 2=bzM^{++][&ꭵ]ڒ>h?O)T,76bvusbwɺU[=]ndOWᵣ{uok}hlBMe=wkb zڱmU:yݎ޼v&Q5f.obfz3%Bb_+S)梨xWhקi X<}? D7G#Tn+Q=SwDΗ:#7!_ <\0AxF?sKV{etwIDc׷Uƛ&@~Fkq|_beS&k/S{? },v{巻9EUW6(_){;癌=EqƮk]iKx{n_x1 . -on?]9c%#?/bUݱRϒum1ArjWvDJ<#Z^uN3;8BϷч' zѵOSAoxܚ7(FCF;o9CK ʘ+h#R:c s2ӓF]'&xݎ:ig4~~#B+) np߅ zQ}? F^a~h(UR;oiJ8=;,]H[I5U(75ՆٔyGdzZ{vM>|jb&AYwF; +GлhE>&Q>"Vuk5ŎL7B "*FR[N|Sn'*FrS׶s zɯp~)7b;m:v{&{fκq zcYUjUmzћ/S6sCЫ.<%}3COTx`ߢuy;zܸ'xBзwMvWzg?rпU}{&-s zm/jQ^vwu=W!RK!n֏o7ܨms1י?Xz)7.D 9v$'nݺ|_rSt믬_)ASݤgդB:s DŽGD qr_"RouW3.vhzbN9_M%:xؔ{TQI;$eI 56NwIGd AĀ qњR"E/e'(ԓJZ7U*A>c'Ş(?Tڕ37we7Qу>1xRl煠GDDı/\y+ _vv)XpƹL+\\~ƢiQv6$՗GlV/%c'uEv]fx_?avIWO>1Q3*B#",ߪ><]{R\q嶼>wtg{f[+b3oҺW77y[kyY[yk4QTEYOgʓSᳫ;ݥ+ íZNO bCb̡])]] = }sߞXG;&y!qչA?}_ކžֲ=x2nK]+w}uv+؍[,ݨ*E&*kN叼IyJ'[- E.f>/#×w\^'Ǝlﹾݿ}ٽ0Nwm Ŀy>/="""K|U又H1c-(_x1%%5| """"A;Q<}m{mqk7 E}{@IMwoX6'Je*'wז鿳>ݹ'w """"u_q]EG*m2OeuӶ9۹~| """"A="""""􈈈="""""􈈈H#""""􈈈H#"""""A=""""*_""""" GDDDD$GDDDDDGDDDDD zDDDDD$ zDDDDD$GDDDD$GDDDDD0;vNeǍ.,k{>>(l`yEzo4>p U9{ ;.qEgZ"C@DDDD~9Gu/#|vcAWd˳_&>s-khݢ#;q6g̻'imxƇʹ15K;|7o~yqg_c KΚ=.j+0^h;E.[ql̎,#w_90Foxy n z-U*lA/^76wВݾ][o0׬=0nxl$ٮ@ZcG64}Rlq3>ߠwO[ nW1>>vk8HEn/ zQbvЋ<( ێא9uge4/θMm)5g嘖.;3䌻{f8SeBr2~""""3]ZRy z흀y_i=}W_:x?Ϡ~XG(= ʚl)bK: 粱,MAno^Nx|:t[C0p#^ _ՠ7&Ћ5//Sn q5o'#7w1k;AZ7k.nN,]Ke@8 )dn+RQ:rIOM(T6VL:nqoKWwszi'_7f'5/sbv #ψcw!JS#5iERwArOޞf#OqvbAo_뿊&ί8̉UWqtU.)ϱqS?=W[ۗ~%,~s'Cv _nzO ^2jЇ)tՌ*C}`zkX=;y/@l'v5T(feyt2?3Bi2C̽O;?0z|:hzGl圂>ϕQ͡HNa oQ=^_r\<5*7N@K;^9^?#V=~A^\ykT7ړC;ժ GC:A:sC=^5c|5c^}Kp^ϽqC zm'hNc@DD=gGzTEI9+AS2+縒N؈3sC|W2wfJh\FR{Ό2Z_~WAcV0}]E+V\8?Hq7Un{n\"fUHwcͅX0q 3FЫ詋]u~l=A0g)attTF߼K.W~ 6. NXy?:822nw ͮ+Z'n L,W"gϞzrS { g\KJ_Yޙay+ak#:r݋؋i;mcxVQקb1} Pd웊x|#ra~f:<7ԜˇmDkW`WĺVRwRY{:8]o'̯-x`}FY_Y7+>rXر#b7{㋮7FύyZ\bVx$%MiIo׿)/kJ|Č㭫H}DzbL5oJv=:mơk2RmqT[MN-z |lN5;ũoӜJ*YF$ArHW/X᮱W'jEx /gUA?USRUKR'JV6;1:56Ԭ ^pwt~늒tcw2N=_Qz4bo7B/DdampRzs(YX5%}%k}bwͥMI,ni DWm2q 3lcfjpxںlerVyԼ@CtvR uIN+-<\aj".,;n?GSYs$]KEqޱn91ŏ XA{S/=Sn/;1{~y|c]'cUR8t.Igݷ͈įڒjϢ"~hTgZpv{;RZ9J;WZ͘` ތ]r|ޫd{]I5ޠkSKF“tq:3p gs웊IS]5tgP|'7n̤ϑx8s$K9Or&ڔɦfܘr#뢚"OMO ٨4/Sn^SÙD,& w]Z-eU"9mXXa۠kJ'7 0PAlb #ߦ 7W]; ze's`=A@TU=[rЁqûr+u|?GXM XDlb2V=.[).[\$ݓaQʙ.TfLnTNv]5sefōޠIvepG lu}qੑlS:>ou}++uML >sX5K흙K-:v:3%h\Pln)1r7Cl_NkPMQ@CžSn^,>F<9A8%^5!7j࢙~)zPΧ4L۬ΠI^UW>Wvg ⫁̦7 !mDƝ.s'M8'Ŷn%vf {XIsT۔\bxI~7o3}NjNQ?G}Kb[aWXtww勍8jm/n35vJv1’էj[A/.,9qkI[X~uv;sg huNu*,g]Ҽ.V?TbCaixc7ͣx\?1W[`󇥌?z/o9kTtY<~fYE[Kbό?8;K[LTԹn܃^Z kX*n?ꖾ0g=Ȝo ;y`EY;v(**H(qA[=01p"OxMj'OS z  zz z'z  zz z'z  zz z~e111ۛf"^V%^b/uQ~uּɓi"^V%^b6^w_\./LδeXXXꤷW`/1B&u/vjX a,uJ4]O47W* df`yO?\hz9덲ZVbI]y]].c.=/Z60 7Pg鬴+ ~/H:] zEuuG[ħNrYxlN;}?⤼Qb_U OHekc_~J|Z?6x#b/o VΫz+ [< + =A􃃃C:-U< ! ^@пdi!^by=zhbHܑnS߳Q~u!^by?|ŠPkkl_LLLdY,8e%^\%&^hםh~ģ^XXjn^*,(e%^\ʰ^]Fz z'z  z~qx < $''z'' z z _J ߿(U |@/Ǡ~ʕe~9}QQQ5e_ASx@qQ$|ZPiB!7oo<󃼀DA__3Mf3}C u@|߸`zGFFbRi)xLOn>9=.$z>2Z<3%5Y['enN Ow]|.A@кy[-'["L18&};YhA,V} yKzͳ&bϽPO澯==A@Ṕ̊U83'hYP\]@^SPaA6g;O|J}>Twhgf[?KUY7/̅OwC7‚ћ?H's~ro=)6w?.m\w Ș6{v0a]W ܼ75/BŒ5O~2w_A?00 z(UB>w['_,z'' z z z z z zU==A@A=AO@Ї344`W2|A~ʕW2|A)**Zez~Lk>{ [>}88ۿ8Ă_~&k۾hui~s =Ao|Hl=sU5[+LgRk.<\1zcj]A@dlM3t>3T[ekk>1p8L?h;mڈBMZc-%|^Ŧ^QϪhiSO.8\}%;?Ե9UCŝSu?+G93#W泞}Uj3˷~푟'Fn5ޙyLmj͞_f(Zc[#V?r7ũ-W%p1zuGSn;kz*cv 斔13JFHE9k Wi/_MQ`ۺLJѮ0c̡ק(=hx:L/YAħk;L;9K4臆D#*yM\{leg~-fwsƼsG~5fہv*73c#կrGG>뙜Qnuz|9O} Ȍ恠 W[пl~gkVoxz 'z z_|,zz0冠 'z zz '裐.(rNl `)YPn%_qAd}@ůl6'Cfųi}C͉ mr;-07ٜmNz @G zQԉA'C^Z*u|e(t~3`S^ w/lU*o_RNOG zȺybTowu#A5s'A=A^]5^Z޹䥼s荋@7_FvM:+]^ؼ<^q՚؃= jz'z  zz z'z iA=AO@A@==A@A=AOH*}u x(|IENDB`jazzband-django-simple-history-2a2bec9/docs/screens/4_history_after_poll_reverted.png000066400000000000000000000527161462567636100314220ustar00rootroot00000000000000PNG  IHDR| pHYs  iTXtXML:com.adobe.xmp n70OIDATxSTW"S_mC92md d %./JaMØ(u6hLXb|[5@G9s4/ S9sNvuAAQOTAAA AAA AAA AAAAAAAAAABOAABOAABOAA=AAA=AAA=AAA=AAA AAA AAA 6>l|zz   ֓tW/nF~`O/˷=un/=JnՌgAAkzO_zyO >j}oTTS;]W/O(ep[9@72TڳoV]>yx]I%QKC;*W#)n{G Xeȏo]Ģ;'}ySX?-j~%l%Z bӞIP+ R;B_OidڒF;tĆ ')f;X-Q1~'[5юW۶i\^q(&^TJ1Zr/!bW"5kXzvmՕF(({ǟjhkO~Wgߥ/[:oSy{`:v[u}:sGn-w;ƥWwVݯz!E/7m 6,GgT9V۞~NK!ȧfcԶ]ޟ^tז7s& oiתT٣~3Zw71z;uSNDx7KWXq DÃ]HS<]F`B[x]Û᧜M\P:/_pY^pl.E\]Ǖۅ^,\LIdzwLѠez{Vă bQ&Őri^yV[^z&'n ]Z-fa2۵UWB屮I@Ӗ/FvdsokGߞ^DO͍ם9~vCn(,]9&_moiTPK::un/\/]wv^:Tl^ܨ~ĻGF{n{雭sW{79{nܾ.CkG; Ο^6ř& Bf⽖Q _y&u7߷\uO\_s jKP{ĴB]3wU g. kiyc֎K ")߾{6A8QMcz3t$:2_HmHׅ^7XB)MB7xQWQ8"`yߴ3븛!3%R!Yg|*C.9E_adb;\$@d}Ԍ"ΪxG)Fӑ[D덐s oXk -\̨I ڎ]B 4G/&(&{[Owvg;F-3ܥ=D9݆}yor}Bu}ͰvGPŭ-ݩov8ڦq%m))/&v[<ԭڋ]f?8T372d\ۗ^'Y>qqV5ڬ-; j_>IP"=/K M)CxGE>ttNt }$9>\ʷqwuqVF=#s^Jz!A>dm?}%G"Wȷ\^#O72̺]kuely1+w/*kF_Bow(n}0С/4uk0껣74zLqmGM*g rsQb6ߕuMGև w[[sd8o(W_KH8fwVXЫ uEgU~=z$NC}!г鈫RًFzv^uIqtbB&k֬u0h{:kW,Gz1dS 4ׇ ?"a;u.Wr&h)7]zʍmY=GQBO$I dЫ=&:5cРzb-'򶄙3"w^lu֯,(ȶT7))&B ҪxD#KK{J,B[eV A8gY޳^5WA.8jB&"=h$BKуmŅ> л uϬFW۽+?Y.7LLu1 b*ŸKz-Y?/$ +6ږUvslS9@2ZúO[kLiei=TtP6B Bw/yPF7Sz uұʜܢmx x,;rLwO]pGF<8Tc]qPa ;RT荇 EqO:wPǫSnUR'Y~ 6aBO;Tq\\N)eG2iLܓ-&4)c2Ǫ)2}.%FYz,B)zb'zUD1g;ԋŌ)7[ ;-B|~0:/w-ٿ.v9'kI}2 ߜR\hhQŀY2ϖ% u3d6e5yu.mGc-sȅ}›μs_kHN2 }X/4oytZ)B5j2Rh A)uH B8*K.57QjָܟxM{Mɬ% epSɳv|OKio~ݎdOKqיڏ x1.g uRy\z 6HXbNܯu/Sf8s;tBZԙvCluX Su/&Ǟ byuݘὫ/~N>و.Y$،' bZW;Wпqw*]T ԼR7^8Fخw]r֦$ ^jKmJN="Y7Zgnl[BN4)qZxW%_e=.=Aj<>rK^)Qq֗;tw5.R3_뫛#e~rg,9>pm/{^,pˠF-nKj&~s1Zܞt1᪶ yzO5]ݟ6u%wo_Ե\oX?9}_?v1nr)V'v\z  6\п%msjo_bq{nf^$^*Sڮ^p G>F.(D~w_G2 zʯOi:],q*S_5׾掽}m&rN5HZIr%  ~mC YAA/Qs׏9Q  ~Ѱڋ]W?1Q  @{1vmkJ"  @ӣX0JƩm+osipkNon7΍45pAA}/߸t}Œyy75d;Է"AABOAABOAABOAA=AAA=AAA=AAA AAA AAA AAA AA  @  '  '  '              z   z   z  @  @  @  XBX|cZb?6u'.!nkSp]nRۯ}{C׾9w  [a9GEsE4Y۫w r }z |  qC=FUWTy+an}Лzc_[NSAЯϒn3g͡Wp2П ?nZ-_-;E`?d؞뤄zVޥ/[s B)9^},s' _ 2.b"]TF.d!hfdžd!Ŵ3q,uurK}e1Kz9M 5r#<oABOyg0  z *6܁ %  ʋ Ey @  @  @  @  '  '  '  '           6/.Ǐ/Z B#=  =B=B B#=  =B=B BW?:=R1J }Y}8skk_Uom:;1vo~K?{6njk\msٗkR/:{oV=qo^Ϯw81h!ė wnDp|"+;hxO}مG#';jkgV~S_}9=qybz 绛tjDPG2># g\~\ݰQt_?:;xp`gG#B'_]x /]S7+G>:i "?{yWmG/O+)_΋3g G}=_uք8X[-P{y)‘`' EKֺ`ftmz|:.Aj>#n|ץDI]a6R{k]GV'1 -v+>cb/] ߇gݤ#w7sչ_6UG__G?nr:Ӆ͵ ich=_jQGw50;\ QG@W|QBhmn0;9`~Gj,L>l=n5^}mdg95}2Lͷk/.lZ 'DL6hf!n3(=O~q~ͳ;[zz@Z3V_jJ2|w!꼖3;'2jHDO};7EkJ#_;;mS!_*ɖrc~7cw'-)ƶ‰C3~$6~|DLNĔdK( ~)7= +Pd7+YSna {{yD]zpѲhFŅ臜ܑAˠ Ҧ|vҸ #G?>ڬM/#<;|1GǥϏ('taɳ͵g?_.<9?bg&f_ڞ )Ճ6m׵i+O4K= 6sչks[r]gFgT䅇_]6u;{o˷9vװhajAa)&j[8p6E Hok]ƽ1^j[ vV_ȏ8ևæC =~kOm}~S>M/L?/8יh 숳_۝5ncaZQTx\R~7 ~h{d{-^8Sl~|<wof9[dG61sr|w;SYz(? xum;p-??Z@Gz@zz@z@Gz@zz2~lU1Ϊ8 +PN.:VgmYJ]٪ OXgu~hV},m+~fyۥ l wEP~-v# Re5)ЧqBޚT{\ iRvYO  Bg[$f3 B*(5B]LRuғOt1xyNmH`؄f_|2;BU侦"ΫθW+sTd KyXT*Q'iEU]gr#v}z"u*5pnnQc#Y^aKk>~E%hu/uǎiG$lO%~Dej{?X~hl_A||,ơe[;݉_EOZgb}t:߷քCkOыWL<+D!;L+@SI{, R(fr7Ȝtѱ1SEp,Bm: 4)PVz'[Ǩ)I6!:;C՞qŋjzYcc-70m>F íGZicn hUb3IJX2(Jzq-XL'pbRU咉 ̌m%OKN.,26: E80'{Ma|g:K&ь1ߑd@;  K%\RLʪ'Gir|pt=5&oBܺM(a-gK 4kM12Zc^mX$2w1M9&DɣK&^Is lߴzOEK{z=ȴ-i8>ɣxϓRRn oJb׷la% @Dє//''s-MqЏ%6`Hi %uT(J E5D`IHQcp/F;][֋VL;2h V~Դ-YЛr J?`R䪄Ch?E)76Jdr[Rٲ=OIrIM[ye5]*=B4RG3%;e'Ӗo!9C m]2{}/+!jD/i = n?2czZ¤Zz$rm7 z oxYOׯmYJ2Lj_Iz+'.{HT-zd!}+4m8=5'lNirx Y]3 cnzX[jֻ=5Ț=}XRv,l;)=۞ȾdϒC_VTlmHpӚBcBfAqd,VT9<ӿ_1З!>]Mv98$q!(+4[BX]ZJQ3x$fYTr_rSNk&@q56|SqECZل޺Kf ,ՒCe掘7F[p !zV)i]YnX(i Ϊk0-iN1g'sxV&K1l0rfS'u1l\dZEN,Bo=OG,S}Ih>bz"ڕR [+HѡA>zX>O6<B=Tʧ. BB=B B#= } c T2##= =B=B=BB##=B=B.sM\CnO ox@uǹk{om;~pZA? O>}-n7Vz wU7گ a5E<>^; k~=-M'%Twt8%2Ζ\G7tǝ㻫EU!8z~2(+.΁sZ  wː?r-GW +8dqFWX uqJȬ(sT,r#UŽ75_6s\p4O n9д#Zt3w )#g X.ɚԪNgwu# s|?5 M4I軥N<)tw M:SdSWɛtk򖖖/Dz<>״eld^sW,lj\7r,?η_};݅'Ys>֋}9Uv|vK K.{kS9=RD蝝zᱢEoOD+G;O?NXS:g 8UOd -^_[ {WngKNѰq+mK,gѓgsj+r{_ݣUMFn쨮;TrucǛT7y }uTL䍞|u9naz*d~;wy$g[w6WLݎKnc }\?n9| B|ð[Ǐ%lP~n'rV4>"o _o p~R(mBO;Ӻv$Ƣ5c!؅>T+z.h9%w=ީwϛ])BۤvXf@Ӗ7 lIEY^tn##;~zR4J ( īr0Q⓲TƚLɫ(@7'TwD@&bf=y>Bo&WYϷڤˌ@⤍4Ȩ1;o˾ ɔ2nTF>pc8M;ū@CCM|?)ؑ?'+%4U%[9!xyixC)7B}P99-<[{z=owSBٰ-\negS1~:z@>h.[m馜%y3c)zǯvo85)+Qs3nc=B}4D]z{B A_J+C?r8ιLB=~𐤈ЧV={9z!=E;54p;;uλփɨ"=;g}(e<Yvf]0=9VT! s~[CEC#F{v?{ijFZ~egz Gm'y-k}ѮXM>ʭjBSnW>s%{)VЫBf<>פtVEpWnW3 S}nY6B_ZwpL=;W½;w(u>ys%Rn D-/?RT6$*"q֟qY680߻,)7^cF_ 4 :=9+o)ՔkfFBnY40>)Jxgu\q{n'&m)7+]s7{Pb_^ }0/n=>z{nMi1 n [om;f՗z[bBV3@yGu#"wb?QZĸ99?&;:]AO#&,^3RVa/mńٓ;U?7 CNGzZ67߻,B%a#z@ooMFw ݙvaz; Ѧ-aFSwҭ)o(7(7J5{Lzrq[2T Gn)VmT#ԕi+9iD{\w;{F w|szEa'XsQES=f_G=;Yr;{#&z80壑9Mm. -9$-ƟӰa{0)?Xg&^uSRLQř7uĴz(ޘ]Qf EM_; |MVM9 vّ '=<(ܷxɆ[`iiPM&Bb59ғI-RָdLg@zGGz@z@zz~ c T69=B= =BB=B Я%[Z,22YU?E=TS5Uck噁U䷧^齞i~YwЯ 7q+A+]BYgG^$c^Xqx%i:иkÉ\GD~]AU %{r_2 φZ*L賘ޗ~_!3V53Qhg1c^Rq Lo%5>[JG;@7l=B_)BݠWx/qwםVAڷl^B/\McK=%4o#S?z5`|3 ʱuϟg 5U:zֶ(v-zЩҺdfo=/n\7Ş>;\Pnsڿ`Ë:2 .X_Q?Pg\ii)SůB~Մ>tw+V|k֯,,}҅^лgT-<.'EsI}Ң͠5DWqunOG\?j~^*f{Vlh5i^\a5y ]EWwZO];:٢//Ƀ"zUUUD,tywAȘ:/Y{EΌ|$̘+CPl.xH* q|WTsSzKflr&KO3z{}fQy| pI wW҇V8[^ΡˋtB+R G)J;; AdNPz+ireze%(қ++QC5bM2˴x/ЇB(~c }O] ɭyKv&KSFZ3@a{kɡ7ht7/QVz"0z! ])wpBYj/EWbͶkYknP┹xOroZgF& Yn/f;"6.OVc̡ BoLP.v졗bmw1xYu"#kE ?Nh1ޅE7_/JrT|Sɡ7oUYzy92]ˡBXP^s5<.π7Yu8iJґg4>TnbYks[rwi#Tqը&*(%OEG3WF7!a;y&0N >jbtYͨ?N1HUT+PV|~1pB B~.y溘MsW歏נτ2M5i^׋ÁĔ)Yx'7x`-*ZɛA}&M7ߩݸWE$ K1gjR,7䙰b܈ɩN(nf}e&:ƃzE©v+93 p|7|L lM2&-I蕙RRjDՌyB/.MSA7E1q\TJ>=Y?|}f Ɠ5o.6Ǖi>.=Bo"Ґ[XjdU::CeH@,J6o57 dN_Tȭěu>Va1\ar'e~@Ef 6|GG:Zԥ5݊-i<\TM-%-Ga1ڹJVrC }q}I)7uom/;mBeU[{7=e*Aӵ۔y. TC-.#T%TjL{a\F&dKK +:¸z#kMzq<B36Я{Gʼ:uiT%/U͝% }xC[Q-1&Ayf=)(yAn^(db#=@6m+ʽxֺ=1R`ޘ=pTi9}Ysݏ+]Egr̵s2ҐCMWPAb"({9zYTg+ڹ&ʔ/KRTs =@cwaONe&ez+L H$kTO U<, Ɏr#n&ԕ:5ЫێJJS}LY@Ju%N9Ҟ,f&z`# &"iQyߴaV$DVf$L @zz@zz@@zz@zz@@+Fd_B?GGz@z@zz@z@zz_ƚc]s^e:mUN~ݣr䶪kG}۶=ZMzWVpBЯp땺8GX#ZOKG'sU1#UUWJ=G ލi^R,_Tg !$ZrbD~]AU #ziҎXw>;6j-[fKbKz_O: W~7mU?v94c\ Ƽi:k,zu~dԚ/J/G7oۖ= } 毞Rw?oXГxx_"()7z\ɣ"%;޺_6ÇX>pTsRtBuQYڑ?o2x] llCt[WްdL:26`CԆr Wç*V~2xx,l=UPv3|pٰVb5~Wn DNGWw /'^Ww9kZ*aG6z?(N6A@H]ߍ6l{/|BEYH /Wm>O>=0*X(Y_i:SscDPZƎ\F& lcl,?羫w5'>޾d^{C{5khKjXB>‰݉ D<-\B]R:wnm%"U|R.WYB"IDf|:ABܶC9)Z1B_B}+g>,o{dbLyѫ ejr}IB=Jl2NN'Qc̡O,y-]ZnU\mD"9MR.vQ֓%ukɚW:e3}p!\]RzҮ3N8P,q)oE kިwly0$GӵhBC\} {-gSsoc'?yo}nH'%*>ڿ̡ڑȡEU J,'גndb}p INҿ.*g$BYnSԚ.o<,W].j "}=Ы 7Cӹm7ɤɖ$ca༩lلO6GJ{!9qu g.U=UrV_˹LSb'y$'gTJǁ} ~~z~)H'IaMڶل]f505 CBdW}3vMӻ$5r+9rgOt?0?n ^ߜ1u0.]z}Q5,a+eM}b7FG[zסo:Wapm5 عyJRn2^vۄ^q8 BozIQ>2\[X+ k-%#V\ჱZ5zDMѕ"ڣ%TTǸ/Zx~3 ?IXc6Я{Gʼ:u(~BobeRrSNli=B_̆e%ZKXYҦsUWZOpإ5^g }ɀccJ!a RrԠR:M9d5GUUJ(>ն\[[N"lzXq"AU/{9zYTۑ]!=@0jLDo&gI*:mx3e")T^}_pRfɜl/:VSMge C~W;Yn/$İ3G:-.{G3bR)ִ&,H!=@Ŷa G*pS~GGz@zzGGz@zzzGGzzGGGz@zzGGz@z1@u*z@Gz@ג-u]-r묪韢@ejS^ϴC;rG kMJeJvyPbّW*fWl);Wof` 4/)[r!?u-0mslƩ19q :y–}z,n/?j0bKz_M[@g'׶t1i|1/~v )>!qg,ZC%9B_IB_S]o"}u";_C?R xGd7g[^6r+KJ?3Si+M.P< 3k+٤ˋF,˽axNQ+>bvk cSCzYLYTlѯ=Sѯ-?*9q&:VG9ь.RS"=\expOK=8ћDq MݍlzqwםVAڷl^B/47NJ^1)T0UsjBL_)C9SX 79GݺO᳅*zY2bZC(jO9+=3A.`Y*/?|v*ud\ǯ,] "τ8Bsh-4_5,U;_z2(+ Ktō]gh.OZ_eJ"*X B_qϴ%KGa'WdylKzs҅f^GAŒ+_ =t=FzͰEޅH* qljN|J}2 }=oLY-+BV#}ڙ* C鲅>hW̥\Rk* 3{|UUU/@}/5>B=[蓊/ޒZ`t:Ɍc}bQ?JqK^ް1Q֎>fح`mp>ί,B% g(-BsA*_r1wDm]ǟƘCޘ"/E]͓=rPeYn]NTg;qQ垺K*-blL2^MEukF]!Y_.`ɡ C/8^fk9C 0'kL'0 x4[3,7n2o}}&YnM#jW7w05{&_T7ↁi[fHrI[MQ$'W wj3/jI~+'TJJʍTYn6הqӰE呋|z~4C0ֹk8cz6:aS5BP  =B= =BBLdz@@zz@zGz@@zz@ze(F˙@IENDB`jazzband-django-simple-history-2a2bec9/docs/screens/5_history_list_display.png000066400000000000000000000342431462567636100300670ustar00rootroot00000000000000PNG  IHDRK pHYs  iTXtXML:com.adobe.xmp rF2IDATxoٰ9ʒԴWvjY)XEV8NS ZW&jb?kɲ@|}ff޵KQ,g9sks}c/@ 4 @m:;7_+ay l?_lܩS##J'Fgn==ko{:ֹwGNӠ\FiS#N]_9~GO~3uD mߛ>kA,K JT^Рή'.x4>wk϶ 8^. ڿ?[p46 cA>wfy1xxT|rH(33wEv)7ks *׸l?5snd$8`tW0ekԮG^VQkQ|To|SXL}H]cT3\O!ޗ^}$ofa3yOѠ'G4;&uzՠo>ѐ:>r.T糩[o-Ϯcչ vfF7W'Gґ{8-РF ƞMB>;;2tޫ#sifg7ŧ+NנT7AgF^3UZRny_4=d=SXq3fg͕gkZݶܭhޫ>xL*U!?{b4Mg,=,f7ן'-nmܣ 4A+Fը<A.dO,oRf2c½#CwTI6[IDҦ}eANͮX*+FN5¬LB}cYȸ㉹kUvfy  \2fzdo˗gƃ*C%AAtFGV)ՠWƓQ;W}zׯ.Zie\W=HeU[wGߪɡl rfcC4 SϗgkU~޸5yf;ooӇl Zo}R4M[MiP('wnu5ȟ:yW-3{ ɘOnI,GꙂ 6?Up t6{jk=ؤZ`{3HMjbmР+nA,>59StR_DlLh̹ lEIIVfUٗH{2*HYYQq 齕2K k7H7gG՞N3{,?.$+76;llP_~ddv9 w*SgOG,~<hB޻l{ugrdIEJAc UW >1}r#g&?[~L5A+&G=ɟ׏82|bt#9?<\/nw6>1^E jPtHϯU{4БKU S׎68=ǝǛbq̩#R?o>if z|g>8У+йAhAhAhhAhAhAhAh@74~a B=|20\}WׁVԻsםnA*ͥ{@ziP(e}ÿC\F F_A^K7݋KӠL(oUԠ^*E {Afڙ(mVO8Bq֠ {h]4߃[q;`@&s]Ez 7}KK0TQC!W% $`+zXE_R]A5,.?,y`3TNhhRL\U_K#$E3tMsyaE%huRPW'>Tkn4_8,XzA W3@g?*Mcmq-4v|[M\\Nښa vZq?;غ}WX>7Mv]-^@4(+ܱb2et^?[I\աwA$.jEDu0U3mz6(aj Yjn6H,NO(hnOh#ة')d"YȮ,YmW'؏45i WԑUS𦳋fh{]nia<ꙭO2)f3 d% 7&5 ʝ9y-i^cza3"d 2emՠٚPH}V9AjK*1!p;(ws/V+%}{լSF\<f,W'2̑<0]=)7u5Q*׼tƍж6(/Z@dd٭`]Z8XF{hƕ3W|\]]v-R~.Ӽ֠f, r<)cm9e9Jߟei#甍|īc]P&U`;:dRkOβY/-Mj GLT>N1}P\uui{c2UT g?qoYݬڠNho}OvTR7xtC.#3hGۂXt (_ @ 4 @ cAEhhhPq Ql@(64A/'^}Zl> +} D 4bAh@  '(6OAh>S Z=;V*y jzl)btu-zw{gZoӣ'N.|_XR?؂O<2>iʊ_ٰO~U*< R%Uϖ[KX?RҠ&U{ro \ޞۈ R>]YIV`xYQ`w{A{;?oDsgWsM췝Q)V6J4-M384ڮv% ktqK>C-Px5yw]/#Ӯ`r~ŎVB=F=YJPO+ւӌ Wke/C|RȓVE^wOV^ ig:9jmi?iy;jMa8"Vu&KȨɝWO+r~e6BiQ 泥Rח47>v;-iЬ [rw'GD|~\R[yGC%^x蝉whA+&o?l6ȻðPJӃͯ!^^N'y헢)Еf˅?xbT>_}{'KVje )4w6ﳃw_*kWVr4ȸԻ6BtJC֋O+c5FOqgu~j0cZ7`*F-V4R<#I͟,~\[ΏfM, F?Z~vtK1| `Ѹ~u~kt7y?;ڧA. !sj秽[ղx,A^\H >D韝N{;o7 /A-,Luqy+gѠf3A^/.o\=\)!>/.|~:iƗc可\Р>*muPٛ` :ϗ.,MuK)[ݯKpjPZ$e(? ͫ61-k^[E~!8= 6{Q1 RvnߛBʿN}`iOE"YKpd"P>ky%poL]yOc_߰- !?. bP=^,zS\KKCWkĂڿY ɯ5??&:vӅW夤ELdSF6({;l9a=]xI|\:FR!I!<`S4(<8~:r<//v^,sd6\k~ Lk 1HDz ]=5FR^)GX{5(L ͿxH-MU'J~KhA_He9=M}g3hKy{4`i1Ișb_ N|oEGKĤX0e#KXox4Jz:?-9mg֠@=վliL gǖn^ _?3b!ٓb ;4*ww>=][] ׽t\&[kZ]rc _r?(}n_7'Iz5uCx3a򪡱NgJX܌~!;84Ey^nGo5Fi0yz~2퉔"3 [/Mmd%K}#l5Lkv =S*amm>3<7Xw핱a:=J&.jS*QҶ6HN%:S̿xD_'ΝbNTJiW?1:ɲmz3`tjHkAhOAh>xWi&4}b@s1D 4bA@ hOAh>AhOAK~[)_,v20\}E^U*Cꉮwn ?ՅЋ[qծyYOx~:a`UO 7i$նh,z4(8sXT /e6 U`>?_Y WT!0F/jR]$ dq1^[%[fl׫^eW¹ォ!q16U=u<|Zb޻q.+u\Ft^L=a4H35~fi(|(ҟPA ׶xPI+m;-mkkD1 n9cak],dѓ7-p̵C*$lM5Q@29^T,C۴YŨ`5kniS'^T}@9l=RmI>BpG{q I‚ jײ{KDgKZ_R}GZn$eY' -N>JxXB6Hx CpR<d_(R6H]4r\$GmfaUG WN٠,OAȥ ,_jWZ%_m1PǙ֠~D8(2~ֈVm@lv$ُmL=ic۔cY~qQylF 7cYoeڴ6^H2FV5< ʻcj˳}G3gY9^ʓ6hPcUrr4=d;Rԉglo+H_gHkP@ABXXݹ<eyZK4H[h!^$w]¿NiP6&ml#A2w'B gW\!m=Ml+SNԝbɢ2[/`MaܜUae+~t-ֻ;%Wy#Lm<©k] yԵADgNԇ615@F'b %"֒uJr":O238'm!J5VR:5Rw%۬lT@;xР}>^ =!j3kg[c2VwJ%}QQ R/3ꬖTe+[`G7y{'LIFK.9o&k~EYr[Bf6h_L$NJjN`sDrGaA)v\4(SN1i5 ܆;)\n doRg5hsU^*ciPE5W;j+ywcbGoF4&D2)Ay;֍i.9 R6 vz3@k\)TFj9.`"s\Oڠb+t! iPp c)eD#?r-kEE5HQ5eAtnQeo6B (bٔNZ2@gĶ&E|qj)=Qcf#mpdWK4bS A '(6OPl4h>AhOAh>AAhD Ql@(6A y?$>b)Mhnn_ѧS A B(6Ql4h>A | @ 4 ' 4@Р._,o//8xcq]euhç??5O{̸VD?γQ=oqc#!ĻCל R90MV-+-o%)ϫàHgSO~򅇢ע+4DqE?v`_ J|%x@ f䏋"CQ,4Re;x6Bx'z[AE d,~jƿ gߒT0ۏT )|Z\rjz°qP.GE? :CpT5HiQ"F&~ C#kPlp㔃c~EEA(,j37 IAEfR%zgVd1i!^]i~"ͫ%RT/3>2FI_D_MޭGo..DZ"_EIZ j|>[*aG"#vKOlA~cׇAF %_r$ &)U0"KR^:d@ [RA4˨5H`5Ҏ8oJ~ !}]6 ֳ_)]G*2iq1 '5&TOMbi(|(ҟPA ׶xPI+m;-mkkD1 n9cakeIHТ/JΓ܇6((qSM} cxX̵l[(N] i6dT!s>/A9Q{Gjc|-ɧb^Xh/$ [XpֲACYSA%nq{YEg^ D:(ɋbAlP2lT4@hXXvsXUlm yb5OuK٠iyW[,_jWZ%_m1PǙ֠~dMtM[#2ekEA2ۚ8ƴ\^a.c$oldmBMy`uZ= 5cYoeY5B(M`}d~ +L0]M6d}r{Y5vF6ȸ:A9kLIJnَ-hK>i٠{6 E l Vwb\>*KZK4(gNe,h-ۥ;$-uL.`HWfo#e Aў\dۡ#x0 J,0ٖnXX`d\)]=KeC֣F+k^,+LM0E&"oZՠ|H~ux!Ndk̼l&d\A.N<ڠcApԁNML *]퉪؜v~3E$*ȤKoz}>vl)۷4Ɔ- ڵ 22޵y M'5H-g-?SDԊ9UeQv/0@ Ҡ-4cl=[pMX =fMZo2Af"Ki5ڿS,XZO4,h;Ŏ}ҺSL$$!ֈКYnCSIﰂs5(N75_l| R4W;]{L7*n#F4&D2)Ayv {w Jڠ?5RthTsC~m3oKXGg`4 uWv1hPӷL,˨kY璀^plEą3$8$fv| JKQARw䔖DZRV4s^d8DTSH6ןڠ݂D0@ Nh>9c=bAj JöMAY>dºG| Rz OD=hƱjЮ> 4Q'$+:-8R 2IA"isԏ(esDm{"ОZ!_R7ޣAh!?_MȋQl4 hD 4bA@ hOAh>AhOAh>AhAAAAAAAQl@(6A *A/'^}Zl> 'bhQl4 h>A |bAhOAh>AhҠֵD'] W_ѥAe2p~f ӽ_^]^ԠfˌkE4v;ZG}Uux A~n0<o/ /m7'm.oB=)(Mh0РliEe'-sڏWrHgjzjnD^] c AɃDj73Nj?.j!9dD: pޜԛРPAR ^=ͥJ-^HŐ*ri=0<.Zg҄ǠAA'4jmӄ)D MQ A2`wS̯kYtHŮ)N)YV{KFg~![Fz5||?[3^AUuy ەo khSh R&r&oV &KDZ+hp/J*O1I0}wn%8`-gMmA.}~P _ٳqii0_? ~j@˨5Ȝ`O䱆ܩ)5GhkAeGSǯnFa ȇh- (}=A) 2=A$̉m$heREGEI屯5(U%Ѫڠ"R /qSM} cxݢX̵l۵+NS QW_=1$z}P _rfw A.a;ڋIlPU5[$:[ҔUݙZ8| JH'Y%^ KBqVN Dn;N,gNL;dɓ_*.U,?SDԫKWUjl@[lqijP04hCJ!,CHpjE} ;IXffT.TG2B >nN$wtbE8`)&,oנ~9}E_j+yo ˌ :D񆮤'1 z?$>b)d(6I B B @ VYX6WyBIENDB`jazzband-django-simple-history-2a2bec9/docs/signals.rst000066400000000000000000000040661462567636100234040ustar00rootroot00000000000000Signals ------------------------------------ `django-simple-history` includes signals that help you provide custom behavior when saving a historical record. Arguments passed to the signals include the following: .. glossary:: instance The source model instance being saved history_instance The corresponding history record history_date Datetime of the history record's creation history_change_reason Freetext description of the reason for the change history_user The user that instigated the change using The database alias being used For Many To Many signals you've got the following : .. glossary:: instance The source model instance being saved history_instance The corresponding history record rows (for pre_create) The elements to be bulk inserted into the m2m table created_rows (for post_create) The created elements into the m2m table field The recorded field object To connect the signals to your callbacks, you can use the ``@receiver`` decorator: .. code-block:: python from django.dispatch import receiver from simple_history.signals import ( pre_create_historical_record, post_create_historical_record, pre_create_historical_m2m_records, post_create_historical_m2m_records, ) @receiver(pre_create_historical_record) def pre_create_historical_record_callback(sender, **kwargs): print("Sent before saving historical record") @receiver(post_create_historical_record) def post_create_historical_record_callback(sender, **kwargs): print("Sent after saving historical record") @receiver(pre_create_historical_m2m_records) def pre_create_historical_m2m_records_callback(sender, **kwargs): print("Sent before saving many to many field on historical record") @receiver(post_create_historical_m2m_records) def post_create_historical_m2m_records_callback(sender, **kwargs): print("Sent after saving many to many field on historical record") jazzband-django-simple-history-2a2bec9/docs/user_tracking.rst000066400000000000000000000136351462567636100246060ustar00rootroot00000000000000User Tracking ============= Recording Which User Changed a Model ------------------------------------ There are four documented ways to attach users to a tracked change: 1. Use the ``HistoryRequestMiddleware``. The middleware sets the User instance that made the request as the ``history_user`` on the history table. 2. Use ``simple_history.admin.SimpleHistoryAdmin``. Under the hood, ``SimpleHistoryAdmin`` actually sets the ``_history_user`` on the object to attach the user to the tracked change by overriding the `save_model` method. 3. Assign a user to the ``_history_user`` attribute of the object as described in the `_history_user section`_. 4. Track the user using an explicit ``history_user_id``, which is described in `Manually Track User Model`_. This method is particularly useful when using multiple databases (where your user model lives in a separate database to your historical model), or when using a user that doesn't live within the Django app (i.e. a user model retrieved from an API). .. _`_history_user section`: Using ``_history_user`` to Record Which User Changed a Model ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To denote which user changed a model, assign a ``_history_user`` attribute on your model. For example if you have a ``changed_by`` field on your model that records which user last changed the model, you could create a ``_history_user`` property referencing the ``changed_by`` field: .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey('auth.User') history = HistoricalRecords() @property def _history_user(self): return self.changed_by @_history_user.setter def _history_user(self, value): self.changed_by = value Admin integration requires that you use a ``_history_user.setter`` attribute with your custom ``_history_user`` property (see :doc:`/admin`). Another option for identifying the change user is by providing a function via ``get_user``. If provided it will be called every time that the ``history_user`` needs to be identified with the following key word arguments: * ``instance``: The current instance being modified * ``request``: If using the middleware the current request object will be provided if they are authenticated. This is very helpful when using ``register``: .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey('auth.User') def get_poll_user(instance, **kwargs): return instance.changed_by register(Poll, get_user=get_poll_user) .. _`Manually Track User Model`: Manually Track User Model ~~~~~~~~~~~~~~~~~~~~~~~~~ Although ``django-simple-history`` tracks the ``history_user`` (the user who changed the model) using a django foreign key, there are instances where we might want to track this user but cannot use a Django foreign key. **Note:** If you want to track a custom user model that is still accessible through a Django foreign key, refer to `Change User Model`_. The two most common cases where this feature will be helpful are: 1. You are working on a Django app with multiple databases, and your history table is in a separate database from the user table. 2. The user model that you want to use for ``history_user`` does not live within the Django app, but is only accessible elsewhere (i.e. through an API call). There are three parameters to ``HistoricalRecords`` or ``register`` that facilitate the ability to manually track a ``history_user``. :history_user_id_field: An instance of field (i.e. ``IntegerField(null=True)`` or ``UUIDField(default=uuid.uuid4, null=True)`` that will uniquely identify your user object. This is generally the field type of the primary key on your user object. :history_user_getter: *optional*. A callable that takes the historical instance of the model and returns the ``history_user`` object. The default getter is shown below: .. code-block:: python def _history_user_getter(historical_instance): if historical_instance.history_user_id is None: return None User = get_user_model() try: return User.objects.get(pk=historical_instance.history_user_id) except User.DoesNotExist: return None :history_user_setter: *optional*. A callable that takes the historical instance and the user instance, and sets ``history_user_id`` on the historical instance. The default setter is shown below: .. code-block:: python def _history_user_setter(historical_instance, user): if user is not None: historical_instance.history_user_id = user.pk .. _`Change User Model`: Change User Model ----------------- If you need to use a different user model then ``settings.AUTH_USER_MODEL``, pass in the required model to ``user_model``. Doing this requires ``_history_user`` or ``get_user`` is provided as detailed above. .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class PollUser(models.Model): user_id = models.ForeignKey('auth.User') # Only PollUsers should be modifying a Poll class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey(PollUser) history = HistoricalRecords(user_model=PollUser) @property def _history_user(self): return self.changed_by @_history_user.setter def _history_user(self, value): self.changed_by = value jazzband-django-simple-history-2a2bec9/docs/utils.rst000066400000000000000000000037451462567636100231070ustar00rootroot00000000000000Utils ===== clean_duplicate_history ----------------------- For performance reasons, ``django-simple-history`` always creates an ``HistoricalRecord`` when ``Model.save()`` is called regardless of data having actually changed. If you find yourself with a lot of history duplicates you can schedule the ``clean_duplicate_history`` command .. code-block:: bash $ python manage.py clean_duplicate_history --auto You can use ``--auto`` to clean up duplicates for every model with ``HistoricalRecords`` or enumerate specific models as args. There is also ``-m/--minutes`` to specify how many minutes to go back in history while searching (default checks whole history), so you can schedule, for instance, an hourly cronjob such as .. code-block:: bash $ python manage.py clean_duplicate_history -m 60 --auto You can also use ``--excluded_fields`` to provide a list of fields to be excluded from the duplicate check .. code-block:: bash $ python manage.py clean_duplicate_history --auto --excluded_fields field1 field2 You can use Django's base manager to perform the cleanup over all records, including those that would otherwise be filtered or modified by a custom manager, by using the ``--base-manager`` flag. .. code-block:: bash $ python manage.py clean_duplicate_history --auto --base-manager clean_old_history ----------------------- You may want to remove historical records that have existed for a certain amount of time. If you find yourself with a lot of old history you can schedule the ``clean_old_history`` command .. code-block:: bash $ python manage.py clean_old_history --auto You can use ``--auto`` to remove old historical entries with ``HistoricalRecords`` or enumerate specific models as args. You may also specify a ``--days`` parameter, which indicates how many days of records you want to keep. The default it 30 days, meaning that all records older than 30 days would be removed. .. code-block:: bash $ python manage.py clean_old_history --days 60 --auto jazzband-django-simple-history-2a2bec9/pyproject.toml000066400000000000000000000057401462567636100231760ustar00rootroot00000000000000[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.8" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "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", # DEV: uncomment this when the `pyproject-fmt` pre-commit hook stops removing it #"Programming Language :: Python :: 3.13", ] dynamic = [ "readme", "version", ] dependencies = [ "django>=4.2", ] urls.Changelog = "https://github.com/jazzband/django-simple-history/blob/master/CHANGES.rst" urls.Documentation = "https://django-simple-history.readthedocs.io/en/stable/" urls.Homepage = "https://github.com/jazzband/django-simple-history" urls.Source = "https://github.com/jazzband/django-simple-history" urls.Tracker = "https://github.com/jazzband/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] # Jazzband's release process is limited to 2.2 metadata # - see https://github.com/jazzband/help/issues/360 core-metadata-version = "2.2" packages = [ "simple_history", ] [tool.hatch.build.targets.sdist] # Jazzband's release process is limited to 2.2 metadata # - see https://github.com/jazzband/help/issues/360 core-metadata-version = "2.2" [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", ] [tool.isort] profile = "black" py_version = "38" [tool.coverage.run] parallel = true branch = true source = [ "simple_history", ] [tool.coverage.paths] source = [ "simple_history", ".tox/*/site-packages", ] [tool.coverage.report] show_missing = true skip_covered = true omit = [ "requirements/*", ] jazzband-django-simple-history-2a2bec9/requirements/000077500000000000000000000000001462567636100227775ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/requirements/coverage.txt000066400000000000000000000000351462567636100253310ustar00rootroot00000000000000coverage==7.5.2 toml==0.10.2 jazzband-django-simple-history-2a2bec9/requirements/docs.txt000066400000000000000000000000461462567636100244700ustar00rootroot00000000000000Sphinx==7.3.7 sphinx-rtd-theme==2.0.0 jazzband-django-simple-history-2a2bec9/requirements/lint.txt000066400000000000000000000000521462567636100245030ustar00rootroot00000000000000black==24.4.2 flake8==7.0.0 isort==5.13.2 jazzband-django-simple-history-2a2bec9/requirements/mysql.txt000066400000000000000000000000231462567636100247000ustar00rootroot00000000000000mysqlclient==2.2.4 jazzband-django-simple-history-2a2bec9/requirements/postgres.txt000066400000000000000000000000301462567636100253770ustar00rootroot00000000000000psycopg[binary]==3.1.19 jazzband-django-simple-history-2a2bec9/requirements/test.txt000066400000000000000000000000221462567636100245110ustar00rootroot00000000000000-r ./coverage.txt jazzband-django-simple-history-2a2bec9/requirements/tox.txt000066400000000000000000000000641462567636100243520ustar00rootroot00000000000000-r ./coverage.txt tox==4.15.0 tox-gh-actions==3.2.0 jazzband-django-simple-history-2a2bec9/runtests.py000077500000000000000000000123631462567636100225250ustar00rootroot00000000000000#!/usr/bin/env python import sys from argparse import ArgumentParser from os.path import abspath, dirname, join from shutil import rmtree import django from django.conf import settings from django.test.runner import DiscoverRunner sys.path.insert(0, abspath(dirname(__file__))) media_root = join(abspath(dirname(__file__)), "test_files") rmtree(media_root, ignore_errors=True) installed_apps = [ "simple_history.tests", "simple_history.tests.custom_user", "simple_history.tests.external", "simple_history.registry_tests.migration_test_app", "simple_history", "django.contrib.contenttypes", "django.contrib.auth", "django.contrib.sessions", "django.contrib.admin", "django.contrib.messages", ] class DisableMigrations: def __contains__(self, item): return True def __getitem__(self, item): return None DATABASE_NAME_TO_DATABASE_SETTINGS = { "sqlite3": { "default": { "ENGINE": "django.db.backends.sqlite3", }, "other": {"ENGINE": "django.db.backends.sqlite3"}, }, "postgres": { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "test", "USER": "postgres", "PASSWORD": "postgres", "HOST": "127.0.0.1", "PORT": 5432, }, "other": { "ENGINE": "django.db.backends.postgresql", "NAME": "other", "USER": "postgres", "PASSWORD": "postgres", "HOST": "127.0.0.1", "PORT": 5432, }, }, "mysql": { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "test", "USER": "root", "PASSWORD": "mysql", "HOST": "127.0.0.1", "PORT": 3306, }, "other": { "ENGINE": "django.db.backends.mysql", "NAME": "other", "USER": "root", "PASSWORD": "mysql", "HOST": "127.0.0.1", "PORT": 3306, }, }, "mariadb": { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "test", "USER": "root", "PASSWORD": "mariadb", "HOST": "127.0.0.1", "PORT": 3307, }, "other": { "ENGINE": "django.db.backends.mysql", "NAME": "other", "USER": "root", "PASSWORD": "mariadb", "HOST": "127.0.0.1", "PORT": 3307, }, }, } DEFAULT_DATABASE_NAME = "sqlite3" DEFAULT_SETTINGS = dict( # nosec SECRET_KEY="not a secret", ALLOWED_HOSTS=["localhost"], AUTH_USER_MODEL="custom_user.CustomUser", ROOT_URLCONF="simple_history.tests.urls", MEDIA_ROOT=media_root, STATIC_URL="/static/", INSTALLED_APPS=installed_apps, LOGGING={ "version": 1, "disable_existing_loggers": True, "handlers": { "console": { "class": "logging.StreamHandler", }, }, "root": { "handlers": ["console"], "level": "INFO", }, }, MIGRATION_MODULES=DisableMigrations(), TEMPLATES=[ { "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ] }, } ], 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, ) MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] DEFAULT_SETTINGS["MIDDLEWARE"] = MIDDLEWARE def get_default_settings(*, database_name=DEFAULT_DATABASE_NAME): return { **DEFAULT_SETTINGS, "DATABASES": DATABASE_NAME_TO_DATABASE_SETTINGS[database_name], } def main(): parser = ArgumentParser(description="Run package tests.") parser.add_argument( "--database", action="store", nargs="?", default=DEFAULT_DATABASE_NAME ) parser.add_argument("--failfast", action="store_true") parser.add_argument("--pdb", action="store_true") parser.add_argument("--tag", action="append", nargs="?") namespace = parser.parse_args() if not settings.configured: default_settings = get_default_settings(database_name=namespace.database) settings.configure(**default_settings) django.setup() tags = namespace.tag failures = DiscoverRunner( failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags ).run_tests(["simple_history.tests"]) failures |= DiscoverRunner( failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags ).run_tests(["simple_history.registry_tests"]) sys.exit(failures) if __name__ == "__main__": main() jazzband-django-simple-history-2a2bec9/simple_history/000077500000000000000000000000001462567636100233265ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/__init__.py000066400000000000000000000022551462567636100254430ustar00rootroot00000000000000from importlib import metadata __version__ = metadata.version("django-simple-history") def register( model, app=None, manager_name="history", records_class=None, table_name=None, **records_config, ): """ Create historical model for `model` and attach history manager to `model`. Keyword arguments: app -- App to install historical model into (defaults to model.__module__) manager_name -- class attribute name to use for historical manager records_class -- class to use for history relation (defaults to HistoricalRecords) table_name -- Custom name for history table (defaults to 'APPNAME_historicalMODELNAME') This method should be used as an alternative to attaching an `HistoricalManager` instance directly to `model`. """ from . import models if records_class is None: records_class = models.HistoricalRecords records = records_class(**records_config) records.manager_name = manager_name records.table_name = table_name records.module = app and ("%s.models" % app) or model.__module__ records.cls = model records.add_extra_methods(model) records.finalize(model) jazzband-django-simple-history-2a2bec9/simple_history/admin.py000066400000000000000000000353331462567636100247770ustar00rootroot00000000000000from typing import Any, Sequence 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.auth import get_permission_codename, get_user_model from django.core.exceptions import PermissionDenied 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 from django.utils.html import mark_safe 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" def get_urls(self): """Returns the additional urls used by the Reversion admin.""" urls = super().get_urls() admin_site = self.admin_site opts = self.model._meta info = opts.app_label, opts.model_name history_urls = [ re_path( "^([^/]+)/history/([^/]+)/$", admin_site.admin_view(self.history_form_view), name="%s_%s_simple_history" % info, ) ] return history_urls + urls def history_view(self, request, object_id, extra_context=None): """The 'history' admin view for this model.""" request.current_app = self.admin_site.name model = self.model opts = model._meta app_label = opts.app_label pk_name = opts.pk.attname history = getattr(model, model._meta.simple_history_manager_attribute) object_id = unquote(object_id) 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 = 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 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 record in historical_records: setattr(record, history_list_entry, value_for_entry(record)) self.set_history_delta_changes(request, historical_records) 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), "object_history_list_template": self.object_history_list_template, "historical_records": historical_records, "module_name": capfirst(force_str(opts.verbose_name_plural)), "object": obj, "root_path": getattr(self.admin_site, "root_path", None), "app_label": app_label, "opts": opts, "admin_user_view": admin_user_view, "history_list_display": history_list_display, "revert_disabled": self.revert_disabled(request, obj), } context.update(self.admin_site.each_context(request)) context.update(extra_context or {}) extra_kwargs = {} return self.render_history_view( 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) else: return _("Change history: %s") % force_str(obj) def response_change(self, request, obj): if "_change_history" in request.POST and SIMPLE_HISTORY_EDIT: verbose_name = obj._meta.verbose_name msg = _('The %(name)s "%(obj)s" was changed successfully.') % { "name": force_str(verbose_name), "obj": force_str(obj), } self.message_user( request, "{} - {}".format(msg, _("You may edit it again below")) ) return http.HttpResponseRedirect(request.path) else: return super().response_change(request, obj) def history_form_view(self, request, object_id, version_id, extra_context=None): request.current_app = self.admin_site.name original_opts = self.model._meta model = getattr( self.model, self.model._meta.simple_history_manager_attribute ).model obj = get_object_or_404( model, **{original_opts.pk.attname: object_id, "history_id": version_id} ).instance obj._state.adding = False if not self.has_view_history_or_change_history_permission(request, obj): raise PermissionDenied if SIMPLE_HISTORY_EDIT: change_history = True else: change_history = False if "_change_history" in request.POST and SIMPLE_HISTORY_EDIT: history = get_history_manager_for_model(obj) obj = history.get(pk=version_id).instance formsets = [] form_class = self.get_form(request, obj) if request.method == "POST": form = form_class(request.POST, request.FILES, instance=obj) if form.is_valid(): new_object = self.save_form(request, form, change=True) self.save_model(request, new_object, form, change=True) form.save_m2m() self.log_change( request, new_object, self.construct_change_message(request, form, formsets), ) return self.response_change(request, new_object) else: form = form_class(instance=obj) admin_form = helpers.AdminForm( form, self.get_fieldsets(request, obj), self.prepopulated_fields, self.get_readonly_fields(request, obj), model_admin=self, ) model_name = original_opts.model_name url_triplet = self.admin_site.name, original_opts.app_label, model_name context = { "title": self.history_form_view_title(request, obj), "adminform": admin_form, "object_id": object_id, "original": obj, "is_popup": False, "media": mark_safe(self.media + admin_form.media), "errors": helpers.AdminErrorList(form, formsets), "app_label": original_opts.app_label, "original_opts": original_opts, "changelist_url": reverse("%s:%s_%s_changelist" % url_triplet), "change_url": reverse("%s:%s_%s_change" % url_triplet, args=(obj.pk,)), "history_url": reverse("%s:%s_%s_history" % url_triplet, args=(obj.pk,)), "change_history": change_history, "revert_disabled": self.revert_disabled(request, obj), # Context variables copied from render_change_form "add": False, "change": True, "has_add_permission": self.has_add_permission(request), "has_view_permission": self.has_view_history_permission(request, obj), "has_change_permission": self.has_change_history_permission(request, obj), "has_delete_permission": self.has_delete_permission(request, obj), "has_file_field": True, "has_absolute_url": False, "form_url": "", "opts": model._meta, "content_type_id": self.content_type_model_cls.objects.get_for_model( self.model ).id, "save_as": self.save_as, "save_on_top": self.save_on_top, "root_path": getattr(self.admin_site, "root_path", None), } context.update(self.admin_site.each_context(request)) context.update(extra_context or {}) extra_kwargs = {} return self.render_history_view( request, self.object_history_form_template, context, **extra_kwargs ) def history_form_view_title(self, request, obj): if self.revert_disabled(request, obj): return _("View %s") % force_str(obj) else: return _("Revert %s") % force_str(obj) def render_history_view(self, request, template, context, **kwargs): """Catch call to render, to allow overriding.""" return render(request, template, context, **kwargs) def save_model(self, request, obj, form, change): """Set special model attribute to user for reference after save""" obj._history_user = request.user super().save_model(request, obj, form, change) @property def content_type_model_cls(self): """Returns the ContentType model class.""" return django_apps.get_model("contenttypes.contenttype") def revert_disabled(self, request, obj=None): """If `True`, hides the "Revert" button in the `submit_line.html` template.""" if getattr(settings, "SIMPLE_HISTORY_REVERT_DISABLED", False): return True elif self.has_view_history_permission( request, obj ) and not self.has_change_history_permission(request, obj): return True return False def has_view_permission(self, request, obj=None): return super().has_view_permission(request, obj) def has_change_permission(self, request, obj=None): return super().has_change_permission(request, obj) def has_view_or_change_permission(self, request, obj=None): return self.has_view_permission(request, obj) or self.has_change_permission( request, obj ) def has_view_history_or_change_history_permission(self, request, obj=None): if self.enforce_history_permissions: return self.has_view_history_permission( request, obj ) or self.has_change_history_permission(request, obj) return self.has_view_or_change_permission(request, obj) def has_view_history_permission(self, request, obj=None): if self.enforce_history_permissions: opts_history = get_history_model_for_model(self.model)._meta codename_view_history = get_permission_codename("view", opts_history) return request.user.has_perm( f"{opts_history.app_label}.{codename_view_history}" ) return self.has_view_permission(request, obj) def has_change_history_permission(self, request, obj=None): if self.enforce_history_permissions: opts_history = get_history_model_for_model(self.model)._meta codename_change_history = get_permission_codename("change", opts_history) return request.user.has_perm( f"{opts_history.app_label}.{codename_change_history}" ) return self.has_change_permission(request, obj) @property def enforce_history_permissions(self): return getattr( settings, "SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS", False ) jazzband-django-simple-history-2a2bec9/simple_history/exceptions.py000066400000000000000000000007701462567636100260650ustar00rootroot00000000000000""" django-simple-history exceptions and warnings classes. """ class MultipleRegistrationsError(Exception): """The model has been registered to have history tracking more than once""" pass class NotHistoricalModelError(TypeError): """No related history model found.""" pass class RelatedNameConflictError(Exception): """Related name conflicting with history manager""" pass class AlternativeManagerError(Exception): """Manager does not belong to model""" pass jazzband-django-simple-history-2a2bec9/simple_history/locale/000077500000000000000000000000001462567636100245655ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ar/000077500000000000000000000000001462567636100251675ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ar/LC_MESSAGES/000077500000000000000000000000001462567636100267545ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ar/LC_MESSAGES/django.mo000066400000000000000000000044761462567636100305660ustar00rootroot00000000000000%`ap  Q &+0<7Ht 0*BX`q"";^ r}3  4ENaWl&5U.g8 -     Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2022-11-08 13:54+0300 Last-Translator: Language-Team: Language: ar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5; X-Generator: Poedit 2.4.2 تعديل سجل التغيراتتعديل سجل تغيرات: %sسبب التغيرتغييرتغير من قبلإختر تاريخ من القائمة ادناه.إغلاقتعليقتم انشاءهالتاريخ/الوقتتمت إزالتهسجل التغيراتالرئيسيةفارغعنصراضغط على زر 'تعديل سجل التغيرات' ادناه لتعديل التاريخ.اضغط على زر 'استرجاع' ادناه للاسترجاع لهذه النسخة من العنصر.استرجاعاسترجاع %(verbose_name)sاسترجاع %sتم تعديل %(name)s "%(obj)s" بنجاح.هذا العنصر لا يملك سجل تغييرات.عرض %(verbose_name)sعرض %sعرض سجل تغيرات: %sيمكنك تعديله مجددا ادناهjazzband-django-simple-history-2a2bec9/simple_history/locale/ar/LC_MESSAGES/django.po000066400000000000000000000100151462567636100305530ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-11-08 11:30+0300\n" "PO-Revision-Date: 2022-11-08 13:54+0300\n" "Last-Translator: \n" "Language-Team: \n" "Language: ar\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" "X-Generator: Poedit 2.4.2\n" #: simple_history/admin.py:102 #, python-format msgid "View history: %s" msgstr "عرض سجل تغيرات: %s" #: simple_history/admin.py:104 #, python-format msgid "Change history: %s" msgstr "تعديل سجل تغيرات: %s" #: simple_history/admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "تم تعديل %(name)s \"%(obj)s\" بنجاح." #: simple_history/admin.py:116 msgid "You may edit it again below" msgstr "يمكنك تعديله مجددا ادناه" #: simple_history/admin.py:216 #, python-format msgid "View %s" msgstr "عرض %s" #: simple_history/admin.py:218 #, python-format msgid "Revert %s" msgstr "استرجاع %s" #: simple_history/models.py:552 msgid "Created" msgstr "تم انشاءه" #: simple_history/models.py:552 msgid "Changed" msgstr "تغيير" #: simple_history/models.py:552 msgid "Deleted" 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 " "object." msgstr "إختر تاريخ من القائمة ادناه." #: simple_history/templates/simple_history/object_history.html:16 msgid "This object doesn't have a change history." msgstr "هذا العنصر لا يملك سجل تغييرات." #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "الرئيسية" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "سجل التغيرات" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "عرض %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "استرجاع %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "اضغط على زر 'استرجاع' ادناه للاسترجاع لهذه النسخة من العنصر." #: simple_history/templates/simple_history/object_history_form.html:25 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 "استرجاع" #: simple_history/templates/simple_history/submit_line.html:6 msgid "Change History" msgstr "تعديل سجل التغيرات" #: simple_history/templates/simple_history/submit_line.html:7 msgid "Close" msgstr "إغلاق" jazzband-django-simple-history-2a2bec9/simple_history/locale/cs_CZ/000077500000000000000000000000001462567636100255665ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/cs_CZ/LC_MESSAGES/000077500000000000000000000000001462567636100273535ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/cs_CZ/LC_MESSAGES/django.mo000066400000000000000000000041341462567636100311540ustar00rootroot00000000000000%`ap  Q &+0<7Ht 0*BX`qm   +5P=   DK%q." $!:    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2021-09-20 19:50+0200 Last-Translator: Language-Team: Language: cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3; Historie změnHistorie změn: %sDůvod změnyZměněnoZměnilVyberte datum ze seznamu níže a vraťte se k předchozí verzi tohoto objektu.ZavřítKomentářVytvořenoDatum/časSmazánoHistorieDomůŽádnéObjektChcete-li historii upravit, stiskněte tlačítko 'Změnit historii'Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objektu.Vrátit změnyVrátit %(verbose_name)sVrátit změny: %s%(name)s "%(obj)s" bylo úspěšně změněno.Tento objekt nemá historii změn.Zobrazit %(verbose_name)sZobrazit %sZobrazit historii: %sNíže jej můžete znovu upravitjazzband-django-simple-history-2a2bec9/simple_history/locale/cs_CZ/LC_MESSAGES/django.po000066400000000000000000000074521462567636100311650ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-09-18 21:54+0200\n" "PO-Revision-Date: 2021-09-20 19:50+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n " "<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n" #: simple_history/admin.py:102 #, python-format msgid "View history: %s" msgstr "Zobrazit historii: %s" #: simple_history/admin.py:104 #, python-format msgid "Change history: %s" msgstr "Historie změn: %s" #: simple_history/admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" bylo úspěšně změněno." #: simple_history/admin.py:116 msgid "You may edit it again below" msgstr "Níže jej můžete znovu upravit" #: simple_history/admin.py:216 #, python-format msgid "View %s" msgstr "Zobrazit %s" #: simple_history/admin.py:218 #, python-format msgid "Revert %s" msgstr "Vrátit změny: %s" #: simple_history/models.py:433 msgid "Created" msgstr "Vytvořeno" #: simple_history/models.py:433 msgid "Changed" msgstr "Změněno" #: simple_history/models.py:433 msgid "Deleted" msgstr "Smazáno" #: simple_history/templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Vyberte datum ze seznamu níže a vraťte se k předchozí verzi tohoto objektu." #: simple_history/templates/simple_history/object_history.html:16 msgid "This object doesn't have a change history." msgstr "Tento objekt nemá historii změn." #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Domů" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "Historie" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Zobrazit %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Vrátit %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objektu." #: simple_history/templates/simple_history/object_history_form.html:25 msgid "Press the 'Change History' button below to edit the history." msgstr "Chcete-li historii upravit, stiskněte tlačítko 'Změnit historii'" #: simple_history/templates/simple_history/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" #: simple_history/templates/simple_history/submit_line.html:6 msgid "Change History" msgstr "Historie změn" #: simple_history/templates/simple_history/submit_line.html:7 msgid "Close" msgstr "Zavřít" jazzband-django-simple-history-2a2bec9/simple_history/locale/de/000077500000000000000000000000001462567636100251555ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/de/LC_MESSAGES/000077500000000000000000000000001462567636100267425ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/de/LC_MESSAGES/django.mo000066400000000000000000000036001462567636100305400ustar00rootroot00000000000000  +9 AQL 9H\c {0**A Q [`i   <[Z!/+-&Y    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CommentCreatedDate/timeDeletedHistoryHomeNoneObjectOr press the 'Change History' button to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.You may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2018-11-21 16:31+0100 Last-Translator: Language-Team: Language: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Historie ändernÄnderungshistorie: %sÄnderungsgrundGeändertGeändert vonWählen Sie eine Version des Objektes aus der untenstehenden Liste, um diese wiederherzustellen.KommentarErstelltDatum/UhrzeitGelöschtÄnderungshistorieStartKeine/rObjektOder wählen Sie 'Historie ändern', um diese zu bearbeiten.Klicken Sie unten auf 'Wiederherstellen', um diese Version des Objektes wiederherzustellen.Wiederherstellen%(verbose_name)s wiederherstellen%s wiederherstellen%(name)s "%(obj)s" wurde erfolgreich geändert.Dieses Objekt hat keine Änderungshistorie.Sie können es unten wieder bearbeitenjazzband-django-simple-history-2a2bec9/simple_history/locale/de/LC_MESSAGES/django.po000066400000000000000000000065641462567636100305570ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-11-21 15:58+0100\n" "PO-Revision-Date: 2018-11-21 16:31+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: simple_history/admin.py:78 #, python-format msgid "Change history: %s" msgstr "Änderungshistorie: %s" #: simple_history/admin.py:97 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" wurde erfolgreich geändert." #: simple_history/admin.py:103 msgid "You may edit it again below" msgstr "Sie können es unten wieder bearbeiten" #: simple_history/admin.py:162 #, python-format msgid "Revert %s" msgstr "%s wiederherstellen" #: simple_history/models.py:314 msgid "Created" msgstr "Erstellt" #: simple_history/models.py:314 msgid "Changed" msgstr "Geändert" #: simple_history/models.py:314 msgid "Deleted" msgstr "Gelöscht" #: simple_history/templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Wählen Sie eine Version des Objektes aus der untenstehenden Liste, um diese " "wiederherzustellen." #: simple_history/templates/simple_history/object_history.html:17 msgid "This object doesn't have a change history." msgstr "Dieses Objekt hat keine Änderungshistorie." #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Start" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "Änderungshistorie" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "%(verbose_name)s wiederherstellen" #: simple_history/templates/simple_history/object_history_form.html:21 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "Klicken Sie unten auf 'Wiederherstellen', um diese Version des Objektes " "wiederherzustellen." #: simple_history/templates/simple_history/object_history_form.html:21 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" #: simple_history/templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Historie ändern" jazzband-django-simple-history-2a2bec9/simple_history/locale/fr/000077500000000000000000000000001462567636100251745ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/fr/LC_MESSAGES/000077500000000000000000000000001462567636100267615ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/fr/LC_MESSAGES/django.mo000066400000000000000000000041511462567636100305610ustar00rootroot00000000000000%`ap  Q &+0<7Ht 0*BX`q4 c*   IW$ | 8%-;    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2020-04-25 22:50+0200 Last-Translator: Language-Team: Language: fr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n > 1); X-Generator: Poedit 2.3 Historique des changementsHistorique de changement: %sRaison de la modificationModifiéModifié parChoisissez une date dans la liste ci-dessous pour revenir à une version précédente de cet objet.FermerCommentaireCrééDate/heureEffacéHistoriqueAccueilAucunObjetCliquez sur le bouton 'Historique' ci-dessous pour modifier l'historique.Cliquez sur le bouton 'Rétablir' ci-dessous pour revenir à cette version de l' objet.RétablirRétablir %(verbose_name)sRétablir %sL'objet "%(obj)s" %(name)s a été changé avec succès.Cet objet n'a pas d'historique.Voir %(verbose_name)sVoir %sHistorique de vue: %sVous pouvez le modifier à nouveau ci-dessousjazzband-django-simple-history-2a2bec9/simple_history/locale/fr/LC_MESSAGES/django.po000066400000000000000000000075701462567636100305740ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-04-04 01:50+0300\n" "PO-Revision-Date: 2020-04-25 22:50+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 2.3\n" #: .\simple_history\admin.py:102 #, python-format msgid "View history: %s" msgstr "Historique de vue: %s" #: .\simple_history\admin.py:104 #, python-format msgid "Change history: %s" msgstr "Historique de changement: %s" #: .\simple_history\admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "L'objet \"%(obj)s\" %(name)s a été changé avec succès." #: .\simple_history\admin.py:116 msgid "You may edit it again below" msgstr "Vous pouvez le modifier à nouveau ci-dessous" #: .\simple_history\admin.py:216 #, python-format msgid "View %s" msgstr "Voir %s" #: .\simple_history\admin.py:218 #, python-format msgid "Revert %s" msgstr "Rétablir %s" #: .\simple_history\models.py:433 msgid "Created" msgstr "Créé" #: .\simple_history\models.py:433 msgid "Changed" msgstr "Modifié" #: .\simple_history\models.py:433 msgid "Deleted" msgstr "Effacé" #: .\simple_history\templates\simple_history\object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Choisissez une date dans la liste ci-dessous pour revenir à une version " "précédente de cet objet." #: .\simple_history\templates\simple_history\object_history.html:16 msgid "This object doesn't have a change history." msgstr "Cet objet n'a pas d'historique." #: .\simple_history\templates\simple_history\object_history_form.html:7 msgid "Home" msgstr "Accueil" #: .\simple_history\templates\simple_history\object_history_form.html:11 msgid "History" msgstr "Historique" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Voir %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Rétablir %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "Cliquez sur le bouton 'Rétablir' ci-dessous pour revenir à cette version de " "l' objet." #: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "Press the 'Change History' button below to edit the history." msgstr "" "Cliquez sur le bouton 'Historique' ci-dessous pour modifier l'historique." #: .\simple_history\templates\simple_history\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" #: .\simple_history\templates\simple_history\submit_line.html:6 msgid "Change History" msgstr "Historique des changements" #: .\simple_history\templates\simple_history\submit_line.html:7 msgid "Close" msgstr "Fermer" jazzband-django-simple-history-2a2bec9/simple_history/locale/id/000077500000000000000000000000001462567636100251615ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/id/LC_MESSAGES/000077500000000000000000000000001462567636100267465ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/id/LC_MESSAGES/django.mo000066400000000000000000000040431462567636100305460ustar00rootroot00000000000000%`ap  Q &+0<7Ht 0*BX`qD  Xms|  @H CN j#x+(    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: Kira Language-Team: LANGUAGE Language: id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=1; plural=0; Ubah RiwayatUbah riwayat: %sAlasan perubahanDiubahDiubah olehPilih tanggal dari daftar di bawah ini untuk kembali ke versi sebelumnya dari objek ini.TutupKomentarDibuatTanggal/waktuDihapusRiwayatBerandaTidak adaObjekTekan tombol 'Ubah Riwayat' di bawah ini untuk mengubah riwayat.Tekan tombol 'Kembalikan' di bawah ini untuk kembali ke versi objek ini.KembalikanKembalikan %(verbose_name)sKembalikan %s%(name)s "%(obj)s" berhasil diubah.Objek ini tidak memiliki riwayat perubahan.Lihat %(verbose_name)sLihat %sLihat riwayat: %sAnda dapat mengeditnya lagi di bawah inijazzband-django-simple-history-2a2bec9/simple_history/locale/id/LC_MESSAGES/django.po000066400000000000000000000074561462567636100305640ustar00rootroot00000000000000# Indonesian translation for django-simple-history # Copyright (C) 2023 # This file is distributed under the same license as the django-simple-history package. # Kira , 2023. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-06-30 15:21+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Kira \n" "Language-Team: LANGUAGE \n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" #: .\simple_history\admin.py:102 #, python-format msgid "View history: %s" msgstr "Lihat riwayat: %s" #: .\simple_history\admin.py:104 #, python-format msgid "Change history: %s" msgstr "Ubah riwayat: %s" #: .\simple_history\admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" berhasil diubah." #: .\simple_history\admin.py:116 msgid "You may edit it again below" msgstr "Anda dapat mengeditnya lagi di bawah ini" #: .\simple_history\admin.py:217 #, python-format msgid "View %s" msgstr "Lihat %s" #: .\simple_history\admin.py:219 #, python-format msgid "Revert %s" msgstr "Kembalikan %s" #: .\simple_history\models.py:552 msgid "Created" msgstr "Dibuat" #: .\simple_history\models.py:552 msgid "Changed" msgstr "Diubah" #: .\simple_history\models.py:552 msgid "Deleted" msgstr "Dihapus" #: .\simple_history\templates\simple_history\object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Pilih tanggal dari daftar di bawah ini untuk kembali ke versi sebelumnya " "dari objek ini." #: .\simple_history\templates\simple_history\object_history.html:16 msgid "This object doesn't have a change history." msgstr "Objek ini tidak memiliki riwayat perubahan." #: .\simple_history\templates\simple_history\object_history_form.html:7 msgid "Home" msgstr "Beranda" #: .\simple_history\templates\simple_history\object_history_form.html:11 msgid "History" msgstr "Riwayat" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Lihat %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Kembalikan %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "Tekan tombol 'Kembalikan' di bawah ini untuk kembali ke versi objek ini." #: .\simple_history\templates\simple_history\object_history_form.html:25 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" #: .\simple_history\templates\simple_history\submit_line.html:6 msgid "Change History" msgstr "Ubah Riwayat" #: .\simple_history\templates\simple_history\submit_line.html:7 msgid "Close" msgstr "Tutup" jazzband-django-simple-history-2a2bec9/simple_history/locale/nb/000077500000000000000000000000001462567636100251645ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/nb/LC_MESSAGES/000077500000000000000000000000001462567636100267515ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/nb/LC_MESSAGES/django.mo000066400000000000000000000043061462567636100305530ustar00rootroot00000000000000)  Q.4< DNVlty~<H  *04*ec?Oet { a    1;@FCM\  )+Jv    Change HistoryChange history: %sChange reasonChangedChanged byChangesChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedDeleted %(type_name)sHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2024-04-11 19:34+0200 Last-Translator: Anders Language-Team: Norwegian Bokmål Language: nb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Endre historikkEndringshistorikk: %sEndringsårsakEndretEndret avEndringerVelg en dato fra listen nedenfor for å tilbakestille til en tidligere versjon av dette objektet.LukkKommentarOpprettetDato/tidSlettetSlettet %(type_name)sHistorikkHjemIngenObjektTrykk på 'Endre historikk'-knappen under for å endre historikken.Trykk på 'Tilbakestill'-knappen under for å tilbakestille til denne versjonen av objektet.TilbakestillTilbakestill %(verbose_name)sTilbakestill %s%(name)s «%(obj)s» ble endret.Dette objektet har ingen endringshistorikk.Se %(verbose_name)sSe %sEndringshistorikk: %sDu kan redigere videre nedenforjazzband-django-simple-history-2a2bec9/simple_history/locale/nb/LC_MESSAGES/django.po000066400000000000000000000102141462567636100305510ustar00rootroot00000000000000# Norwegian Bokmål translation for django-simple-history # Copyright (C) 2023 # This file is distributed under the same license as the django-simple-history package. # Anders , 2023. # msgid "" 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: 2024-04-11 19:34+0200\n" "Last-Translator: Anders \n" "Language-Team: Norwegian Bokmål \n" "Language: nb\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" # Dette er en tittel, ikke en handlingsbeskrivelse, så f.eks. # "Se/Vis (endrings)historikk" hadde ikke fungert så bra #: simple_history/admin.py:109 #, python-format msgid "View history: %s" msgstr "Endringshistorikk: %s" #: simple_history/admin.py:111 #, python-format msgid "Change history: %s" msgstr "Endringshistorikk: %s" #: 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:123 msgid "You may edit it again below" msgstr "Du kan redigere videre nedenfor" #: simple_history/admin.py:224 #, python-format msgid "View %s" msgstr "Se %s" #: simple_history/admin.py:226 #, python-format msgid "Revert %s" msgstr "Tilbakestill %s" #: simple_history/models.py:552 msgid "Created" msgstr "Opprettet" #: simple_history/models.py:552 msgid "Changed" msgstr "Endret" #: simple_history/models.py:552 msgid "Deleted" msgstr "Slettet" #: 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 "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Velg en dato fra listen nedenfor for å tilbakestille til en tidligere " "versjon av dette objektet." #: simple_history/templates/simple_history/object_history.html:16 msgid "This object doesn't have a change history." msgstr "Dette objektet har ingen endringshistorikk." #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Hjem" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "Historikk" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Se %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Tilbakestill %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "Trykk på 'Tilbakestill'-knappen under for å tilbakestille til denne " "versjonen av objektet." #: simple_history/templates/simple_history/object_history_form.html:25 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" #: simple_history/templates/simple_history/submit_line.html:6 msgid "Change History" msgstr "Endre historikk" #: simple_history/templates/simple_history/submit_line.html:7 msgid "Close" msgstr "Lukk" jazzband-django-simple-history-2a2bec9/simple_history/locale/pl/000077500000000000000000000000001462567636100252005ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/pl/LC_MESSAGES/000077500000000000000000000000001462567636100267655ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/pl/LC_MESSAGES/django.mo000066400000000000000000000035601462567636100305700ustar00rootroot00000000000000| Q&x 9H6= U0_*qIX kyU   !&D-Ir  4!'&I     Change HistoryChange history: %sChangedChanged byChoose a date from the list below to revert to a previous version of this object.CommentCreatedDate/timeDeletedHistoryHomeNoneObjectOr press the 'Change History' button to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.You may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2017-06-06 15:38+0200 Last-Translator: Language-Team: Language: pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2); X-Generator: Poedit 2.0.2 Historia zmianHistoria zmian: %sZmodyfikowaneZmodyfikowane przezWybierz datę z poniższej listy aby przywrócić poprzednią wersję tego obiektu.KomentarzDodaneData/czasUsunięteHistoriaStrona głównaBrakObiektLub naciśnij przycisk „Historia zmian” aby edytować historię.Naciśnij przycisk „Przywróć” aby przywrócić tę wersję obiektu.PrzywróćPrzywróć %(verbose_name)sPrzywróć %s%(name)s "%(obj)s" został pomyślnie zmodyfikowany.Ten obiekt nie ma historii zmian.Możesz edytować go ponownie poniżejjazzband-django-simple-history-2a2bec9/simple_history/locale/pl/LC_MESSAGES/django.po000066400000000000000000000057351462567636100306010ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2017-06-06 15:32+0200\n" "PO-Revision-Date: 2017-06-06 15:38+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: pl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " "|| n%100>=20) ? 1 : 2);\n" "X-Generator: Poedit 2.0.2\n" #: admin.py:73 #, python-format msgid "Change history: %s" msgstr "Historia zmian: %s" #: admin.py:92 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" został pomyślnie zmodyfikowany." #: admin.py:98 msgid "You may edit it again below" msgstr "Możesz edytować go ponownie poniżej" #: admin.py:156 #, python-format msgid "Revert %s" msgstr "Przywróć %s" #: models.py:211 msgid "Created" msgstr "Dodane" #: models.py:212 msgid "Changed" msgstr "Zmodyfikowane" #: models.py:213 msgid "Deleted" msgstr "Usunięte" #: templates/simple_history/object_history.html:10 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Wybierz datę z poniższej listy aby przywrócić poprzednią wersję tego obiektu." #: templates/simple_history/object_history.html:46 msgid "This object doesn't have a change history." msgstr "Ten obiekt nie ma historii zmian." #: templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Strona główna" #: templates/simple_history/object_history_form.html:11 msgid "History" msgstr "Historia" #: templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Przywróć %(verbose_name)s" #: templates/simple_history/object_history_form.html:21 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "Naciśnij przycisk „Przywróć” aby przywrócić tę wersję obiektu." #: templates/simple_history/object_history_form.html:21 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óć" #: templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Historia zmian" jazzband-django-simple-history-2a2bec9/simple_history/locale/pt_BR/000077500000000000000000000000001462567636100255735ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001462567636100273605ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/pt_BR/LC_MESSAGES/django.mo000066400000000000000000000035361462567636100311660ustar00rootroot00000000000000| Q&x 9H6= U0_*: / P[^j    H IU +5-0     Change HistoryChange history: %sChangedChanged byChoose a date from the list below to revert to a previous version of this object.CommentCreatedDate/timeDeletedHistoryHomeNoneObjectOr press the 'Change History' button to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.You may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2017-06-01 15:47-0300 Last-Translator: Language-Team: Language: pt_BR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n > 1); X-Generator: Poedit 1.8.11 Histórico de ModificaçõesHistórico de modificações: %sModificadoModificado porEscolha a data desejada na lista a seguir para reverter as modificações feitas nesse objeto.ComentárioCriadoData/horaExcluídoHistóricoInício-ObjetoOu clique em 'Histórico de Modificações' para modificar o histórico.Clique em 'Reverter' para reverter as modificações feitas nesse objeto.ReverterReverter %(verbose_name)sReverter %s%(name)s "%(obj)s" modificados com sucesso.Esse objeto não tem um histórico de modificações.Você pode fazer novas modificações abaixo:jazzband-django-simple-history-2a2bec9/simple_history/locale/pt_BR/LC_MESSAGES/django.po000066400000000000000000000064111462567636100311640ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2017-06-01 15:47-0300\n" "PO-Revision-Date: 2017-06-01 15:47-0300\n" "Last-Translator: \n" "Language-Team: \n" "Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 1.8.11\n" #: simple_history/admin.py:73 #, python-format msgid "Change history: %s" msgstr "Histórico de modificações: %s" #: simple_history/admin.py:92 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" modificados com sucesso." #: simple_history/admin.py:98 msgid "You may edit it again below" msgstr "Você pode fazer novas modificações abaixo:" #: simple_history/admin.py:156 #, python-format msgid "Revert %s" msgstr "Reverter %s" #: simple_history/models.py:209 msgid "Created" msgstr "Criado" #: simple_history/models.py:210 msgid "Changed" msgstr "Modificado" #: simple_history/models.py:211 msgid "Deleted" msgstr "Excluído" #: simple_history/templates/simple_history/object_history.html:10 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." 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:46 msgid "This object doesn't have a change history." msgstr "Esse objeto não tem um histórico de modificações." #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Início" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "Histórico" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Reverter %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:21 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "Clique em 'Reverter' para reverter as modificações feitas nesse objeto." #: simple_history/templates/simple_history/object_history_form.html:21 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" #: simple_history/templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Histórico de Modificações" jazzband-django-simple-history-2a2bec9/simple_history/locale/ru_RU/000077500000000000000000000000001462567636100256215ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ru_RU/LC_MESSAGES/000077500000000000000000000000001462567636100274065ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ru_RU/LC_MESSAGES/django.mo000066400000000000000000000044751462567636100312170ustar00rootroot00000000000000  +9 AQL 9H\c {0*%! o )%O<kHK    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CommentCreatedDate/timeDeletedHistoryHomeNoneObjectOr press the 'Change History' button to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.You may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2021-10-14 14:05+0300 Last-Translator: Language-Team: Language: ru_RU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2); X-Generator: Poedit 3.0 Изменить записьИстория изменений: %sПричина измененияИзмененоИзмененоВыберите дату из списка ниже, чтобы вернуться к предыдущей версии этого объекта.КомментарийСозданоДата/времяУдаленоИсторияГлавнаяNoneОбъектИли нажмите кнопку 'Изменить запись', чтобы изменить историю.Нажмите кнопку 'Восстановить' ниже, чтобы вернуться к этой версии объекта.ВосстановитьВосстановить %(verbose_name)sВосстановить %s%(name)s "%(obj)s" было успешно изменено.Этот объект не имеет истории изменений.Вы можете отредактировать его снова нижеjazzband-django-simple-history-2a2bec9/simple_history/locale/ru_RU/LC_MESSAGES/django.po000066400000000000000000000067431462567636100312220ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-10-10 16:47+0300\n" "PO-Revision-Date: 2021-10-14 14:05+0300\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" "%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "X-Generator: Poedit 3.0\n" #: admin.py:77 #, python-format msgid "Change history: %s" msgstr "История изменений: %s" #: admin.py:96 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" было успешно изменено." #: admin.py:102 msgid "You may edit it again below" msgstr "Вы можете отредактировать его снова ниже" #: admin.py:160 #, python-format msgid "Revert %s" msgstr "Восстановить %s" #: models.py:304 msgid "Created" msgstr "Создано" #: models.py:305 msgid "Changed" msgstr "Изменено" #: models.py:306 msgid "Deleted" msgstr "Удалено" #: templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Выберите дату из списка ниже, чтобы вернуться к предыдущей версии этого " "объекта." #: templates/simple_history/object_history.html:17 msgid "This object doesn't have a change history." msgstr "Этот объект не имеет истории изменений." #: templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Главная" #: templates/simple_history/object_history_form.html:11 msgid "History" msgstr "История" #: templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Восстановить %(verbose_name)s" #: templates/simple_history/object_history_form.html:21 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "Нажмите кнопку 'Восстановить' ниже, чтобы вернуться к этой версии объекта." #: templates/simple_history/object_history_form.html:21 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 "Восстановить" #: templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Изменить запись" jazzband-django-simple-history-2a2bec9/simple_history/locale/ur/000077500000000000000000000000001462567636100252135ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ur/LC_MESSAGES/000077500000000000000000000000001462567636100270005ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/ur/LC_MESSAGES/django.mo000066400000000000000000000047711462567636100306100ustar00rootroot00000000000000%`ap  Q &+0<7Ht 0*BX`q#' :    5@G Yf/!E07 h   F     Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history Report-Msgid-Bugs-To: PO-Revision-Date: 2023-07-02 11:50+0800 Last-Translator: Language-Team: Language: ur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); تاریخ کو تبدیل کریںتاریخ کو تبدیل کریں: %sتبدیلی کا سبببدل گیاکی طرف سے تبدیلاس آبجیکٹ کے پچھلے ورژن پر واپس جانے کے لیے نیچے دی گئی فہرست میں سے ایک تاریخ کا انتخاب کریں۔بند کریںتبصرہبنایاتاریخ/وقتحذف کر دیا گیاتاریخگھرکوئی نہیںآبجیکٹتاریخ میں ترمیم کرنے کے لیے نیچے دیے گئے 'تاریخ کو تبدیل کریں' کے بٹن کو دبائیں.آبجیکٹ کے اس ورژن پر واپس جانے کے لیے نیچے 'تبدیلی واپس کریں' کے بٹن کو دبائیں.تبدیلی واپس کریںتبدیلی واپس کریں %(verbose_name)sتبدیلی واپس کریں %s%(name)s "%(obj)s" کامیابی سے تبدیل کر دیا گیا.اس آبجیکٹ کی تاریخ نہیں ہے.دیکھیں %(verbose_name)sدیکھیں %sتاریخ دیکھیں: %sآپ نیچے اس میں دوبارہ ترمیم کر سکتے ہیںjazzband-django-simple-history-2a2bec9/simple_history/locale/ur/LC_MESSAGES/django.po000066400000000000000000000103751462567636100306100ustar00rootroot00000000000000# Urdu translation for django-simple-history # Copyright (C) 2023 # This file is distributed under the same license as the django-simple-history package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: django-simple-history\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-07-02 11:10+0800\n" "PO-Revision-Date: 2023-07-02 11:50+0800\n" "Last-Translator: \n" "Language-Team: \n" "Language: ur\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: .\simple_history\admin.py:102 #, python-format msgid "View history: %s" msgstr "تاریخ دیکھیں: %s" #: .\simple_history\admin.py:104 #, python-format msgid "Change history: %s" msgstr "تاریخ کو تبدیل کریں: %s" #: .\simple_history\admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" کامیابی سے تبدیل کر دیا گیا." #: .\simple_history\admin.py:116 msgid "You may edit it again below" msgstr "آپ نیچے اس میں دوبارہ ترمیم کر سکتے ہیں" #: .\simple_history\admin.py:217 #, python-format msgid "View %s" msgstr "دیکھیں %s" #: .\simple_history\admin.py:219 #, python-format msgid "Revert %s" msgstr "تبدیلی واپس کریں %s" #: .\simple_history\models.py:552 msgid "Created" msgstr "بنایا" #: .\simple_history\models.py:552 msgid "Changed" msgstr "بدل گیا" #: .\simple_history\models.py:552 msgid "Deleted" 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 " "object." msgstr "" "اس آبجیکٹ کے پچھلے ورژن پر واپس جانے کے لیے نیچے دی گئی فہرست میں سے ایک تاریخ کا انتخاب کریں۔" #: .\simple_history\templates\simple_history\object_history.html:16 msgid "This object doesn't have a change history." msgstr "اس آبجیکٹ کی تاریخ نہیں ہے." #: .\simple_history\templates\simple_history\object_history_form.html:7 msgid "Home" msgstr "گھر" #: .\simple_history\templates\simple_history\object_history_form.html:11 msgid "History" msgstr "تاریخ" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "دیکھیں %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "تبدیلی واپس کریں %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "" "آبجیکٹ کے اس ورژن پر واپس جانے کے لیے نیچے 'تبدیلی واپس کریں' کے بٹن کو دبائیں." #: .\simple_history\templates\simple_history\object_history_form.html:25 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 "تبدیلی واپس کریں" #: .\simple_history\templates\simple_history\submit_line.html:6 msgid "Change History" msgstr "تاریخ کو تبدیل کریں" #: .\simple_history\templates\simple_history\submit_line.html:7 msgid "Close" msgstr "بند کریں" jazzband-django-simple-history-2a2bec9/simple_history/locale/zh_Hans/000077500000000000000000000000001462567636100261575ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/zh_Hans/LC_MESSAGES/000077500000000000000000000000001462567636100277445ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/locale/zh_Hans/LC_MESSAGES/django.mo000066400000000000000000000040701462567636100315440ustar00rootroot00000000000000%`ap  Q &+0<7Ht 0*BX`qr * 7 AQK     ?<2ov %$     Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object.RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: django-simple-history VERSION Report-Msgid-Bugs-To: PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: Peihao Ren Language-Team: Simplified Chinese Language: zh_Hans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=1; plural=0; 修改历史记录修改历史记录: %s修改原因已修改修改人从下面的列表中选择一个日期以还原到该记录对象的先前版本关闭备注已创建日期/时间已删除历史记录主页无记录对象按下面的“修改历史记录”按钮编辑历史记录。按下面的“还原”按钮还原记录到当前版本。还原还原 %(verbose_name)s还原 %s%(name)s "%(obj)s" 已成功修改。该记录对象没有修改的记录查看 %(verbose_name)s查看 %s查看历史记录: %s你可在下方再次编辑jazzband-django-simple-history-2a2bec9/simple_history/locale/zh_Hans/LC_MESSAGES/django.po000066400000000000000000000074661462567636100315630ustar00rootroot00000000000000# Simplified Chinese translation for django-simple-history # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the django-simple-history package. # Peihao Ren , 2023. # msgid "" msgstr "" "Project-Id-Version: django-simple-history VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-11-30 22:12+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Peihao Ren \n" "Language-Team: Simplified Chinese \n" "Language: zh_Hans\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" #: simple_history/admin.py:102 #, python-format msgid "View history: %s" msgstr "查看历史记录: %s" #: simple_history/admin.py:104 #, python-format msgid "Change history: %s" msgstr "修改历史记录: %s" #: simple_history/admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" 已成功修改。" #: simple_history/admin.py:116 msgid "You may edit it again below" msgstr "你可在下方再次编辑" #: simple_history/admin.py:217 #, python-format msgid "View %s" msgstr "查看 %s" #: simple_history/admin.py:219 #, python-format msgid "Revert %s" msgstr "还原 %s" #: simple_history/models.py:552 msgid "Created" msgstr "已创建" #: simple_history/models.py:552 msgid "Changed" msgstr "已修改" #: simple_history/models.py:552 msgid "Deleted" 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 " "object." msgstr "从下面的列表中选择一个日期以还原到该记录对象的先前版本" #: simple_history/templates/simple_history/object_history.html:16 msgid "This object doesn't have a change history." msgstr "该记录对象没有修改的记录" #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "主页" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "历史记录" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "查看 %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "还原 %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object." msgstr "按下面的“还原”按钮还原记录到当前版本。" #: simple_history/templates/simple_history/object_history_form.html:25 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 "还原" #: simple_history/templates/simple_history/submit_line.html:6 msgid "Change History" msgstr "修改历史记录" #: simple_history/templates/simple_history/submit_line.html:7 msgid "Close" msgstr "关闭" jazzband-django-simple-history-2a2bec9/simple_history/management/000077500000000000000000000000001462567636100254425ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/management/__init__.py000066400000000000000000000000001462567636100275410ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/management/commands/000077500000000000000000000000001462567636100272435ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/management/commands/__init__.py000066400000000000000000000000001462567636100313420ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/management/commands/clean_duplicate_history.py000066400000000000000000000113551462567636100345170ustar00rootroot00000000000000from django.db import transaction from django.utils import timezone from ... import utils from . import populate_history class Command(populate_history.Command): args = "" help = ( "Scans HistoricalRecords for identical sequencial entries " "(duplicates) in a model and deletes them." ) DONE_CLEANING_FOR_MODEL = "Removed {count} historical records for {model}\n" def add_arguments(self, parser): parser.add_argument("models", nargs="*", type=str) parser.add_argument( "--auto", action="store_true", dest="auto", default=False, help="Automatically search for models with the HistoricalRecords field " "type", ) parser.add_argument( "-d", "--dry", action="store_true", help="Dry (test) run only, no changes" ) parser.add_argument( "-m", "--minutes", type=int, help="Only search the last MINUTES of history" ) parser.add_argument( "--excluded_fields", nargs="+", help="List of fields to be excluded from the diff_against check", ) parser.add_argument( "--base-manager", action="store_true", default=False, help="Use Django's base manager to handle all records stored in the" " database, including those that would otherwise be filtered or modified" " by a custom manager.", ) def handle(self, *args, **options): self.verbosity = options["verbosity"] self.excluded_fields = options.get("excluded_fields") self.base_manager = options.get("base_manager") to_process = set() model_strings = options.get("models", []) or args if model_strings: for model_pair in self._handle_model_list(*model_strings): to_process.add(model_pair) elif options["auto"]: to_process = self._auto_models() else: self.log(self.COMMAND_HINT) self._process(to_process, date_back=options["minutes"], dry_run=options["dry"]) def _process(self, to_process, date_back=None, dry_run=True): if date_back: stop_date = timezone.now() - timezone.timedelta(minutes=date_back) else: stop_date = None for model, history_model in to_process: m_qs = history_model.objects if stop_date: m_qs = m_qs.filter(history_date__gte=stop_date) if self.verbosity >= 2: found = m_qs.count() self.log(f"{model} has {found} historical entries", 2) if not m_qs.exists(): continue # Break apart the query so we can add additional filtering if self.base_manager: model_query = model._base_manager.all() else: model_query = model._default_manager.all() # If we're provided a stop date take the initial hit of getting the # filtered records to iterate over if stop_date: model_query = model_query.filter( pk__in=(m_qs.values_list(model._meta.pk.name).distinct()) ) for o in model_query.iterator(): self._process_instance(o, model, stop_date=stop_date, dry_run=dry_run) def _process_instance(self, instance, model, stop_date=None, dry_run=True): entries_deleted = 0 history = utils.get_history_manager_for_model(instance) o_qs = history.all() if stop_date: # to compare last history match extra_one = o_qs.filter(history_date__lte=stop_date).first() o_qs = o_qs.filter(history_date__gte=stop_date) else: extra_one = None with transaction.atomic(): # ordering is ('-history_date', '-history_id') so this is ok f1 = o_qs.first() if not f1: return for f2 in o_qs[1:]: entries_deleted += self._check_and_delete(f1, f2, dry_run) f1 = f2 if extra_one: entries_deleted += self._check_and_delete(f1, extra_one, dry_run) self.log( self.DONE_CLEANING_FOR_MODEL.format(model=model, count=entries_deleted) ) def log(self, message, verbosity_level=1): if self.verbosity >= verbosity_level: self.stdout.write(message) def _check_and_delete(self, entry1, entry2, dry_run=True): delta = entry1.diff_against(entry2, excluded_fields=self.excluded_fields) if not delta.changed_fields: if not dry_run: entry1.delete() return 1 return 0 jazzband-django-simple-history-2a2bec9/simple_history/management/commands/clean_old_history.py000066400000000000000000000046371462567636100333300ustar00rootroot00000000000000from django.db import transaction from django.utils import timezone from ... import models, utils from ...exceptions import NotHistoricalModelError from . import populate_history class Command(populate_history.Command): args = "" help = "Scans HistoricalRecords for old entries " "and deletes them." DONE_CLEANING_FOR_MODEL = "Removed {count} historical records for {model}\n" def add_arguments(self, parser): parser.add_argument("models", nargs="*", type=str) parser.add_argument( "--auto", action="store_true", dest="auto", default=False, help="Automatically search for models with the HistoricalRecords field " "type", ) parser.add_argument( "--days", help="Only Keep the last X Days of history, default is 30", dest="days", type=int, default=30, ) parser.add_argument( "-d", "--dry", action="store_true", help="Dry (test) run only, no changes" ) def handle(self, *args, **options): self.verbosity = options["verbosity"] to_process = set() model_strings = options.get("models", []) or args if model_strings: for model_pair in self._handle_model_list(*model_strings): to_process.add(model_pair) elif options["auto"]: to_process = self._auto_models() else: self.log(self.COMMAND_HINT) self._process(to_process, days_back=options["days"], dry_run=options["dry"]) def _process(self, to_process, days_back=None, dry_run=True): start_date = timezone.now() - timezone.timedelta(days=days_back) for model, history_model in to_process: history_model_manager = history_model.objects history_model_manager = history_model_manager.filter( history_date__lt=start_date ) found = history_model_manager.count() self.log(f"{model} has {found} old historical entries", 2) if not found: continue if not dry_run: history_model_manager.delete() self.log(self.DONE_CLEANING_FOR_MODEL.format(model=model, count=found)) def log(self, message, verbosity_level=1): if self.verbosity >= verbosity_level: self.stdout.write(message) jazzband-django-simple-history-2a2bec9/simple_history/management/commands/populate_history.py000066400000000000000000000140651462567636100332350ustar00rootroot00000000000000from django.apps import apps from django.core.management.base import BaseCommand, CommandError from ... import models, utils from ...exceptions import NotHistoricalModelError get_model = apps.get_model class Command(BaseCommand): args = "" help = ( "Populates the corresponding HistoricalRecords field with " "the current state of all instances in a model" ) COMMAND_HINT = "Please specify a model or use the --auto option" MODEL_NOT_FOUND = "Unable to find model" MODEL_NOT_HISTORICAL = "No history model found" NO_REGISTERED_MODELS = "No registered models were found\n" START_SAVING_FOR_MODEL = "Saving historical records for {model}\n" DONE_SAVING_FOR_MODEL = "Finished saving historical records for {model}\n" EXISTING_HISTORY_FOUND = "Existing history found, skipping model" INVALID_MODEL_ARG = "An invalid model was specified" def add_arguments(self, parser): super().add_arguments(parser) parser.add_argument("models", nargs="*", type=str) parser.add_argument( "--auto", action="store_true", dest="auto", default=False, help="Automatically search for models with the HistoricalRecords field " "type", ) parser.add_argument( "--batchsize", action="store", dest="batchsize", default=200, type=int, help="Set a custom batch size when bulk inserting historical records.", ) def handle(self, *args, **options): self.verbosity = options["verbosity"] to_process = set() model_strings = options.get("models", []) or args if model_strings: for model_pair in self._handle_model_list(*model_strings): to_process.add(model_pair) elif options["auto"]: to_process = self._auto_models() else: if self.verbosity >= 1: self.stdout.write(self.COMMAND_HINT) self._process(to_process, batch_size=options["batchsize"]) def _auto_models(self): to_process = set() for model in models.registered_models.values(): try: # avoid issues with multi-table inheritance history_model = utils.get_history_model_for_model(model) except NotHistoricalModelError: continue to_process.add((model, history_model)) if not to_process: if self.verbosity >= 1: self.stdout.write(self.NO_REGISTERED_MODELS) return to_process def _handle_model_list(self, *args): failing = False for natural_key in args: try: model, history = self._model_from_natural_key(natural_key) except ValueError as e: failing = True self.stderr.write(f"{e}\n") else: if not failing: yield (model, history) if failing: raise CommandError(self.INVALID_MODEL_ARG) def _model_from_natural_key(self, natural_key): try: app_label, model = natural_key.split(".", 1) except ValueError: model = None else: try: model = get_model(app_label, model) except LookupError: model = None if not model: msg = self.MODEL_NOT_FOUND + f" < {natural_key} >\n" raise ValueError(msg) try: history_model = utils.get_history_model_for_model(model) except NotHistoricalModelError: msg = self.MODEL_NOT_HISTORICAL + f" < {natural_key} >\n" raise ValueError(msg) return model, history_model def _bulk_history_create(self, model, batch_size): """Save a copy of all instances to the historical model. :param model: Model you want to bulk create :param batch_size: number of models to create at once. :return: """ instances = [] history = utils.get_history_manager_for_model(model) if self.verbosity >= 1: self.stdout.write( "Starting bulk creating history models for {} instances {}-{}".format( model, 0, batch_size ) ) iterator_kwargs = {"chunk_size": batch_size} for index, instance in enumerate( model._default_manager.iterator(**iterator_kwargs) ): # Can't Just pass batch_size to bulk_create as this can lead to # Out of Memory Errors as we load too many models into memory after # creating them. So we only keep batch_size worth of models in # historical_instances and clear them after we hit batch_size if index % batch_size == 0: history.bulk_history_create(instances, batch_size=batch_size) instances = [] if self.verbosity >= 1: self.stdout.write( "Finished bulk creating history models for {} " "instances {}-{}, starting next {}".format( model, index - batch_size, index, batch_size ) ) instances.append(instance) # create any we didn't get in the last loop if instances: history.bulk_history_create(instances, batch_size=batch_size) def _process(self, to_process, batch_size): for model, history_model in to_process: if history_model.objects.exists(): self.stderr.write( "{msg} {model}\n".format( msg=self.EXISTING_HISTORY_FOUND, model=model ) ) continue if self.verbosity >= 1: self.stdout.write(self.START_SAVING_FOR_MODEL.format(model=model)) self._bulk_history_create(model, batch_size) if self.verbosity >= 1: self.stdout.write(self.DONE_SAVING_FOR_MODEL.format(model=model)) jazzband-django-simple-history-2a2bec9/simple_history/manager.py000066400000000000000000000246151462567636100253220ustar00rootroot00000000000000from django.conf import settings from django.db import connection, models from django.db.models import OuterRef, QuerySet, Subquery from django.utils import timezone from simple_history.utils import ( get_app_model_primary_key_name, get_change_reason_from_object, ) # when converting a historical record to an instance, this attribute is added # to the instance so that code can reverse the instance to its historical record SIMPLE_HISTORY_REVERSE_ATTR_NAME = "_history" 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 """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._as_instances = False self._as_of = None self._pk_attr = self.model.instance_type._meta.pk.attname def as_instances(self): """ 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="-") result._as_instances = True else: result = self._clone() return result def filter(self, *args, **kwargs): """ If a `pk` filter arrives and the queryset is returning instances then the caller actually wants to filter based on the original type's primary key, and not the history_id (historical record's primary key); this happens frequently with DRF. """ if self._as_instances and "pk" in kwargs: kwargs[self._pk_attr] = kwargs.pop("pk") return super().filter(*args, **kwargs) def latest_of_each(self): """ Ensures results in the queryset are the latest historical record for each primary key. Deletions are not removed. Returns a queryset. """ # 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 def _select_related_history_tracked_objs(self): """ 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): 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): super()._fetch_all() self._instanceize() def _instanceize(self): """ 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 historical objects to be converted. """ if ( self._result_cache and self._as_instances and isinstance(self._result_cache[0], self.model) ): self._result_cache = [item.instance for item in self._result_cache] for item in self._result_cache: historic = getattr(item, SIMPLE_HISTORY_REVERSE_ATTR_NAME) setattr(historic, "_as_of", self._as_of) class HistoryManager(models.Manager): def __init__(self, model, instance=None): super().__init__() self.model = model self.instance = instance def get_super_queryset(self): return super().get_queryset() def get_queryset(self): qs = self.get_super_queryset() if self.instance is None: return qs key_name = get_app_model_primary_key_name(self.instance) return self.get_super_queryset().filter(**{key_name: self.instance.pk}) def most_recent(self): """ Returns the most recent copy of the instance available in the history. """ if not self.instance: raise TypeError( "Can't use most_recent() without a {} instance.".format( self.model._meta.object_name ) ) tmp = [] for field in self.model.tracked_fields: if isinstance(field, models.ForeignKey): tmp.append(field.name + "_id") else: tmp.append(field.name) fields = tuple(tmp) try: values = self.get_queryset().values(*fields)[0] except IndexError: raise self.instance.DoesNotExist( "%s has no historical record." % self.instance._meta.object_name ) return self.instance.__class__(**values) def as_of(self, date): """ Get a snapshot as of a specific date. When this is used on an instance, it will return the instance based on the specific date. If the instance did not exist yet, or had been deleted, then a DoesNotExist error is railed. When this is used on a model's history manager, the resulting queryset will locate the most recent historical record before the specified date for each primary key, generating instances. If the most recent historical record is a deletion, that instance is dropped from the result. A common usage pattern for querying is to accept an optional time point `date` and then use: `qs = .history.as_of(date) if date else .objects` after which point one can add filters, values - anything a normal queryset would support. To retrieve historical records, query the model's history directly; for example: `qs = .history.filter(history_date__lte=date, pk=...)` To retrieve the most recent historical record, including deletions, you could then use: `qs = qs.latest_of_each()` """ queryset = self.get_queryset().filter(history_date__lte=date) if not self.instance: if isinstance(queryset, HistoricalQuerySet): queryset._as_of = date queryset = queryset.latest_of_each().as_instances() return queryset try: # historical records are sorted in reverse chronological order history_obj = queryset[0] except IndexError: raise self.instance.DoesNotExist( "%s had not yet been created." % self.instance._meta.object_name ) if history_obj.history_type == "-": raise self.instance.DoesNotExist( "%s had already been deleted." % self.instance._meta.object_name ) result = history_obj.instance historic = getattr(result, SIMPLE_HISTORY_REVERSE_ATTR_NAME) setattr(historic, "_as_of", date) return result def bulk_history_create( self, objs, batch_size=None, update=False, default_user=None, default_change_reason="", default_date=None, custom_historical_attrs=None, ): """ Bulk create the history for the objects specified by objs. If called by bulk_update_with_history, use the update boolean and save the history_type accordingly. """ if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): return history_type = "+" if update: history_type = "~" historical_instances = [] for instance in objs: history_user = getattr( instance, "_history_user", default_user or self.model.get_default_history_user(instance), ) row = self.model( history_date=getattr( instance, "_history_date", default_date or timezone.now() ), history_user=history_user, history_change_reason=get_change_reason_from_object(instance) or default_change_reason, history_type=history_type, **{ field.attname: getattr(instance, field.attname) for field in self.model.tracked_fields }, **(custom_historical_attrs or {}), ) if hasattr(self.model, "history_relation"): row.history_relation_id = instance.pk historical_instances.append(row) return self.model.objects.bulk_create( historical_instances, batch_size=batch_size ) class HistoryDescriptor: def __init__(self, model, manager=HistoryManager, queryset=HistoricalQuerySet): self.model = model self.queryset_class = queryset self.manager_class = manager def __get__(self, instance, owner): return self.manager_class.from_queryset(self.queryset_class)( self.model, instance ) jazzband-django-simple-history-2a2bec9/simple_history/middleware.py000066400000000000000000000020731462567636100260170ustar00rootroot00000000000000from contextlib import contextmanager from asgiref.sync import iscoroutinefunction from django.utils.decorators import sync_and_async_middleware from .models import HistoricalRecords @contextmanager def _context_manager(request): HistoricalRecords.context.request = request try: yield None finally: try: del HistoricalRecords.context.request except AttributeError: pass @sync_and_async_middleware def HistoryRequestMiddleware(get_response): """Expose request to HistoricalRecords. This middleware sets request as a local context/thread variable, making it available to the model-level utilities to allow tracking of the authenticated user making a change. """ if iscoroutinefunction(get_response): async def middleware(request): with _context_manager(request): return await get_response(request) else: def middleware(request): with _context_manager(request): return get_response(request) return middleware jazzband-django-simple-history-2a2bec9/simple_history/models.py000066400000000000000000001275251462567636100251770ustar00rootroot00000000000000import copy import importlib import uuid import warnings from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Sequence, Type, Union import django from django.apps import apps from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.db import models from django.db.models import ManyToManyField from django.db.models.fields.proxy import OrderWrt from django.db.models.fields.related import ForeignKey from django.db.models.fields.related_descriptors import ( ForwardManyToOneDescriptor, ReverseManyToOneDescriptor, create_reverse_many_to_one_manager, ) from django.db.models.query import QuerySet from django.db.models.signals import m2m_changed from django.forms.models import model_to_dict from django.urls import reverse from django.utils import timezone from django.utils.encoding import smart_str from django.utils.functional import cached_property from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ from . import exceptions, utils from .manager import ( SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoricalQuerySet, HistoryDescriptor, HistoryManager, ) from .signals import ( post_create_historical_m2m_records, post_create_historical_record, pre_create_historical_m2m_records, pre_create_historical_record, ) 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 = {} def _default_get_user(request, **kwargs): try: return request.user except AttributeError: return None def _history_user_getter(historical_instance): if historical_instance.history_user_id is None: return None User = get_user_model() try: return User.objects.get(pk=historical_instance.history_user_id) except User.DoesNotExist: return None def _history_user_setter(historical_instance, user): if user is not None: historical_instance.history_user_id = user.pk class HistoricalRecords: DEFAULT_MODEL_NAME_PREFIX = "Historical" thread = context = LocalContext() # retain thread for backwards compatibility m2m_models = {} def __init__( self, verbose_name=None, verbose_name_plural=None, bases=(models.Model,), user_related_name="+", table_name=None, inherit=False, excluded_fields=None, history_id_field=None, history_change_reason_field=None, user_model=None, get_user=_default_get_user, cascade_delete_history=False, custom_model_name=None, app=None, history_user_id_field=None, history_user_getter=_history_user_getter, history_user_setter=_history_user_setter, related_name=None, use_base_model_db=False, user_db_constraint=True, no_db_index=list(), excluded_field_kwargs=None, history_manager=HistoryManager, historical_queryset=HistoricalQuerySet, m2m_fields=(), m2m_fields_model_field_name="_history_m2m_fields", m2m_bases=(models.Model,), ): self.user_set_verbose_name = verbose_name self.user_set_verbose_name_plural = verbose_name_plural self.user_related_name = user_related_name self.user_db_constraint = user_db_constraint self.table_name = table_name self.inherit = inherit self.history_id_field = history_id_field self.history_change_reason_field = history_change_reason_field self.user_model = user_model self.get_user = get_user self.cascade_delete_history = cascade_delete_history self.custom_model_name = custom_model_name self.app = app self.user_id_field = history_user_id_field self.user_getter = history_user_getter self.user_setter = history_user_setter self.related_name = related_name self.use_base_model_db = use_base_model_db self.history_manager = history_manager self.historical_queryset = historical_queryset self.m2m_fields = m2m_fields self.m2m_fields_model_field_name = m2m_fields_model_field_name if isinstance(no_db_index, str): no_db_index = [no_db_index] self.no_db_index = no_db_index if excluded_fields is None: excluded_fields = [] self.excluded_fields = excluded_fields if excluded_field_kwargs is None: excluded_field_kwargs = {} self.excluded_field_kwargs = excluded_field_kwargs try: if isinstance(bases, str): raise TypeError self.bases = (HistoricalChanges,) + tuple(bases) except TypeError: raise TypeError("The `bases` option must be a list or a tuple.") try: if isinstance(m2m_bases, str): raise TypeError self.m2m_bases = (HistoricalChanges,) + tuple(m2m_bases) except TypeError: raise TypeError("The `m2m_bases` option must be a list or a tuple.") def contribute_to_class(self, cls, name): self.manager_name = name self.module = cls.__module__ self.cls = cls models.signals.class_prepared.connect(self.finalize, weak=False) self.add_extra_methods(cls) if cls._meta.abstract and not self.inherit: msg = ( "HistoricalRecords added to abstract model ({}) without " "inherit=True".format(self.cls.__name__) ) warnings.warn(msg, UserWarning) def add_extra_methods(self, cls): def save_without_historical_record(self, *args, **kwargs): """ Save model without saving a historical record Make sure you know what you're doing before you use this method. """ self.skip_history_when_saving = True try: ret = self.save(*args, **kwargs) finally: del self.skip_history_when_saving return ret setattr(cls, "save_without_historical_record", save_without_historical_record) def finalize(self, sender, **kwargs): inherited = False if self.cls is not sender: # set in concrete inherited = self.inherit and issubclass(sender, self.cls) if not inherited: return # set in abstract if hasattr(sender._meta, "simple_history_manager_attribute"): raise exceptions.MultipleRegistrationsError( "{}.{} registered multiple times for history tracking.".format( sender._meta.app_label, sender._meta.object_name ) ) history_model = self.create_history_model(sender, inherited) if inherited: # Make sure history model is in same module as concrete model module = importlib.import_module(history_model.__module__) else: module = importlib.import_module(self.module) setattr(module, history_model.__name__, history_model) # The HistoricalRecords object will be discarded, # 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) m2m_fields = self.get_m2m_fields_from_model(sender) for field in m2m_fields: m2m_changed.connect( partial(self.m2m_changed, attr=field.name), sender=field.remote_field.through, weak=False, ) descriptor = HistoryDescriptor( history_model, manager=self.history_manager, queryset=self.historical_queryset, ) setattr(sender, self.manager_name, descriptor) sender._meta.simple_history_manager_attribute = self.manager_name for field in m2m_fields: m2m_model = self.create_history_m2m_model( history_model, field.remote_field.through ) self.m2m_models[field] = m2m_model setattr(module, m2m_model.__name__, m2m_model) m2m_descriptor = HistoryDescriptor(m2m_model) setattr(history_model, field.name, m2m_descriptor) def get_history_model_name(self, model): if not self.custom_model_name: return f"{self.DEFAULT_MODEL_NAME_PREFIX}{model._meta.object_name}" # Must be trying to use a custom history model name if callable(self.custom_model_name): name = self.custom_model_name(model._meta.object_name) else: # simple string name = self.custom_model_name # Desired class name cannot be same as the model it is tracking if not ( name.lower() == model._meta.object_name.lower() and model.__module__ == self.module ): return name raise ValueError( "The 'custom_model_name' option '{}' evaluates to a name that is the same " "as the model it is tracking. This is not permitted.".format( self.custom_model_name ) ) def create_history_m2m_model(self, model, through_model): attrs = {} fields = self.copy_fields(through_model) attrs.update(fields) attrs.update(self.get_extra_fields_m2m(model, through_model, fields)) name = self.get_history_model_name(through_model) registered_models[through_model._meta.db_table] = through_model attrs.update(Meta=type("Meta", (), self.get_meta_options_m2m(through_model))) m2m_history_model = type(str(name), self.m2m_bases, attrs) return m2m_history_model def create_history_model(self, model, inherited): """ Creates a historical model to associate with the model provided. """ attrs = { "__module__": self.module, "_history_excluded_fields": self.excluded_fields, "_history_m2m_fields": self.get_m2m_fields_from_model(model), "tracked_fields": self.fields_included(model), } app_module = "%s.models" % model._meta.app_label if inherited: # inherited use models module attrs["__module__"] = model.__module__ elif model.__module__ != self.module: # registered under different app attrs["__module__"] = self.module elif app_module != self.module: # Abuse an internal API because the app registry is loading. app = apps.app_configs[model._meta.app_label] models_module = app.name attrs["__module__"] = models_module fields = self.copy_fields(model) attrs.update(fields) attrs.update(self.get_extra_fields(model, fields)) # type in python2 wants str as a first argument attrs.update(Meta=type("Meta", (), self.get_meta_options(model))) if not inherited and self.table_name is not None: attrs["Meta"].db_table = self.table_name # Set as the default then check for overrides name = self.get_history_model_name(model) registered_models[model._meta.db_table] = model history_model = type(str(name), self.bases, attrs) return history_model def fields_included(self, model): fields = [] for field in model._meta.fields: if field.name not in self.excluded_fields: fields.append(field) return fields def field_excluded_kwargs(self, field): """ Find the excluded kwargs for a given field. """ return self.excluded_field_kwargs.get(field.name, set()) def copy_fields(self, model): """ Creates copies of the model's original fields, returning a dictionary mapping field name to copied field object. """ fields = {} for field in self.fields_included(model): field = copy.copy(field) field.remote_field = copy.copy(field.remote_field) if isinstance(field, OrderWrt): # OrderWrt is a proxy field, switch to a plain IntegerField field.__class__ = models.IntegerField if isinstance(field, models.ForeignKey): old_field = field old_swappable = old_field.swappable old_field.swappable = False try: _name, _path, args, field_args = old_field.deconstruct() finally: old_field.swappable = old_swappable if getattr(old_field, "one_to_one", False) or isinstance( old_field, models.OneToOneField ): FieldType = models.ForeignKey else: FieldType = type(old_field) # Remove any excluded kwargs for the field. # This is useful when a custom OneToOneField is being used that # has a different set of arguments than ForeignKey for exclude_arg in self.field_excluded_kwargs(old_field): field_args.pop(exclude_arg, None) # If field_args['to'] is 'self' then we have a case where the object # has a foreign key to itself. If we pass the historical record's # field to = 'self', the foreign key will point to an historical # record rather than the base record. We can use old_field.model here. if field_args.get("to", None) == "self": field_args["to"] = old_field.model # Override certain arguments passed when creating the field # so that they work for the historical field. field_args.update( db_constraint=False, related_name="+", null=True, blank=True, primary_key=False, db_index=True, serialize=True, unique=False, on_delete=models.DO_NOTHING, ) field = FieldType(*args, **field_args) field.name = old_field.name else: transform_field(field) # drop db index if field.name in self.no_db_index: field.db_index = False fields[field.name] = field return fields def _get_history_change_reason_field(self): if self.history_change_reason_field: # User specific field from init history_change_reason_field = self.history_change_reason_field elif getattr( settings, "SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD", False ): # Use text field with no max length, not enforced by DB anyways history_change_reason_field = models.TextField(null=True) else: # Current default, with max length history_change_reason_field = models.CharField(max_length=100, null=True) return history_change_reason_field def _get_history_id_field(self): if self.history_id_field: history_id_field = self.history_id_field.clone() history_id_field.primary_key = True history_id_field.editable = False elif getattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", False): history_id_field = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False ) else: history_id_field = models.AutoField(primary_key=True) return history_id_field def _get_history_user_fields(self): if self.user_id_field is not None: # Tracking user using explicit id rather than Django ForeignKey history_user_fields = { "history_user": property(self.user_getter, self.user_setter), "history_user_id": self.user_id_field, } else: user_model = self.user_model or getattr( settings, "AUTH_USER_MODEL", "auth.User" ) history_user_fields = { "history_user": models.ForeignKey( user_model, null=True, related_name=self.user_related_name, on_delete=models.SET_NULL, db_constraint=self.user_db_constraint, ) } return history_user_fields def _get_history_related_field(self, model): if self.related_name: if self.manager_name == self.related_name: raise exceptions.RelatedNameConflictError( "The related name must not be called like the history manager." ) return { "history_relation": models.ForeignKey( model, on_delete=models.DO_NOTHING, related_name=self.related_name, db_constraint=False, ) } else: return {} def get_extra_fields_m2m(self, model, through_model, fields): """Return dict of extra fields added to the m2m historical record model""" extra_fields = { "__module__": model.__module__, "__str__": lambda self: "{} as of {}".format( self._meta.verbose_name, self.history.history_date ), "history": models.ForeignKey( model, db_constraint=False, on_delete=models.DO_NOTHING, ), "instance_type": through_model, "m2m_history_id": self._get_history_id_field(), } return extra_fields def get_extra_fields(self, model, fields): """Return dict of extra fields added to the historical record model""" def revert_url(self): """URL for this change in the default admin site.""" opts = model._meta app_label, model_name = opts.app_label, opts.model_name return reverse( f"{admin.site.name}:{app_label}_{model_name}_simple_history", args=[getattr(self, opts.pk.attname), self.history_id], ) def get_instance(self): attrs = { field.attname: getattr(self, field.attname) for field in fields.values() } if self._history_excluded_fields: # We don't add ManyToManyFields to this list because they may cause # the subsequent `.get()` call to fail. See #706 for context. excluded_attnames = [ model._meta.get_field(field).attname for field in self._history_excluded_fields if not isinstance(model._meta.get_field(field), ManyToManyField) ] try: values = ( model.objects.filter(pk=getattr(self, model._meta.pk.attname)) .values(*excluded_attnames) .get() ) except ObjectDoesNotExist: pass else: attrs.update(values) result = model(**attrs) # this is the only way external code could know an instance is historical setattr(result, SIMPLE_HISTORY_REVERSE_ATTR_NAME, self) return result def get_next_record(self): """ Get the next history record for the instance. `None` if last. """ history = utils.get_history_manager_from_history(self) return ( history.filter(history_date__gt=self.history_date) .order_by("history_date") .first() ) def get_prev_record(self): """ Get the previous history record for the instance. `None` if first. """ history = utils.get_history_manager_from_history(self) return ( history.filter(history_date__lt=self.history_date) .order_by("history_date") .last() ) def get_default_history_user(instance): """ Returns the user specified by `get_user` method for manually creating historical objects """ return self.get_history_user(instance) extra_fields = { "history_id": self._get_history_id_field(), "history_date": models.DateTimeField(db_index=self._date_indexing is True), "history_change_reason": self._get_history_change_reason_field(), "history_type": models.CharField( max_length=1, choices=(("+", _("Created")), ("~", _("Changed")), ("-", _("Deleted"))), ), "history_object": HistoricalObjectDescriptor( model, self.fields_included(model) ), "instance": property(get_instance), "instance_type": model, "next_record": property(get_next_record), "prev_record": property(get_prev_record), "revert_url": revert_url, "__str__": lambda self: "{} as of {}".format( self.history_object, self.history_date ), "get_default_history_user": staticmethod(get_default_history_user), } extra_fields.update(self._get_history_related_field(model)) extra_fields.update(self._get_history_user_fields()) return extra_fields @property def _date_indexing(self): """False, True, or 'composite'; default is True""" result = getattr(settings, "SIMPLE_HISTORY_DATE_INDEX", True) valid = True if isinstance(result, str): result = result.lower() if result not in ("composite",): valid = False elif not isinstance(result, bool): valid = False if not valid: raise ImproperlyConfigured( "SIMPLE_HISTORY_DATE_INDEX must be one of (False, True, 'Composite')" ) return result def get_meta_options_m2m(self, through_model): """ Returns a dictionary of fields that will be added to the Meta inner class of the m2m historical record model. """ name = self.get_history_model_name(through_model) meta_fields = {"verbose_name": name} if self.app: meta_fields["app_label"] = self.app return meta_fields def get_meta_options(self, model): """ Returns a dictionary of fields that will be added to the Meta inner class of the historical record model. """ meta_fields = { "ordering": ("-history_date", "-history_id"), "get_latest_by": ("history_date", "history_id"), } if self.user_set_verbose_name: name = self.user_set_verbose_name else: name = format_lazy("historical {}", smart_str(model._meta.verbose_name)) if self.user_set_verbose_name_plural: plural_name = self.user_set_verbose_name_plural else: plural_name = format_lazy( "historical {}", smart_str(model._meta.verbose_name_plural) ) meta_fields["verbose_name"] = name meta_fields["verbose_name_plural"] = plural_name if self.app: meta_fields["app_label"] = self.app if self._date_indexing == "composite": meta_fields["indexes"] = ( models.Index(fields=("history_date", model._meta.pk.attname)), ) return meta_fields def post_save(self, instance, created, using=None, **kwargs): if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): return if not created and hasattr(instance, "skip_history_when_saving"): return if not kwargs.get("raw", False): self.create_historical_record(instance, created and "+" or "~", using=using) def post_delete(self, instance, using=None, **kwargs): if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): return if self.cascade_delete_history: manager = getattr(instance, self.manager_name) manager.using(using).all().delete() else: self.create_historical_record(instance, "-", using=using) 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 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 if action in ("post_add", "post_remove", "post_clear"): # It should be safe to ~ this since the row must exist to modify m2m on it self.create_historical_record(instance, "~") def create_historical_record_m2ms(self, history_instance, instance): for field in history_instance._history_m2m_fields: 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 = [] 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 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( sender=m2m_history_model, rows=insert_rows, history_instance=history_instance, instance=instance, field=field, ) created_rows = m2m_history_model.objects.bulk_create(insert_rows) post_create_historical_m2m_records.send( sender=m2m_history_model, created_rows=created_rows, history_instance=history_instance, instance=instance, field=field, ) def create_historical_record(self, instance, history_type, using=None): using = using if self.use_base_model_db else None history_date = getattr(instance, "_history_date", timezone.now()) history_user = self.get_history_user(instance) history_change_reason = self.get_change_reason_for_object( instance, history_type, using ) manager = getattr(instance, self.manager_name) attrs = {} for field in self.fields_included(instance): attrs[field.attname] = getattr(instance, field.attname) relation_field = getattr(manager.model, "history_relation", None) if relation_field is not None: attrs["history_relation"] = instance history_instance = manager.model( history_date=history_date, history_type=history_type, history_user=history_user, history_change_reason=history_change_reason, **attrs, ) pre_create_historical_record.send( sender=manager.model, instance=instance, history_date=history_date, history_user=history_user, history_change_reason=history_change_reason, history_instance=history_instance, using=using, ) history_instance.save(using=using) self.create_historical_record_m2ms(history_instance, instance) post_create_historical_record.send( sender=manager.model, instance=instance, history_instance=history_instance, history_date=history_date, history_user=history_user, history_change_reason=history_change_reason, using=using, ) def get_history_user(self, instance): """Get the modifying user from instance or middleware.""" try: return instance._history_user except AttributeError: request = None try: if self.context.request.user.is_authenticated: request = self.context.request except AttributeError: pass return self.get_user(instance=instance, request=request) def get_m2m_fields_from_model(self, model): m2m_fields = set(self.m2m_fields) try: m2m_fields.update(getattr(model, self.m2m_fields_model_field_name)) except AttributeError: pass field_names = [ field if isinstance(field, str) else field.name for field in m2m_fields ] return [getattr(model, field_name).field for field_name in field_names] def transform_field(field): """Customize field appropriately for use in historical model""" field.name = field.attname if isinstance(field, models.BigAutoField): field.__class__ = models.BigIntegerField elif isinstance(field, models.AutoField): field.__class__ = models.IntegerField elif isinstance(field, models.FileField): # Don't copy file, just path. if getattr(settings, "SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD", False): field.__class__ = models.CharField else: field.__class__ = models.TextField # Historical instance shouldn't change create/update timestamps field.auto_now = False field.auto_now_add = False # Just setting db_collation explicitly since we're not using # field.deconstruct() here field.db_collation = None if field.primary_key or field.unique: # 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. """ def get_queryset(self, **hints) -> QuerySet: instance = hints.get("instance") if instance: history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None) histmgr = getattr( self.field.remote_field.model, getattr( self.field.remote_field.model._meta, "simple_history_manager_attribute", "_notthere", ), None, ) if history and histmgr: return histmgr.as_of(getattr(history, "_as_of", history.history_date)) return super().get_queryset(**hints) class HistoricReverseManyToOneDescriptor(ReverseManyToOneDescriptor): """ 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. """ @cached_property def related_manager_cls(self): related_model = self.rel.related_model class HistoricRelationModelManager(related_model._default_manager.__class__): def get_queryset(self): try: return self.instance._prefetched_objects_cache[ self.field.remote_field.get_cache_name() ] except (AttributeError, KeyError): history = getattr( self.instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None ) histmgr = getattr( self.model, getattr( self.model._meta, "simple_history_manager_attribute", "_notthere", ), None, ) if history and histmgr: queryset = histmgr.as_of( getattr(history, "_as_of", history.history_date) ) else: queryset = super().get_queryset() return self._apply_rel_filters(queryset) return create_reverse_many_to_one_manager( HistoricRelationModelManager, self.rel ) class HistoricForeignKey(ForeignKey): """ Allows foreign keys 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 foreign key and also historic, changing them to a HistoricForeignKey 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"). HistoricForeignKey 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 = HistoricForwardManyToOneDescriptor related_accessor_class = HistoricReverseManyToOneDescriptor def is_historic(instance): """ Returns True if the instance was acquired with an as_of timepoint. """ return to_historic(instance) is not None def to_historic(instance): """ Returns a historic model instance if the instance was acquired with an as_of timepoint, or None. """ return getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None) class HistoricalObjectDescriptor: def __init__(self, model, fields_included): self.model = model self.fields_included = fields_included def __get__(self, instance, owner): if instance is None: return self values = {f.attname: getattr(instance, f.attname) for f in self.fields_included} return self.model(**values) class HistoricalChanges(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:" f" '{type(self)}' and '{type(old_history)}'" ) if excluded_fields is None: excluded_fields = set() included_m2m_fields = {field.name for field in old_history._history_m2m_fields} if included_fields is None: included_fields = {f.name for f in old_history.tracked_fields if f.editable} else: included_m2m_fields = included_m2m_fields.intersection(included_fields) fields = ( set(included_fields) .difference(included_m2m_fields) .difference(excluded_fields) ) 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 = [] old_values = model_to_dict(old_history, fields=fields) new_values = model_to_dict(self, fields=fields) for field in fields: old_value = old_values[field] new_value = 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) return changes 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 = [] 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) 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: field: str old: ModelChangeValue new: ModelChangeValue @dataclass(frozen=True) class ModelDelta: changes: Sequence[ModelChange] changed_fields: Sequence[str] old_record: HistoricalChanges new_record: HistoricalChanges jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/000077500000000000000000000000001462567636100264205ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/__init__.py000066400000000000000000000000001462567636100305170ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/000077500000000000000000000000001462567636100323105ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/__init__.py000066400000000000000000000000001462567636100344070ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations/000077500000000000000000000000001462567636100344645ustar00rootroot000000000000000001_initial.py000066400000000000000000000072131462567636100370530ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 1.9.12 on 2017-01-18 21:58 import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( name="DoYouKnow", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ) ], ), migrations.CreateModel( name="HistoricalYar", fields=[ ( "id", models.IntegerField( auto_created=True, blank=True, db_index=True, verbose_name="ID" ), ), ("history_id", models.AutoField(primary_key=True, serialize=False)), ("history_date", models.DateTimeField()), ( "history_type", models.CharField( choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1, ), ), ("history_change_reason", models.CharField(max_length=100, null=True)), ( "history_user", models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL, ), ), ], options={ "verbose_name": "historical yar", "ordering": ("-history_date", "-history_id"), "get_latest_by": "history_date", }, ), migrations.CreateModel( name="Yar", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ) ], ), migrations.CreateModel( name="WhatIMean", fields=[ ( "doyouknow_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="migration_test_app.DoYouKnow", ), ) ], bases=("migration_test_app.doyouknow",), ), migrations.AddField( model_name="yar", name="what", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to="migration_test_app.WhatIMean", ), ), migrations.AddField( model_name="historicalyar", name="what", field=models.ForeignKey( blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="migration_test_app.WhatIMean", ), ), ] 0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py000066400000000000000000000061321462567636100541760ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 2.1 on 2018-10-19 21:53 import django.db.models.deletion from django.conf import settings from django.db import migrations, models import simple_history.models from .. import models as my_models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("migration_test_app", "0001_initial"), ] operations = [ migrations.CreateModel( name="HistoricalModelWithCustomAttrForeignKey", fields=[ ( "id", models.IntegerField( auto_created=True, blank=True, db_index=True, verbose_name="ID" ), ), ("history_id", models.AutoField(primary_key=True, serialize=False)), ("history_change_reason", models.CharField(max_length=100, null=True)), ("history_date", models.DateTimeField()), ( "history_type", models.CharField( choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1, ), ), ( "history_user", models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL, ), ), ( "what_i_mean", my_models.CustomAttrNameForeignKey( attr_name="custom_attr_name", blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="migration_test_app.WhatIMean", ), ), ], options={ "verbose_name": "historical model with custom attr foreign key", "ordering": ("-history_date", "-history_id"), "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( name="ModelWithCustomAttrForeignKey", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "what_i_mean", my_models.CustomAttrNameForeignKey( attr_name="custom_attr_name", on_delete=django.db.models.deletion.CASCADE, to="migration_test_app.WhatIMean", ), ), ], ), ] 0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py000066400000000000000000000017341462567636100525230ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.0.dev20210802171549 on 2021-08-11 11:05 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey", ), ] operations = [ migrations.AlterModelOptions( name="historicalmodelwithcustomattrforeignkey", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr foreign key", }, ), migrations.AlterModelOptions( name="historicalyar", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical yar", }, ), ] 0004_history_date_indexing.py000066400000000000000000000013001462567636100417770ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.0.dev20210811195242 on 2021-08-13 10:07 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more", ), ] operations = [ migrations.AlterField( model_name="historicalmodelwithcustomattrforeignkey", name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( model_name="historicalyar", name="history_date", field=models.DateTimeField(db_index=True), ), ] 0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py000066400000000000000000000062351462567636100553650ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 3.2.6 on 2021-08-24 10:36 import django.db.models.deletion from django.conf import settings from django.db import migrations, models import simple_history.models import simple_history.registry_tests.migration_test_app.models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ( "migration_test_app", "0004_history_date_indexing", ), ] operations = [ migrations.CreateModel( name="ModelWithCustomAttrOneToOneField", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "what_i_mean", simple_history.registry_tests.migration_test_app.models.CustomAttrNameOneToOneField( attr_name="custom_attr_name", on_delete=django.db.models.deletion.CASCADE, to="migration_test_app.whatimean", ), ), ], ), migrations.CreateModel( name="HistoricalModelWithCustomAttrOneToOneField", fields=[ ( "id", models.IntegerField( auto_created=True, blank=True, db_index=True, verbose_name="ID" ), ), ("history_id", models.AutoField(primary_key=True, serialize=False)), ("history_date", models.DateTimeField()), ("history_change_reason", models.CharField(max_length=100, null=True)), ( "history_type", models.CharField( choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1, ), ), ( "history_user", models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL, ), ), ( "what_i_mean", models.ForeignKey( blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="migration_test_app.whatimean", ), ), ], options={ "verbose_name": "historical model with custom attr one to one field", "ordering": ("-history_date", "-history_id"), "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), ] 0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py000066400000000000000000000016201462567636100532100ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.1.dev20211006030854 on 2021-10-07 13:51 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield", ), ] operations = [ migrations.AlterModelOptions( name="historicalmodelwithcustomattronetoonefield", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr one to one field", }, ), migrations.AlterField( model_name="historicalmodelwithcustomattronetoonefield", name="history_date", field=models.DateTimeField(db_index=True), ), ] 0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py000066400000000000000000000030431462567636100525220ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.0.1 on 2022-01-28 11:26 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more", ), ] operations = [ migrations.AlterModelOptions( name="historicalmodelwithcustomattrforeignkey", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr foreign key", "verbose_name_plural": "historical model with custom attr foreign keys", }, ), migrations.AlterModelOptions( name="historicalmodelwithcustomattronetoonefield", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr one to one field", "verbose_name_plural": "historical model with custom attr one to one fields", }, ), migrations.AlterModelOptions( name="historicalyar", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical yar", "verbose_name_plural": "historical yars", }, ), ] __init__.py000066400000000000000000000000001462567636100365040ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/migrationsjazzband-django-simple-history-2a2bec9/simple_history/registry_tests/migration_test_app/models.py000066400000000000000000000032521462567636100341470ustar00rootroot00000000000000from django.db import models from simple_history.models import HistoricalRecords class DoYouKnow(models.Model): pass class WhatIMean(DoYouKnow): pass class Yar(models.Model): what = models.ForeignKey(WhatIMean, on_delete=models.CASCADE) history = HistoricalRecords() class CustomAttrNameForeignKey(models.ForeignKey): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrForeignKey(models.Model): what_i_mean = CustomAttrNameForeignKey( WhatIMean, models.CASCADE, attr_name="custom_attr_name" ) history = HistoricalRecords() class CustomAttrNameOneToOneField(models.OneToOneField): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrOneToOneField(models.Model): what_i_mean = CustomAttrNameOneToOneField( WhatIMean, models.CASCADE, attr_name="custom_attr_name" ) history = HistoricalRecords(excluded_field_kwargs={"what_i_mean": {"attr_name"}}) jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/models.py000066400000000000000000000000001462567636100302430ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/registry_tests/tests.py000066400000000000000000000171731462567636100301450ustar00rootroot00000000000000import unittest import uuid from datetime import datetime, timedelta from io import StringIO from django.apps import apps from django.contrib.auth import get_user_model from django.core import management from django.test import TestCase, TransactionTestCase, override_settings from simple_history import exceptions, register from ..tests.models import ( Choice, InheritTracking1, InheritTracking2, InheritTracking3, InheritTracking4, ModelWithCustomAttrForeignKey, ModelWithCustomAttrOneToOneField, ModelWithHistoryInDifferentApp, Poll, Restaurant, TrackedAbstractBaseA, TrackedAbstractBaseB, TrackedWithAbstractBase, TrackedWithConcreteBase, UserAccessorDefault, UserAccessorOverride, UUIDRegisterModel, Voter, ) get_model = apps.get_model User = get_user_model() today = datetime(2021, 1, 1, 10, 0) tomorrow = today + timedelta(days=1) yesterday = today - timedelta(days=1) class RegisterTest(TestCase): def test_register_no_args(self): self.assertEqual(len(Choice.history.all()), 0) poll = Poll.objects.create(pub_date=today) choice = Choice.objects.create(poll=poll, votes=0) self.assertEqual(len(choice.history.all()), 1) def test_register_separate_app(self): def get_history(model): return model.history self.assertRaises(AttributeError, get_history, User) self.assertEqual(len(User.histories.all()), 0) user = User.objects.create(username="bob", password="pass") self.assertEqual(len(User.histories.all()), 1) self.assertEqual(len(user.histories.all()), 1) def test_reregister(self): with self.assertRaises(exceptions.MultipleRegistrationsError): register(Restaurant, manager_name="again") def test_register_custome_records(self): self.assertEqual(len(Voter.history.all()), 0) poll = Poll.objects.create(pub_date=today) choice = Choice.objects.create(poll=poll, votes=0) user = User.objects.create(username="voter") voter = Voter.objects.create(choice=choice, user=user) self.assertEqual(len(voter.history.all()), 1) expected = "Voter object changed by None as of " self.assertEqual(expected, str(voter.history.all()[0])[: len(expected)]) def test_register_history_id_field(self): self.assertEqual(len(UUIDRegisterModel.history.all()), 0) entry = UUIDRegisterModel.objects.create() self.assertEqual(len(entry.history.all()), 1) history = entry.history.all()[0] self.assertTrue(isinstance(history.history_id, uuid.UUID)) class TestUserAccessor(unittest.TestCase): def test_accessor_default(self): register(UserAccessorDefault) self.assertFalse(hasattr(User, "historicaluseraccessordefault_set")) def test_accessor_override(self): register(UserAccessorOverride, user_related_name="my_history_model_accessor") self.assertTrue(hasattr(User, "my_history_model_accessor")) class TestInheritedModule(TestCase): def test_using_app_label(self): try: from ..tests.models import HistoricalConcreteExternal except ImportError: self.fail("HistoricalConcreteExternal is in wrong module") def test_default(self): try: from ..tests.models import HistoricalConcreteExternal2 except ImportError: self.fail("HistoricalConcreteExternal2 is in wrong module") class TestTrackingInheritance(TestCase): def test_tracked_abstract_base(self): self.assertEqual( sorted( f.attname for f in TrackedWithAbstractBase.history.model._meta.fields ), sorted( [ "id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_tracked_concrete_base(self): self.assertEqual( sorted( f.attname for f in TrackedWithConcreteBase.history.model._meta.fields ), sorted( [ "id", "trackedconcretebase_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_multiple_tracked_bases(self): with self.assertRaises(exceptions.MultipleRegistrationsError): class TrackedWithMultipleAbstractBases( TrackedAbstractBaseA, TrackedAbstractBaseB ): pass def test_tracked_abstract_and_untracked_concrete_base(self): self.assertEqual( sorted(f.attname for f in InheritTracking1.history.model._meta.fields), sorted( [ "id", "untrackedconcretebase_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_indirect_tracked_abstract_base(self): self.assertEqual( sorted(f.attname for f in InheritTracking2.history.model._meta.fields), sorted( [ "id", "baseinherittracking2_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_indirect_tracked_concrete_base(self): self.assertEqual( sorted(f.attname for f in InheritTracking3.history.model._meta.fields), sorted( [ "id", "baseinherittracking3_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_registering_with_tracked_abstract_base(self): with self.assertRaises(exceptions.MultipleRegistrationsError): register(InheritTracking4) class TestCustomAttrForeignKey(TestCase): """https://github.com/jazzband/django-simple-history/issues/431""" def test_custom_attr(self): field = ModelWithCustomAttrForeignKey.history.model._meta.get_field("poll") self.assertEqual(field.attr_name, "custom_poll") class TestCustomAttrOneToOneField(TestCase): """https://github.com/jazzband/django-simple-history/issues/870""" def test_custom_attr(self): field = ModelWithCustomAttrOneToOneField.history.model._meta.get_field("poll") self.assertFalse(hasattr(field, "attr_name")) @override_settings(MIGRATION_MODULES={}) class TestMigrate(TransactionTestCase): def test_makemigration_command(self): management.call_command( "makemigrations", "migration_test_app", stdout=StringIO() ) def test_migrate_command(self): management.call_command( "migrate", "migration_test_app", fake=True, stdout=StringIO() ) class TestModelWithHistoryInDifferentApp(TestCase): """https://github.com/jazzband/django-simple-history/issues/485""" def test__different_app(self): appLabel = ModelWithHistoryInDifferentApp.history.model._meta.app_label self.assertEqual(appLabel, "external") jazzband-django-simple-history-2a2bec9/simple_history/signals.py000066400000000000000000000012621462567636100253410ustar00rootroot00000000000000import django.dispatch # Arguments: "instance", "history_instance", "history_date", # "history_user", "history_change_reason", "using" pre_create_historical_record = django.dispatch.Signal() # Arguments: "instance", "history_instance", "history_date", # "history_user", "history_change_reason", "using" post_create_historical_record = django.dispatch.Signal() # Arguments: "sender", "rows", "history_instance", "instance", # "field" pre_create_historical_m2m_records = django.dispatch.Signal() # Arguments: "sender", "created_rows", "history_instance", # "instance", "field" post_create_historical_m2m_records = django.dispatch.Signal() jazzband-django-simple-history-2a2bec9/simple_history/template_utils.py000066400000000000000000000225071462567636100267410ustar00rootroot00000000000000import dataclasses from os.path import commonprefix from typing import Any, Dict, Final, List, Tuple, Type, 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}" jazzband-django-simple-history-2a2bec9/simple_history/templates/000077500000000000000000000000001462567636100253245ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/templates/simple_history/000077500000000000000000000000001462567636100303765ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/templates/simple_history/object_history.html000066400000000000000000000010241462567636100343100ustar00rootroot00000000000000{% extends "admin/object_history.html" %} {% load i18n %} {% block content %}
{% if not revert_disabled %}

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

{% endif %}
{% if historical_records %} {% include object_history_list_template %} {% else %}

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

{% endif %}
{% endblock %} object_history_form.html000066400000000000000000000024561462567636100352660ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/templates/simple_history{% extends "admin/change_form.html" %} {% load i18n %} {% load url from simple_history_compat %} {% block breadcrumbs %}
{% endblock %} {% block submit_buttons_top %} {% include "simple_history/submit_line.html" %} {% endblock %} {% block submit_buttons_bottom %} {% include "simple_history/submit_line.html" %} {% 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 %} object_history_list.html000066400000000000000000000043401462567636100352700ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/templates/simple_history{% 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 historical_records %} {% 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 %}
jazzband-django-simple-history-2a2bec9/simple_history/templates/simple_history/submit_line.html000066400000000000000000000006551462567636100336040ustar00rootroot00000000000000{% load i18n %}
{% if not revert_disabled %} {% endif %} {% if change_history %} {% endif %} {% trans 'Close' %}
jazzband-django-simple-history-2a2bec9/simple_history/templatetags/000077500000000000000000000000001462567636100260205ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/templatetags/__init__.py000066400000000000000000000000001462567636100301170ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/templatetags/getattributes.py000066400000000000000000000003711462567636100312610ustar00rootroot00000000000000from django import template register = template.Library() def getattribute(value, arg): """Gets an attribute of an object dynamically from a string name""" return getattr(value, arg, None) register.filter("getattribute", getattribute) jazzband-django-simple-history-2a2bec9/simple_history/templatetags/simple_history_admin_list.py000066400000000000000000000006121462567636100336460ustar00rootroot00000000000000import warnings from django import template register = template.Library() @register.inclusion_tag("simple_history/object_history_list.html", takes_context=True) def display_list(context): warnings.warn( "'include' the context variable 'object_history_list_template' instead." " This will be removed in version 3.8.", DeprecationWarning, ) return context jazzband-django-simple-history-2a2bec9/simple_history/templatetags/simple_history_compat.py000066400000000000000000000001721462567636100330070ustar00rootroot00000000000000from django import template from django.template.defaulttags import url register = template.Library() register.tag(url) jazzband-django-simple-history-2a2bec9/simple_history/tests/000077500000000000000000000000001462567636100244705ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/__init__.py000066400000000000000000000000001462567636100265670ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/admin.py000066400000000000000000000050311462567636100261310ustar00rootroot00000000000000from 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 ( Book, Choice, ConcreteExternal, Document, Employee, FileModel, Paper, Person, Place, Planet, Poll, PollWithManyToMany, ) class PersonAdmin(SimpleHistoryAdmin): def has_change_permission(self, request, obj=None): return False def has_view_permission(self, request, obj=None): return False class ChoiceAdmin(SimpleHistoryAdmin): history_list_display = ["votes"] class FileModelAdmin(SimpleHistoryAdmin): def test_method(self, obj): return "test_method_value" history_list_display = ["title", "test_method"] class PlanetAdmin(SimpleHistoryAdmin): def test_method(self, obj): return "test_method_value" history_list_display = ["title", "test_method"] 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(Employee, 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) jazzband-django-simple-history-2a2bec9/simple_history/tests/custom_user/000077500000000000000000000000001462567636100270405ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/custom_user/__init__.py000066400000000000000000000000001462567636100311370ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/custom_user/admin.py000066400000000000000000000002351462567636100305020ustar00rootroot00000000000000from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import CustomUser admin.site.register(CustomUser, UserAdmin) jazzband-django-simple-history-2a2bec9/simple_history/tests/custom_user/models.py000066400000000000000000000001371462567636100306760ustar00rootroot00000000000000from django.contrib.auth.models import AbstractUser class CustomUser(AbstractUser): pass jazzband-django-simple-history-2a2bec9/simple_history/tests/external/000077500000000000000000000000001462567636100263125ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/external/__init__.py000066400000000000000000000000001462567636100304110ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/external/models.py000066400000000000000000000026471462567636100301600ustar00rootroot00000000000000from django.db import models from simple_history import register from simple_history.models import HistoricalRecords from simple_history.tests.custom_user.models import CustomUser class AbstractExternal(models.Model): history = HistoricalRecords(inherit=True) class Meta: abstract = True class AbstractExternal2(models.Model): history = HistoricalRecords(inherit=True, custom_model_name=lambda x: f"Audit{x}") class Meta: abstract = True app_label = "external" class AbstractExternal3(models.Model): history = HistoricalRecords( inherit=True, app="external", custom_model_name=lambda x: f"Audit{x}" ) class Meta: abstract = True app_label = "external" class ExternalModel(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class ExternalModelRegistered(models.Model): name = models.CharField(max_length=100) register(ExternalModelRegistered, app="simple_history.tests", manager_name="histories") class ExternalModelWithCustomUserIdField(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords(history_user_id_field=models.IntegerField(null=True)) class Poll(models.Model): """Test model for same-named historical models This model intentionally conflicts with the 'Polls' model in 'tests.models'. """ history = HistoricalRecords(user_related_name="+") jazzband-django-simple-history-2a2bec9/simple_history/tests/generated_file_checks/000077500000000000000000000000001462567636100307455ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/generated_file_checks/__init__.py000066400000000000000000000000001462567636100330440ustar00rootroot00000000000000check_translations.py000066400000000000000000000042371462567636100351240ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/generated_file_checksimport subprocess import sys from glob import glob from pathlib import Path import django from django.conf import settings from django.core.management import call_command from runtests import get_default_settings def log(*args, **kwargs): # Flush so that all printed messages appear in the correct order, not matter what # `file` argument is passed (e.g. `sys.stdout` (default) or `sys.stderr`) print(*args, **{"flush": True, **kwargs}) def log_err(*args, **kwargs): log(*args, **{"file": sys.stderr, **kwargs}) # For some reason, changes in the .po files are often not reflected in the .mo files # when running 'compilemessages' - unless the .mo files are deleted first, # in which case they seem to be consistently updated def delete_mo_files(): locale_dir = Path("simple_history/locale") log( f"Deleting the following files inside '{locale_dir}'" f" so that they can be regenerated by 'compilemessages':" ) for file_path in glob("**/*.mo", root_dir=locale_dir, recursive=True): log(f"\t{file_path}") (locale_dir / file_path).unlink() # Code based on # https://github.com/stefanfoulis/django-phonenumber-field/blob/e653a0972b56d39f1f51fa1f5124b70c2a5549bc/check-translations def main(): # Delete the .mo files before regenerating them below delete_mo_files() log("Running 'compilemessages'...") call_command("compilemessages") log("\nRunning 'git status'...") result = subprocess.run( ["git", "status", "--porcelain"], check=True, stdout=subprocess.PIPE, ) assert result.stderr is None stdout = result.stdout.decode() if stdout: log_err(f"Unexpected changes found in the workspace:\n\n{stdout}") if ".mo" in stdout: log_err( "To properly update any '.mo' files," " try deleting them before running 'compilemessages'." ) sys.exit(1) else: # Print the human-readable status to the console subprocess.run(["git", "status"]) if __name__ == "__main__": if not settings.configured: settings.configure(**get_default_settings()) django.setup() main() jazzband-django-simple-history-2a2bec9/simple_history/tests/models.py000066400000000000000000000637241462567636100263410ustar00rootroot00000000000000import datetime import uuid from django.apps import apps from django.conf import settings from django.db import models from django.db.models.deletion import CASCADE from django.db.models.fields.related import ForeignKey from django.urls import reverse from simple_history import register from simple_history.manager import HistoricalQuerySet, HistoryManager from simple_history.models import HistoricalRecords, HistoricForeignKey from .custom_user.models import CustomUser as User from .external.models import AbstractExternal, AbstractExternal2, AbstractExternal3 get_model = apps.get_model class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords() def get_absolute_url(self): return reverse("poll-detail", kwargs={"pk": self.pk}) class PollWithNonEditableField(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") modified = models.DateTimeField(auto_now=True, editable=False) history = HistoricalRecords() class PollWithUniqueQuestion(models.Model): question = models.CharField(max_length=200, unique=True) pub_date = models.DateTimeField("date published") history = HistoricalRecords() class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") place = models.TextField(null=True) history = HistoricalRecords(excluded_fields=["pub_date"]) class PollWithExcludedFieldsWithDefaults(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") expiration_time = models.DateField(default=datetime.date(2030, 12, 12)) place = models.TextField(null=True) min_questions = models.PositiveIntegerField(default=1) max_questions = models.PositiveIntegerField() history = HistoricalRecords( excluded_fields=[ "pub_date", "expiration_time", "place", "min_questions", "max_questions", ] ) class PollWithExcludedFKField(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") place = models.ForeignKey("Place", on_delete=models.CASCADE) history = HistoricalRecords(excluded_fields=["place"]) class AlternativePollManager(models.Manager): def get_queryset(self): return super().get_queryset().exclude(id=1) class PollWithAlternativeManager(models.Model): some_objects = AlternativePollManager() all_objects = models.Manager() question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords() class CustomPollManager(models.Manager): def get_queryset(self): return super().get_queryset().exclude(hidden=True) class PollWithCustomManager(models.Model): some_objects = CustomPollManager() all_objects = models.Manager() question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") hidden = models.BooleanField(default=False) history = HistoricalRecords() class IPAddressHistoricalModel(models.Model): ip_address = models.GenericIPAddressField() class Meta: abstract = True class PollWithHistoricalIPAddress(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords(bases=[IPAddressHistoricalModel]) def get_absolute_url(self): return reverse("poll-detail", kwargs={"pk": self.pk}) class SessionsHistoricalModel(models.Model): session = models.CharField(max_length=200, null=True, default=None) class Meta: abstract = True class PollWithHistoricalSessionAttr(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(bases=[SessionsHistoricalModel]) class PollWithManyToMany(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") places = models.ManyToManyField("Place") history = HistoricalRecords(m2m_fields=[places]) class PollWithManyToManyCustomHistoryID(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") places = models.ManyToManyField("Place") history = HistoricalRecords( m2m_fields=[places], history_id_field=models.UUIDField(default=uuid.uuid4) ) class PollQuerySet(HistoricalQuerySet): def questions(self): return self.filter(question__startswith="Question ") class PollManager(HistoryManager): def low_ids(self): return self.filter(id__lte=3) class PollWithQuerySetCustomizations(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords( history_manager=PollManager, historical_queryset=PollQuerySet ) class HistoricalRecordsWithExtraFieldM2M(HistoricalRecords): def get_extra_fields_m2m(self, model, through_model, fields): extra_fields = super().get_extra_fields_m2m(model, through_model, fields) def get_class_name(self): return self.__class__.__name__ extra_fields["get_class_name"] = get_class_name return extra_fields class PollWithManyToManyWithIPAddress(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") places = models.ManyToManyField("Place") history = HistoricalRecordsWithExtraFieldM2M( m2m_fields=[places], m2m_bases=[IPAddressHistoricalModel] ) class PollWithSeveralManyToMany(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") places = models.ManyToManyField("Place", related_name="places_poll") restaurants = models.ManyToManyField("Restaurant", related_name="restaurants_poll") books = models.ManyToManyField("Book", related_name="books_poll") history = HistoricalRecords(m2m_fields=[places, restaurants, books]) class PollParentWithManyToMany(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") places = models.ManyToManyField("Place") history = HistoricalRecords( m2m_fields=[places], inherit=True, ) class Meta: abstract = True class PollChildBookWithManyToMany(PollParentWithManyToMany): books = models.ManyToManyField("Book", related_name="books_poll_child") _history_m2m_fields = ["books"] class PollChildRestaurantWithManyToMany(PollParentWithManyToMany): restaurants = models.ManyToManyField( "Restaurant", related_name="restaurants_poll_child" ) _history_m2m_fields = [restaurants] class PollWithSelfManyToMany(models.Model): relations = models.ManyToManyField("self") history = HistoricalRecords(m2m_fields=[relations]) class CustomAttrNameForeignKey(models.ForeignKey): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrForeignKey(models.Model): poll = CustomAttrNameForeignKey(Poll, models.CASCADE, attr_name="custom_poll") history = HistoricalRecords() class CustomAttrNameOneToOneField(models.OneToOneField): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrOneToOneField(models.Model): poll = CustomAttrNameOneToOneField(Poll, models.CASCADE, attr_name="custom_poll") history = HistoricalRecords(excluded_field_kwargs={"poll": {"attr_name"}}) class Temperature(models.Model): location = models.CharField(max_length=200) temperature = models.IntegerField() history = HistoricalRecords() __history_date = None @property def _history_date(self): return self.__history_date @_history_date.setter def _history_date(self, value): self.__history_date = value class WaterLevel(models.Model): waters = models.CharField(max_length=200) level = models.IntegerField() date = models.DateTimeField() history = HistoricalRecords(cascade_delete_history=True) @property def _history_date(self): return self.date class Choice(models.Model): poll = models.ForeignKey(Poll, on_delete=models.CASCADE) choice = models.CharField(max_length=200) votes = models.IntegerField() register(Choice) class Voter(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name="voters") def __str__(self): return "Voter object" class HistoricalRecordsVerbose(HistoricalRecords): def get_extra_fields(self, model, fields): def verbose_str(self): return "{} changed by {} as of {}".format( self.history_object, self.history_user, self.history_date, ) extra_fields = super().get_extra_fields(model, fields) extra_fields["__str__"] = verbose_str return extra_fields register(Voter, records_class=HistoricalRecordsVerbose) class Place(models.Model): name = models.CharField(max_length=100) class Restaurant(Place): rating = models.IntegerField() updates = HistoricalRecords() class Person(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() def save(self, *args, **kwargs): if hasattr(self, "skip_history_when_saving"): raise RuntimeError("error while saving") else: super().save(*args, **kwargs) class FileModel(models.Model): title = models.CharField(max_length=100) file = models.FileField(upload_to="files") history = HistoricalRecords() # Set SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD setattr(settings, "SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD", True) class CharFieldFileModel(models.Model): title = models.CharField(max_length=100) file = models.FileField(upload_to="files") history = HistoricalRecords() # Clear SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD delattr(settings, "SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD") class Document(models.Model): changed_by = models.ForeignKey( User, on_delete=models.CASCADE, null=True, blank=True ) history = HistoricalRecords() @property def _history_user(self): try: return self.changed_by except User.DoesNotExist: return None class Paper(Document): history = HistoricalRecords() @Document._history_user.setter def _history_user(self, value): self.changed_by = value class RankedDocument(Document): rank = models.IntegerField(default=50) history = HistoricalRecords() class Profile(User): date_of_birth = models.DateField() class AdminProfile(models.Model): profile = models.ForeignKey(Profile, on_delete=models.CASCADE) class State(models.Model): library = models.ForeignKey("Library", on_delete=models.CASCADE, null=True) history = HistoricalRecords() class Book(models.Model): isbn = models.CharField(max_length=15, primary_key=True) history = HistoricalRecords( verbose_name="dead trees", verbose_name_plural="dead trees plural" ) class HardbackBook(Book): price = models.FloatField() class Bookcase(models.Model): books = models.ForeignKey(HardbackBook, on_delete=models.CASCADE) class Library(models.Model): book = models.ForeignKey(Book, on_delete=models.CASCADE, null=True) history = HistoricalRecords() class Meta: verbose_name = "quiet please" verbose_name_plural = "quiet please plural" class BaseModel(models.Model): pass class FirstLevelInheritedModel(BaseModel): pass class SecondLevelInheritedModel(FirstLevelInheritedModel): pass class AbstractBase(models.Model): class Meta: abstract = True class ConcreteAttr(AbstractBase): history = HistoricalRecords(bases=[AbstractBase]) class ConcreteUtil(AbstractBase): pass register(ConcreteUtil, bases=[AbstractBase]) class MultiOneToOne(models.Model): fk = models.ForeignKey(SecondLevelInheritedModel, on_delete=models.CASCADE) class SelfFK(models.Model): fk = models.ForeignKey("self", on_delete=models.CASCADE, null=True) history = HistoricalRecords() register(User, app="simple_history.tests", manager_name="histories") class ExternalModelWithAppLabel(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class Meta: app_label = "external" class ExternalModelSpecifiedWithAppParam(models.Model): name = models.CharField(max_length=100) register( ExternalModelSpecifiedWithAppParam, app="simple_history.tests.external", manager_name="histories", ) class UnicodeVerboseName(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class Meta: verbose_name = "\u570b" class UnicodeVerboseNamePlural(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class Meta: verbose_name_plural = "\u570b" class CustomFKError(models.Model): fk = models.ForeignKey(SecondLevelInheritedModel, on_delete=models.CASCADE) history = HistoricalRecords() class Series(models.Model): """A series of works, like a trilogy of books.""" name = models.CharField(max_length=100) author = models.CharField(max_length=100) class SeriesWork(models.Model): series = models.ForeignKey("Series", on_delete=models.CASCADE, related_name="works") title = models.CharField(max_length=100) history = HistoricalRecords() class Meta: order_with_respect_to = "series" class PollInfo(models.Model): poll = models.OneToOneField(Poll, on_delete=models.CASCADE, primary_key=True) history = HistoricalRecords() class UserAccessorDefault(models.Model): pass class UserAccessorOverride(models.Model): pass class Employee(models.Model): manager = models.OneToOneField("Employee", null=True, on_delete=models.CASCADE) history = HistoricalRecords() class Country(models.Model): code = models.CharField(max_length=15, unique=True) class Province(models.Model): country = models.ForeignKey(Country, on_delete=models.CASCADE, to_field="code") history = HistoricalRecords() class City(models.Model): country = models.ForeignKey( Country, on_delete=models.CASCADE, db_column="countryCode" ) history = HistoricalRecords() class Planet(models.Model): star = models.CharField(max_length=30) history = HistoricalRecords() def __str__(self): return self.star class Meta: verbose_name = "Planet" verbose_name_plural = "Planets" class Contact(models.Model): name = models.CharField(max_length=30) email = models.EmailField(max_length=255, unique=True) history = HistoricalRecords(table_name="contacts_history") class ContactRegister(models.Model): name = models.CharField(max_length=30) email = models.EmailField(max_length=255, unique=True) register(ContactRegister, table_name="contacts_register_history") class ModelWithHistoryInDifferentApp(models.Model): name = models.CharField(max_length=30) history = HistoricalRecords(app="external") class ModelWithHistoryInDifferentDb(models.Model): name = models.CharField(max_length=30) history = HistoricalRecords() class ModelWithHistoryUsingBaseModelDb(models.Model): name = models.CharField(max_length=30) history = HistoricalRecords(use_base_model_db=True) class ModelWithFkToModelWithHistoryUsingBaseModelDb(models.Model): fk = models.ForeignKey( ModelWithHistoryUsingBaseModelDb, on_delete=models.CASCADE, null=True ) history = HistoricalRecords(use_base_model_db=True) ############################################################################### # # Inheritance examples # ############################################################################### class TrackedAbstractBaseA(models.Model): history = HistoricalRecords(inherit=True) class Meta: abstract = True class TrackedAbstractBaseB(models.Model): history_b = HistoricalRecords(inherit=True) class Meta: abstract = True class UntrackedAbstractBase(models.Model): class Meta: abstract = True class TrackedConcreteBase(models.Model): history = HistoricalRecords(inherit=True) class UntrackedConcreteBase(models.Model): pass class ConcreteExternal(AbstractExternal): name = models.CharField(max_length=50) class Meta: app_label = "tests" class ConcreteExternal2(AbstractExternal): name = models.CharField(max_length=50) class Meta: pass # Don't set app_label to test inherited module path class TrackedWithAbstractBase(TrackedAbstractBaseA): pass class TrackedWithConcreteBase(TrackedConcreteBase): pass class InheritTracking1(TrackedAbstractBaseA, UntrackedConcreteBase): pass class BaseInheritTracking2(TrackedAbstractBaseA): pass class InheritTracking2(BaseInheritTracking2): pass class BaseInheritTracking3(TrackedAbstractBaseA): pass class InheritTracking3(BaseInheritTracking3): pass class InheritTracking4(TrackedAbstractBaseA): pass class BasePlace(models.Model): name = models.CharField(max_length=50) history = HistoricalRecords(inherit=True, table_name="base_places_history") class InheritedRestaurant(BasePlace): serves_hot_dogs = models.BooleanField(default=False) class BucketMember(models.Model): name = models.CharField(max_length=30) user = models.OneToOneField( User, related_name="bucket_member", on_delete=models.CASCADE ) class BucketData(models.Model): changed_by = models.ForeignKey( BucketMember, on_delete=models.SET_NULL, null=True, blank=True ) history = HistoricalRecords(user_model=BucketMember) @property def _history_user(self): return self.changed_by def get_bucket_member_changed_by(instance, **kwargs): try: return instance.changed_by except AttributeError: return None class BucketDataRegisterChangedBy(models.Model): changed_by = models.ForeignKey( BucketMember, on_delete=models.SET_NULL, null=True, blank=True ) register( BucketDataRegisterChangedBy, user_model=BucketMember, get_user=get_bucket_member_changed_by, ) def get_bucket_member_request_user(request, **kwargs): try: return request.user.bucket_member except AttributeError: return None class BucketDataRegisterRequestUser(models.Model): data = models.CharField(max_length=30) def get_absolute_url(self): return reverse("bucket_data-detail", kwargs={"pk": self.pk}) register( BucketDataRegisterRequestUser, user_model=BucketMember, get_user=get_bucket_member_request_user, ) class UUIDModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) history = HistoricalRecords(history_id_field=models.UUIDField(default=uuid.uuid4)) class UUIDRegisterModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) register(UUIDRegisterModel, history_id_field=models.UUIDField(default=uuid.uuid4)) # Set the SIMPLE_HISTORY_HISTORY_ID_USE_UUID setattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", True) class UUIDDefaultModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) history = HistoricalRecords() # Clear the SIMPLE_HISTORY_HISTORY_ID_USE_UUID delattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID") # Set the SIMPLE_HISTORY_HISTORY_CHANGE_REASON_FIELD setattr(settings, "SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD", True) class DefaultTextFieldChangeReasonModel(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords() # Clear the SIMPLE_HISTORY_HISTORY_CHANGE_REASON_FIELD delattr(settings, "SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD") class UserTextFieldChangeReasonModel(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords(history_change_reason_field=models.TextField(null=True)) class CharFieldChangeReasonModel(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords() class CustomManagerNameModel(models.Model): name = models.CharField(max_length=15) log = HistoricalRecords() # # Following classes test the "custom_model_name" option # class OverrideModelNameAsString(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords(custom_model_name="MyHistoricalCustomNameModel") class OverrideModelNameAsCallable(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords(custom_model_name=lambda x: f"Audit{x}") class AbstractModelCallable1(models.Model): history = HistoricalRecords(inherit=True, custom_model_name=lambda x: f"Audit{x}") class Meta: abstract = True class OverrideModelNameUsingBaseModel1(AbstractModelCallable1): name = models.CharField(max_length=15, unique=True) class OverrideModelNameUsingExternalModel1(AbstractExternal2): name = models.CharField(max_length=15, unique=True) class OverrideModelNameUsingExternalModel2(AbstractExternal3): name = models.CharField(max_length=15, unique=True) class OverrideModelNameRegisterMethod1(models.Model): name = models.CharField(max_length=15, unique=True) register( OverrideModelNameRegisterMethod1, custom_model_name="MyOverrideModelNameRegisterMethod1", ) class OverrideModelNameRegisterMethod2(models.Model): name = models.CharField(max_length=15, unique=True) class ForeignKeyToSelfModel(models.Model): fk_to_self = models.ForeignKey( "ForeignKeyToSelfModel", null=True, related_name="+", on_delete=models.CASCADE ) fk_to_self_using_str = models.ForeignKey( "self", null=True, related_name="+", on_delete=models.CASCADE ) history = HistoricalRecords() class Street(models.Model): name = models.CharField(max_length=150) log = HistoricalRecords(related_name="history") class ManyToManyModelOther(models.Model): name = models.CharField(max_length=15, unique=True) class BulkCreateManyToManyModel(models.Model): name = models.CharField(max_length=15, unique=True) other = models.ManyToManyField(ManyToManyModelOther) history = HistoricalRecords() class ModelWithExcludedManyToMany(models.Model): name = models.CharField(max_length=15, unique=True) other = models.ManyToManyField(ManyToManyModelOther) history = HistoricalRecords(excluded_fields=["other"]) class ModelWithSingleNoDBIndexUnique(models.Model): name = models.CharField(max_length=15, unique=True, db_index=True) name_keeps_index = models.CharField(max_length=15, unique=True, db_index=True) history = HistoricalRecords(no_db_index="name") class ModelWithMultipleNoDBIndex(models.Model): name = models.CharField(max_length=15, db_index=True) name_keeps_index = models.CharField(max_length=15, db_index=True) fk = models.ForeignKey( "Library", on_delete=models.CASCADE, null=True, related_name="+" ) fk_keeps_index = models.ForeignKey( "Library", on_delete=models.CASCADE, null=True, related_name="+" ) history = HistoricalRecords(no_db_index=["name", "fk", "other"]) class TestOrganization(models.Model): name = models.CharField(max_length=15, unique=True) class TestOrganizationWithHistory(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords() class TestParticipantToHistoricOrganization(models.Model): """ Non-historic table foreign key 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 = HistoricForeignKey( TestOrganizationWithHistory, on_delete=CASCADE, related_name="participants" ) class TestHistoricParticipantToOrganization(models.Model): """ Historic table foreign key to non-historic table. In this case it should simply behave like ForeignKey because the origin model (this one) can be historic but the target model is not, so foreign key lookups are always "current". """ name = models.CharField(max_length=15, unique=True) organization = HistoricForeignKey( TestOrganization, on_delete=CASCADE, related_name="participants" ) history = HistoricalRecords() class TestHistoricParticipanToHistoricOrganization(models.Model): """ Historic table foreign key 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. NOTE: related_name has to be different than the one used in TestParticipantToHistoricOrganization as they are sharing the same target table. """ name = models.CharField(max_length=15, unique=True) organization = HistoricForeignKey( TestOrganizationWithHistory, on_delete=CASCADE, related_name="historic_participants", ) history = HistoricalRecords() jazzband-django-simple-history-2a2bec9/simple_history/tests/other_admin.py000066400000000000000000000003211462567636100273270ustar00rootroot00000000000000from django.contrib.admin.sites import AdminSite from simple_history.admin import SimpleHistoryAdmin from .models import State site = AdminSite(name="other_admin") site.register(State, SimpleHistoryAdmin) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/000077500000000000000000000000001462567636100256325ustar00rootroot00000000000000jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/__init__.py000066400000000000000000000001561462567636100277450ustar00rootroot00000000000000from .test_admin import * from .test_commands import * from .test_manager import * from .test_models import * jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_admin.py000066400000000000000000001271061462567636100303420ustar00rootroot00000000000000from 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.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.messages.storage.fallback import FallbackStorage from django.test import TestCase 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, middleware_override_settings, ) from ..models import ( Book, BucketData, BucketMember, Choice, ConcreteExternal, Employee, FileModel, Person, Place, Planet, Poll, PollWithManyToMany, State, ) User = get_user_model() today = datetime(2021, 1, 1, 10, 0) tomorrow = today + timedelta(days=1) def get_history_url(obj, history_index=None, site="admin"): app, model = obj._meta.app_label, obj._meta.model_name if history_index is not None: history = obj.history.order_by("history_id")[history_index] return reverse( "{site}:{app}_{model}_simple_history".format( site=site, app=app, model=model ), args=[quote(obj.pk), quote(history.history_id)], ) else: return reverse( f"{site}:{app}_{model}_history", args=[quote(obj.pk)], ) class AdminSiteTest(TestCase): def setUp(self): self.user = User.objects.create_superuser("user_login", "u@example.com", "pass") def login(self, user=None, superuser=None): user = user or self.user if superuser is not None: user.is_superuser = True if superuser is None else superuser user.is_active = True user.save() self.client.force_login(user) def test_history_list(self): model_name = self.user._meta.model_name self.assertEqual(model_name, "customuser") self.login() poll = Poll(question="why?", pub_date=today) poll._change_reason = "A random test reason" poll._history_user = self.user poll.save() response = self.client.get(get_history_url(poll)) self.assertContains(response, get_history_url(poll, 0)) self.assertContains(response, "Poll object") self.assertContains(response, "Created") self.assertContains(response, "Changed by") self.assertContains(response, "Change reason") 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") self.login() poll = Poll(question="why?", pub_date=today) poll._history_user = self.user poll.save() choice = Choice(poll=poll, choice="because", votes=12) choice._history_user = self.user choice.save() choice.votes = 15 choice.save() response = self.client.get(get_history_url(choice)) self.assertContains(response, get_history_url(choice, 0)) self.assertContains(response, "Choice object") self.assertContains(response, "Created") self.assertContains(response, self.user.username) self.assertContains(response, "votes") self.assertContains(response, "12") self.assertContains(response, "15") def test_history_list_custom_admin_methods(self): model_name = self.user._meta.model_name self.assertEqual(model_name, "customuser") self.login() file_model = FileModel(title="Title 1") file_model._history_user = self.user file_model.save() file_model.title = "Title 2" file_model.save() response = self.client.get(get_history_url(file_model)) self.assertContains(response, get_history_url(file_model, 0)) self.assertContains(response, "FileModel object") self.assertContains(response, "Created") self.assertContains(response, self.user.username) self.assertContains(response, "test_method_value") self.assertContains(response, "Title 1") self.assertContains(response, "Title 2") def test_history_list_custom_user_id_field(self): instance = ExternalModelWithCustomUserIdField(name="random_name") instance._history_user = self.user instance.save() self.login() resp = self.client.get(get_history_url(instance)) self.assertEqual(200, resp.status_code) def test_history_view_permission(self): self.login() person = Person.objects.create(name="Sandra Hale") resp = self.client.get(get_history_url(person)) self.assertEqual(403, resp.status_code) def test_history_form_permission(self): self.login(self.user) person = Person.objects.create(name="Sandra Hale") resp = self.client.get(get_history_url(person, 0)) self.assertEqual(403, resp.status_code) def test_invalid_history_form(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) response = self.client.post(get_history_url(poll, 0), data={"question": ""}) self.assertEqual(response.status_code, 200) self.assertContains(response, "This field is required") def test_history_form(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() # Make sure form for initial version is correct response = self.client.get(get_history_url(poll, 0)) form = response.context.get("adminform").form self.assertEqual(form["question"].value(), "why?") self.assertEqual(form["pub_date"].value(), datetime(2021, 1, 1, 10, 0)) # Create new version based on original version new_data = { "pub_date_0": "2021-01-02", "pub_date_1": "10:00:00", "question": "what?", } response = self.client.post(get_history_url(poll, 0), data=new_data) self.assertEqual(response.status_code, 302) self.assertTrue(response.url.endswith(reverse("admin:tests_poll_changelist"))) # Ensure form for second version is correct response = self.client.get(get_history_url(poll, 1)) form = response.context.get("adminform").form self.assertEqual(form["question"].value(), "how?") self.assertEqual(form["pub_date"].value(), datetime(2021, 1, 1, 10, 0)) # Ensure form for new third version is correct response = self.client.get(get_history_url(poll, 2)) form = response.context["adminform"].form self.assertEqual(form["question"].value(), "what?") self.assertEqual(form["pub_date"].value(), datetime(2021, 1, 2, 10, 0)) # Ensure current version of poll is correct poll = Poll.objects.get() self.assertEqual(poll.question, "what?") self.assertEqual(poll.pub_date, tomorrow) self.assertEqual( [p.history_user for p in Poll.history.all()], [self.user, None, None] ) def test_history_user_on_save_in_admin(self): self.login() # Ensure polls created via admin interface save correct user poll_data = { "question": "new poll?", "pub_date_0": "2012-01-01", "pub_date_1": "10:00:00", } self.client.post(reverse("admin:tests_poll_add"), data=poll_data) self.assertEqual(Poll.history.get().history_user, self.user) # Ensure polls saved on edit page in admin interface save correct user self.client.post(reverse("admin:tests_poll_add"), data=poll_data) self.assertEqual( [p.history_user for p in Poll.history.all()], [self.user, self.user] ) def test_underscore_in_pk(self): self.login() book = Book(isbn="9780147_513731") book._history_user = self.user book.save() response = self.client.get(get_history_url(book)) self.assertContains(response, book.history.all()[0].revert_url()) def test_historical_user_no_setter(self): """Demonstrate admin error without `_historical_user` setter. (Issue #43) """ self.login() with self.assertRaises(AttributeError): self.client.post(reverse("admin:tests_document_add")) def test_historical_user_with_setter(self): """Documented work-around for #43""" self.login() self.client.post(reverse("admin:tests_paper_add")) def test_history_user_not_saved(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) historical_poll = poll.history.all()[0] self.assertIsNone( historical_poll.history_user, "No way to know of request, history_user should be unset.", ) @override_settings(**middleware_override_settings) def test_middleware_saves_user(self): self.login() self.client.post( reverse("admin:tests_book_add"), data={"isbn": "9780147_513731"} ) book = Book.objects.get() historical_book = book.history.all()[0] self.assertEqual( historical_book.history_user, self.user, "Middleware should make the request available to " "retrieve history_user.", ) @override_settings(**middleware_override_settings) def test_middleware_unsets_request(self): self.login() self.client.get(reverse("admin:tests_book_add")) self.assertFalse(hasattr(HistoricalRecords.context, "request")) @override_settings(**middleware_override_settings) def test_rolled_back_user_does_not_lead_to_foreign_key_error(self): # This test simulates the rollback of a user after a request (which # happens, e.g. in test cases), and verifies that subsequently # creating a new entry does not fail with a foreign key error. self.login() self.assertEqual( self.client.get(reverse("admin:tests_book_add")).status_code, 200 ) book = Book.objects.create(isbn="9780147_513731") historical_book = book.history.all()[0] self.assertIsNone( historical_book.history_user, "No way to know of request, history_user should be unset.", ) @override_settings(**middleware_override_settings) def test_middleware_anonymous_user(self): self.client.get(reverse("admin:index")) poll = Poll.objects.create(question="why?", pub_date=today) historical_poll = poll.history.all()[0] self.assertEqual( historical_poll.history_user, None, "Middleware request user should be able to " "be anonymous.", ) def test_other_admin(self): """Test non-default admin instances. Make sure non-default admin instances can resolve urls and render pages. """ self.login() state = State.objects.create() history_url = get_history_url(state, site="other_admin") self.client.get(history_url) change_url = get_history_url(state, 0, site="other_admin") self.client.get(change_url) def test_deleting_user(self): """Test deletes of a user does not cascade delete the history""" self.login() poll = Poll(question="why?", pub_date=today) poll._history_user = self.user poll.save() historical_poll = poll.history.all()[0] self.assertEqual(historical_poll.history_user, self.user) self.user.delete() historical_poll = poll.history.all()[0] self.assertEqual(historical_poll.history_user, None) def test_deleteting_member(self): """Test deletes of a BucketMember doesn't cascade delete the history""" self.login() member = BucketMember.objects.create(name="member1", user=self.user) bucket_data = BucketData(changed_by=member) bucket_data.save() historical_poll = bucket_data.history.all()[0] self.assertEqual(historical_poll.history_user, member) member.delete() historical_poll = bucket_data.history.all()[0] self.assertEqual(historical_poll.history_user, None) def test_missing_one_to_one(self): """A relation to a missing one-to-one model should still show history""" self.login() manager = Employee.objects.create() employee = Employee.objects.create(manager=manager) employee.manager = None employee.save() manager.delete() response = self.client.get(get_history_url(employee, 0)) self.assertEqual(response.status_code, 200) def test_history_deleted_instance(self): """Ensure history page can be retrieved even for deleted instances""" self.login() employee = Employee.objects.create() employee_pk = employee.pk employee.delete() employee.pk = employee_pk response = self.client.get(get_history_url(employee)) self.assertEqual(response.status_code, 200) def test_response_change(self): """ Test the response_change method that it works with a _change_history in the POST and settings.SIMPLE_HISTORY_EDIT set to True """ request = RequestFactory().post("/") request.POST = {"_change_history": True} request.session = "session" request._messages = FallbackStorage(request) request.path = "/awesome/url/" poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.SIMPLE_HISTORY_EDIT", True): response = admin.response_change(request, poll) self.assertEqual(response["Location"], "/awesome/url/") def test_response_change_change_history_setting_off(self): """ Test the response_change method that it works with a _change_history in the POST and settings.SIMPLE_HISTORY_EDIT set to False """ request = RequestFactory().post("/") request.POST = {"_change_history": True} request.session = "session" request._messages = FallbackStorage(request) request.path = "/awesome/url/" request.user = self.user poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) admin.response_change(request, poll) with patch("simple_history.admin.admin.ModelAdmin.response_change") as m_admin: m_admin.return_value = "it was called" response = admin.response_change(request, poll) self.assertEqual(response, "it was called") def test_response_change_no_change_history(self): request = RequestFactory().post("/") request.session = "session" request._messages = FallbackStorage(request) request.user = self.user poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.admin.ModelAdmin.response_change") as m_admin: m_admin.return_value = "it was called" response = admin.response_change(request, poll) self.assertEqual(response, "it was called") def test_history_form_view_without_getting_history(self): request = RequestFactory().post("/") request.session = "session" request._messages = FallbackStorage(request) request.user = self.user poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() history = poll.history.all()[0] admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.render") as mock_render: admin.history_form_view(request, poll.id, history.pk) 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), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.id}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_view_permission": admin.has_view_history_permission(request, poll), "has_change_permission": admin.has_change_history_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled(request, poll), "has_file_field": True, "has_absolute_url": False, "form_url": "", "opts": ANY, "content_type_id": ANY, "save_as": admin.save_as, "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } # 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 ) def test_history_form_view_getting_history(self): request = RequestFactory().post("/") request.session = "session" request._messages = FallbackStorage(request) request.user = self.user request.POST = {"_change_history": True} poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() history = poll.history.all()[0] admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.render") as mock_render: with patch("simple_history.admin.SIMPLE_HISTORY_EDIT", True): admin.history_form_view(request, poll.id, history.pk) 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), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.pk}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_view_permission": admin.has_view_history_permission(request, poll), "has_change_permission": admin.has_change_history_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled(request, poll), "has_file_field": True, "has_absolute_url": False, "form_url": "", "opts": ANY, "content_type_id": ANY, "save_as": admin.save_as, "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } # 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 ) def test_history_form_view_getting_history_with_setting_off(self): request = RequestFactory().post("/") request.session = "session" request._messages = FallbackStorage(request) request.user = self.user request.POST = {"_change_history": True} poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() history = poll.history.all()[0] admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.render") as mock_render: with patch("simple_history.admin.SIMPLE_HISTORY_EDIT", False): admin.history_form_view(request, poll.id, history.pk) 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), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.id}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_view_permission": admin.has_view_history_permission(request, poll), "has_change_permission": admin.has_change_history_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled(request, poll), "has_file_field": True, "has_absolute_url": False, "form_url": "", "opts": ANY, "content_type_id": ANY, "save_as": admin.save_as, "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } # 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 ) def test_history_form_view_getting_history_abstract_external(self): request = RequestFactory().post("/") request.session = "session" request._messages = FallbackStorage(request) request.user = self.user request.POST = {"_change_history": True} obj = ConcreteExternal.objects.create(name="test") obj.name = "new_test" obj.save() history = obj.history.all()[0] admin_site = AdminSite() admin = SimpleHistoryAdmin(ConcreteExternal, admin_site) with patch("simple_history.admin.render") as mock_render: with patch("simple_history.admin.SIMPLE_HISTORY_EDIT", True): admin.history_form_view(request, obj.id, history.pk) 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), "adminform": ANY, "object_id": obj.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/concreteexternal/", "change_url": ANY, "history_url": "/admin/tests/concreteexternal/{pk}/history/".format( pk=obj.pk ), "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_view_permission": admin.has_view_history_permission(request, obj), "has_change_permission": admin.has_change_history_permission(request, obj), "has_delete_permission": admin.has_delete_permission(request, obj), "revert_disabled": admin.revert_disabled(request, obj), "has_file_field": True, "has_absolute_url": False, "form_url": "", "opts": ANY, "content_type_id": ANY, "save_as": admin.save_as, "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } # 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 ) def test_history_form_view_accepts_additional_context(self): request = RequestFactory().post("/") request.session = "session" request._messages = FallbackStorage(request) request.user = self.user poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() history = poll.history.all()[0] admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.render") as mock_render: admin.history_form_view( request, poll.id, history.pk, extra_context={"anything_else": "will be merged into context"}, ) 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, "title": "Revert %s" % force_str(poll), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.id}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_view_permission": admin.has_view_history_permission(request, poll), "has_change_permission": admin.has_change_history_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled(request, poll), "has_file_field": True, "has_absolute_url": False, "form_url": "", "opts": ANY, "content_type_id": ANY, "save_as": admin.save_as, "save_on_top": admin.save_on_top, "root_path": getattr(admin_site, "root_path", None), } # 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 ) def assert_history_view_response_contains( self, user=None, *, title_prefix: PermissionAction, choose_date: bool ): user = user or self.user user = User.objects.get(pk=user.pk) # refresh perms cache self.login(user) planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet)) self.assertEqual(response.status_code, 200) # `count=None` means at least once self.assertContains( response, "Change history: Sun", count=None if title_prefix == PermissionAction.CHANGE else 0, ) self.assertContains( response, "View history: Sun", count=None if title_prefix == PermissionAction.VIEW else 0, ) self.assertContains(response, "Choose a date", count=None if choose_date else 0) def test_history_view__title_suggests_revert_by_default(self): self.assert_history_view_response_contains( title_prefix=PermissionAction.CHANGE, choose_date=True ) @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=False) def test_history_view__title_suggests_revert(self): self.assert_history_view_response_contains( title_prefix=PermissionAction.CHANGE, choose_date=True ) @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=True) def test_history_view__title_suggests_view_only(self): self.assert_history_view_response_contains( title_prefix=PermissionAction.VIEW, choose_date=False ) def test_history_form_view__shows_revert_button_by_default(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertContains(response, "Revert Planet") self.assertContains(response, "Revert Sun") self.assertContains(response, "Press the 'Revert' button") @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=False) def test_history_form_view__shows_revert_button(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertContains(response, "Revert Planet") self.assertContains(response, "Revert Sun") self.assertContains(response, "Press the 'Revert' button") @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=True) def test_history_form_view__does_not_show_revert_button(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertNotContains(response, "Revert") self.assertContains(response, "View Planet") self.assertContains(response, "View Sun") def _test_history_view_response_text_with_revert_disabled(self, *, disabled): user = User.objects.create(username="astronomer", is_staff=True, is_active=True) user.user_permissions.add( Permission.objects.get(codename="view_planet"), Permission.objects.get(codename="view_historicalplanet"), ) self.assert_history_view_response_contains( user, title_prefix=PermissionAction.VIEW, choose_date=False ) user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_planet"), Permission.objects.get(codename="change_planet"), ) self.assert_history_view_response_contains( user, title_prefix=PermissionAction.VIEW if disabled else PermissionAction.CHANGE, choose_date=not disabled, ) @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=True) def test_history_view_response_text__revert_disabled(self): self._test_history_view_response_text_with_revert_disabled(disabled=True) def test_history_view_response_text__revert_enabled(self): self._test_history_view_response_text_with_revert_disabled(disabled=False) @override_settings(SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS=True) def test_history_form_view__no_perms_enforce_history_permissions(self): user = User.objects.create(username="astronomer", is_staff=True, is_active=True) user = User.objects.get(pk=user.pk) # refresh perms cache self.client.force_login(user) planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertEqual(response.status_code, 403) @override_settings(SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS=True) def test_history_view__no_perms_enforce_history_permissions(self): user = User.objects.create(username="astronomer", is_staff=True, is_active=True) user = User.objects.get(pk=user.pk) # refresh perms cache self.client.force_login(user) planet = Planet.objects.create(star="Sun") resp = self.client.get(get_history_url(planet)) self.assertEqual(resp.status_code, 403) @override_settings( SIMPLE_HISTORY_REVERT_DISABLED=True, SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS=True, ) def test_history_view__enforce_history_permissions_and_revert_enabled(self): user = User.objects.create(username="astronomer", is_staff=True, is_active=True) user.user_permissions.add( Permission.objects.get(codename="view_historicalplanet"), ) self.assert_history_view_response_contains( user, title_prefix=PermissionAction.VIEW, choose_date=False ) def _test_permission_combos_with_enforce_history_permissions(self, *, enforced): user = User.objects.create(username="astronomer", is_staff=True, is_active=True) def get_request(usr): usr = User.objects.get(pk=usr.pk) # refresh perms cache req = RequestFactory().post("/") req.session = "session" req._messages = FallbackStorage(req) req.user = usr return req admin_site = AdminSite() admin = SimpleHistoryAdmin(Planet, admin_site) # no perms request = get_request(user) self.assertFalse(admin.has_view_permission(request)) self.assertFalse(admin.has_change_permission(request)) self.assertFalse(admin.has_view_history_permission(request)) self.assertFalse(admin.has_change_history_permission(request)) # has concrete view/change only -> view_historical is false user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_planet"), Permission.objects.get(codename="change_planet"), ) request = get_request(user) self.assertTrue(admin.has_view_permission(request)) self.assertTrue(admin.has_change_permission(request)) self.assertEqual(admin.has_view_history_permission(request), not enforced) self.assertEqual(admin.has_change_history_permission(request), not enforced) # has concrete view/change and historical change -> view_history is false user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_planet"), Permission.objects.get(codename="change_planet"), Permission.objects.get(codename="change_historicalplanet"), ) request = get_request(user) self.assertTrue(admin.has_view_permission(request)) self.assertTrue(admin.has_change_permission(request)) self.assertEqual(admin.has_view_history_permission(request), not enforced) self.assertTrue(admin.has_change_history_permission(request)) # has concrete view/change and historical view/change -> view_history is true user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_planet"), Permission.objects.get(codename="change_planet"), Permission.objects.get(codename="view_historicalplanet"), Permission.objects.get(codename="change_historicalplanet"), ) request = get_request(user) self.assertTrue(admin.has_view_permission(request)) self.assertTrue(admin.has_change_permission(request)) self.assertTrue(admin.has_view_history_permission(request)) self.assertTrue(admin.has_change_history_permission(request)) # has historical view only -> view_history is true user.user_permissions.clear() user.user_permissions.add( Permission.objects.get(codename="view_historicalplanet"), ) request = get_request(user) self.assertFalse(admin.has_view_permission(request)) self.assertFalse(admin.has_change_permission(request)) self.assertEqual(admin.has_view_history_permission(request), enforced) self.assertFalse(admin.has_change_history_permission(request)) @override_settings(SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS=True) def test_permission_combos__enforce_history_permissions(self): self._test_permission_combos_with_enforce_history_permissions(enforced=True) def test_permission_combos__default(self): self._test_permission_combos_with_enforce_history_permissions(enforced=False) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_commands.py000066400000000000000000000556261462567636100310620ustar00rootroot00000000000000from contextlib import contextmanager from datetime import datetime, timedelta from io import StringIO from django.core import management from django.test import TestCase from simple_history import models as sh_models from simple_history.management.commands import ( clean_duplicate_history, clean_old_history, populate_history, ) from ..models import ( Book, CustomManagerNameModel, Place, Poll, PollWithCustomManager, PollWithExcludeFields, Restaurant, ) @contextmanager def replace_registry(new_value=None): hidden_registry = sh_models.registered_models sh_models.registered_models = new_value or {} try: yield except Exception: raise finally: sh_models.registered_models = hidden_registry class TestPopulateHistory(TestCase): command_name = "populate_history" command_error = (management.CommandError, SystemExit) def test_no_args(self): out = StringIO() management.call_command(self.command_name, stdout=out, stderr=StringIO()) self.assertIn(populate_history.Command.COMMAND_HINT, out.getvalue()) def test_bad_args(self): test_data = ( (populate_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)), (populate_history.Command.MODEL_NOT_FOUND, ("invalid.model",)), (populate_history.Command.MODEL_NOT_FOUND, ("bad_key",)), ) for msg, args in test_data: out = StringIO() self.assertRaises( self.command_error, management.call_command, self.command_name, *args, stdout=StringIO(), stderr=out, ) self.assertIn(msg, out.getvalue()) def test_auto_populate(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() management.call_command( self.command_name, auto=True, stdout=StringIO(), stderr=StringIO() ) self.assertEqual(Poll.history.all().count(), 1) def test_populate_with_custom_batch_size(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() management.call_command( self.command_name, auto=True, batchsize=500, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 1) def test_specific_populate(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() Book.objects.create(isbn="9780007117116") Book.history.all().delete() management.call_command( self.command_name, "tests.book", stdout=StringIO(), stderr=StringIO() ) self.assertEqual(Book.history.all().count(), 1) self.assertEqual(Poll.history.all().count(), 0) def test_failing_wont_save(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() self.assertRaises( self.command_error, management.call_command, self.command_name, "tests.poll", "tests.invalid_model", stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 0) def test_multi_table(self): data = {"rating": 5, "name": "Tea 'N More"} Restaurant.objects.create(**data) Restaurant.updates.all().delete() management.call_command( self.command_name, "tests.restaurant", stdout=StringIO(), stderr=StringIO() ) update_record = Restaurant.updates.all()[0] for attr, value in data.items(): self.assertEqual(getattr(update_record, attr), value) def test_existing_objects(self): data = {"rating": 5, "name": "Tea 'N More"} out = StringIO() Restaurant.objects.create(**data) pre_call_count = Restaurant.updates.count() management.call_command( self.command_name, "tests.restaurant", stdout=StringIO(), stderr=out ) self.assertEqual(Restaurant.updates.count(), pre_call_count) self.assertIn(populate_history.Command.EXISTING_HISTORY_FOUND, out.getvalue()) def test_no_historical(self): out = StringIO() with replace_registry({"test_place": Place}): management.call_command(self.command_name, auto=True, stdout=out) self.assertIn(populate_history.Command.NO_REGISTERED_MODELS, out.getvalue()) def test_batch_processing_with_batch_size_less_than_total(self): data = [ Poll(id=1, question="Question 1", pub_date=datetime.now()), Poll(id=2, question="Question 2", pub_date=datetime.now()), Poll(id=3, question="Question 3", pub_date=datetime.now()), Poll(id=4, question="Question 4", pub_date=datetime.now()), ] Poll.objects.bulk_create(data) management.call_command( self.command_name, auto=True, batchsize=3, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.count(), 4) def test_stdout_not_printed_when_verbosity_is_0(self): out = StringIO() Poll.objects.create(question="Question 1", pub_date=datetime.now()) management.call_command( self.command_name, auto=True, batchsize=3, stdout=out, stderr=StringIO(), verbosity=0, ) self.assertEqual(out.getvalue(), "") def test_stdout_printed_when_verbosity_is_not_specified(self): out = StringIO() Poll.objects.create(question="Question 1", pub_date=datetime.now()) management.call_command( self.command_name, auto=True, batchsize=3, stdout=out, stderr=StringIO() ) self.assertNotEqual(out.getvalue(), "") def test_excluded_fields(self): poll = PollWithExcludeFields.objects.create( question="Will this work?", pub_date=datetime.now() ) PollWithExcludeFields.history.all().delete() management.call_command( self.command_name, "tests.pollwithexcludefields", auto=True, stdout=StringIO(), stderr=StringIO(), ) initial_history_record = PollWithExcludeFields.history.all()[0] self.assertEqual(initial_history_record.question, poll.question) class TestCleanDuplicateHistory(TestCase): command_name = "clean_duplicate_history" command_error = (management.CommandError, SystemExit) def test_no_args(self): out = StringIO() management.call_command(self.command_name, stdout=out, stderr=StringIO()) self.assertIn(clean_duplicate_history.Command.COMMAND_HINT, out.getvalue()) def test_bad_args(self): test_data = ( (clean_duplicate_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)), (clean_duplicate_history.Command.MODEL_NOT_FOUND, ("invalid.model",)), (clean_duplicate_history.Command.MODEL_NOT_FOUND, ("bad_key",)), ) for msg, args in test_data: out = StringIO() self.assertRaises( self.command_error, management.call_command, self.command_name, *args, stdout=StringIO(), stderr=out, ) self.assertIn(msg, out.getvalue()) def test_no_historical(self): out = StringIO() with replace_registry({"test_place": Place}): management.call_command(self.command_name, auto=True, stdout=out) self.assertIn( clean_duplicate_history.Command.NO_REGISTERED_MODELS, out.getvalue() ) def test_auto_dry_run(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) p.save() # not related to dry_run test, just for increasing coverage :) # create instance with single-entry history older than "minutes" # so it is skipped p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) h = p.history.first() h.history_date -= timedelta(hours=1) h.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, minutes=50, dry=True, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 3) def test_auto_cleanup(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() self.assertEqual(Poll.history.all().count(), 2) p.question = "Maybe this one won't...?" p.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def _prepare_cleanup_manager(self): one = PollWithCustomManager._default_manager.create( question="This is hidden in default manager", pub_date=datetime.now(), hidden=True, ) one.save() two = PollWithCustomManager._default_manager.create( question="This is visible in default manager", pub_date=datetime.now() ) two.save() self.assertEqual(PollWithCustomManager.history.count(), 4) def test_auto_cleanup_defaultmanager(self): self._prepare_cleanup_manager() out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(PollWithCustomManager.history.count(), 3) def test_auto_cleanup_basemanage(self): self._prepare_cleanup_manager() out = StringIO() management.call_command( self.command_name, auto=True, base_manager=True, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n" "Removed 1 historical records for " "\n", ) self.assertEqual(PollWithCustomManager.history.count(), 2) def test_auto_cleanup_verbose(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.question = "Maybe this one won't...?" p.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, "tests.poll", auto=True, verbosity=2, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), " has 3 historical entries\n" "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_dated(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() self.assertEqual(Poll.history.all().count(), 5) for h in Poll.history.all()[2:]: h.history_date -= timedelta(hours=1) h.save() management.call_command( self.command_name, auto=True, minutes=50, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 4) def test_auto_cleanup_dated_extra_one(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() p.save() p.save() self.assertEqual(Poll.history.all().count(), 7) for h in Poll.history.all()[2:]: h.history_date -= timedelta(hours=1) h.save() management.call_command( self.command_name, auto=True, minutes=50, stdout=StringIO(), stderr=StringIO(), ) # even though only the last 2 entries match the date range # the "extra_one" (the record before the oldest match) # is identical to the oldest match, so oldest match is deleted self.assertEqual(Poll.history.all().count(), 5) def test_auto_cleanup_custom_history_field(self): m = CustomManagerNameModel.objects.create(name="John") self.assertEqual(CustomManagerNameModel.log.all().count(), 1) m.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 2) m.name = "Ivan" m.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(CustomManagerNameModel.log.all().count(), 2) def test_auto_cleanup_with_excluded_fields(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.pub_date = p.pub_date + timedelta(days=1) p.save() self.assertEqual(Poll.history.all().count(), 2) out = StringIO() management.call_command( self.command_name, auto=True, excluded_fields=("pub_date",), stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 1) def test_auto_cleanup_for_model_with_excluded_fields(self): p = PollWithExcludeFields.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(PollWithExcludeFields.history.all().count(), 1) p.pub_date = p.pub_date + timedelta(days=1) p.save() self.assertEqual(PollWithExcludeFields.history.all().count(), 2) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(PollWithExcludeFields.history.all().count(), 1) class TestCleanOldHistory(TestCase): command_name = "clean_old_history" command_error = (management.CommandError, SystemExit) def test_no_args(self): out = StringIO() management.call_command(self.command_name, stdout=out, stderr=StringIO()) self.assertIn(clean_old_history.Command.COMMAND_HINT, out.getvalue()) def test_bad_args(self): test_data = ( (clean_old_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)), (clean_old_history.Command.MODEL_NOT_FOUND, ("invalid.model",)), (clean_old_history.Command.MODEL_NOT_FOUND, ("bad_key",)), ) for msg, args in test_data: out = StringIO() self.assertRaises( self.command_error, management.call_command, self.command_name, *args, stdout=StringIO(), stderr=out, ) self.assertIn(msg, out.getvalue()) def test_no_historical(self): out = StringIO() with replace_registry({"test_place": Place}): management.call_command(self.command_name, auto=True, stdout=out) self.assertIn(clean_old_history.Command.NO_REGISTERED_MODELS, out.getvalue()) def test_auto_dry_run(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) p.save() # not related to dry_run test, just for increasing coverage :) # create instance with single-entry history older than "minutes" # so it is skipped p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) h = p.history.first() h.history_date -= timedelta(days=31) h.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, days=20, dry=True, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 3) def test_auto_cleanup(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() self.assertEqual(Poll.history.all().count(), 2) p.question = "Maybe this one won't...?" p.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() h = p.history.first() h.history_date -= timedelta(days=40) h.save() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_verbose(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.question = "Maybe this one won't...?" p.save() h = p.history.first() h.history_date -= timedelta(days=40) h.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, "tests.poll", auto=True, verbosity=2, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), " has 1 old historical entries\n" "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_dated(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() self.assertEqual(Poll.history.all().count(), 5) for h in Poll.history.all()[2:]: h.history_date -= timedelta(days=30) h.save() management.call_command( self.command_name, auto=True, days=20, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_dated_extra_one(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() p.save() p.save() self.assertEqual(Poll.history.all().count(), 7) for h in Poll.history.all()[2:]: h.history_date -= timedelta(days=30) h.save() management.call_command( self.command_name, auto=True, days=20, stdout=StringIO(), stderr=StringIO(), ) # We will remove the 3 ones that we are marking as old self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_custom_history_field(self): m = CustomManagerNameModel.objects.create(name="John") self.assertEqual(CustomManagerNameModel.log.all().count(), 1) m.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 2) m.name = "Ivan" m.save() h = m.log.first() h.history_date -= timedelta(days=40) h.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(CustomManagerNameModel.log.all().count(), 2) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_deprecation.py000066400000000000000000000010101462567636100315300ustar00rootroot00000000000000import unittest from simple_history import __version__ from simple_history.templatetags.simple_history_admin_list import display_list class DeprecationWarningTest(unittest.TestCase): def test__display_list__warns_deprecation_and_is_yet_to_be_removed(self): with self.assertWarns(DeprecationWarning): display_list({}) # DEV: `display_list()` (and the file `simple_history_admin_list.py`) should be # removed when 3.8 is released self.assertLess(__version__, "3.8") jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_index.py000066400000000000000000000010711462567636100303510ustar00rootroot00000000000000from django.conf import settings from django.db import models from django.test import TestCase, override_settings from simple_history.models import HistoricalRecords @override_settings(SIMPLE_HISTORY_DATE_INDEX="Composite") class HistoricalIndexTest(TestCase): def test_has_composite_index(self): self.assertEqual(settings.SIMPLE_HISTORY_DATE_INDEX, "Composite") class Foo(models.Model): history = HistoricalRecords() self.assertEqual( ["history_date", "id"], Foo.history.model._meta.indexes[0].fields ) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_manager.py000066400000000000000000000342471462567636100306670ustar00rootroot00000000000000from datetime import datetime, timedelta from operator import attrgetter 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 Choice, Document, Poll, RankedDocument User = get_user_model() class AsOfTest(TestCase): model = Document def setUp(self): user = User.objects.create_user("tester", "tester@example.com") self.now = datetime.now() self.yesterday = self.now - timedelta(days=1) self.obj = self.model.objects.create() self.obj.changed_by = user self.obj.save() self.model.objects.all().delete() # allows us to leave PK on instance ( self.delete_history, self.change_history, self.create_history, ) = self.model.history.all() self.create_history.history_date = self.now - timedelta(days=2) self.create_history.save() self.change_history.history_date = self.now - timedelta(days=1) self.change_history.save() self.delete_history.history_date = self.now self.delete_history.save() def test_created_after(self): """An object created after the 'as of' date should not be included. """ as_of_list = list(self.model.history.as_of(self.now - timedelta(days=5))) self.assertFalse(as_of_list) def test_deleted_before(self): """An object deleted before the 'as of' date should not be included. """ as_of_list = list(self.model.history.as_of(self.now + timedelta(days=1))) self.assertFalse(as_of_list) def test_deleted_after(self): """An object created before, but deleted after the 'as of' date should be included. """ as_of_list = list(self.model.history.as_of(self.now - timedelta(days=1))) self.assertEqual(len(as_of_list), 1) self.assertEqual(as_of_list[0].pk, self.obj.pk) def test_modified(self): """An object modified before the 'as of' date should reflect the last version. """ as_of_list = list(self.model.history.as_of(self.now - timedelta(days=1))) self.assertEqual(as_of_list[0].changed_by, self.obj.changed_by) class AsOfAdditionalTestCase(TestCase): def test_create_and_delete(self): document = Document.objects.create() now = datetime.now() document.delete() docs_as_of_now = Document.history.as_of(now) doc = docs_as_of_now[0] # as_of queries inject a property allowing callers # to go from instance to historical instance historic = getattr(doc, SIMPLE_HISTORY_REVERSE_ATTR_NAME) self.assertIsNotNone(historic) # as_of queries inject the time point of the original # query into the historic record so callers can do magical # things like chase historic foreign key relationships # by patching forward and reverse one-to-one relationship # processing (see issue 880) self.assertEqual(historic._as_of, now) docs_as_of_tmw = Document.history.as_of(now + timedelta(days=1)) with self.assertNumQueries(1): self.assertFalse(list(docs_as_of_tmw)) def test_multiple(self): document1 = Document.objects.create() document2 = Document.objects.create() historical = Document.history.as_of(datetime.now() + timedelta(days=1)) # history, even converted to objects, is kept in reverse chronological order # because sorting it based on the original table's meta ordering is not possible # when the ordering leverages foreign key relationships with self.assertNumQueries(1): self.assertEqual(list(historical), [document2, document1]) def test_filter_pk_as_instance(self): # when a queryset is returning historical documents, `pk` queries # reference the history_id; however when a queryset is returning # instances, `pk' queries reference the original table's primary key document1 = RankedDocument.objects.create(id=101, rank=42) RankedDocument.objects.create(id=102, rank=84) self.assertFalse(RankedDocument.history.filter(pk=document1.id)) self.assertTrue( RankedDocument.history.all().as_instances().filter(pk=document1.id) ) def test_as_of(self): """Demonstrates how as_of works now that it returns a QuerySet.""" t0 = datetime.now() document1 = RankedDocument.objects.create(rank=42) document2 = RankedDocument.objects.create(rank=84) t1 = datetime.now() document2.rank = 51 document2.save() document1.delete() t2 = datetime.now() # nothing exists at t0 queryset = RankedDocument.history.as_of(t0) self.assertEqual(queryset.count(), 0) # at t1, two records exist queryset = RankedDocument.history.as_of(t1) self.assertEqual(queryset.count(), 2) self.assertEqual(queryset.filter(rank__gte=75).count(), 1) ids = {item["id"] for item in queryset.values("id")} self.assertEqual(ids, {document1.id, document2.id}) # these records are historic record = queryset[0] historic = getattr(record, SIMPLE_HISTORY_REVERSE_ATTR_NAME) self.assertIsInstance(historic, RankedDocument.history.model) self.assertEqual(historic._as_of, t1) # at t2 we have one record left queryset = RankedDocument.history.as_of(t2) self.assertEqual(queryset.count(), 1) self.assertEqual(queryset.filter(rank__gte=75).count(), 0) 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 document2.save() document1.delete() 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) # only want the most recend records (provided by HistoricalQuerySet) 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) # these new methods are idempotent 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( set( RankedDocument.history.filter(history_date__lte=t2) .latest_of_each() .as_instances() ), set(RankedDocument.history.as_of(t2)), ) class BulkHistoryCreateTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=datetime.now()), Poll(id=2, question="Question 2", pub_date=datetime.now()), Poll(id=3, question="Question 3", pub_date=datetime.now()), Poll(id=4, question="Question 4", pub_date=datetime.now()), ] def test_simple_bulk_history_create(self): created = Poll.history.bulk_history_create(self.data) self.assertEqual(len(created), 4) self.assertQuerySetEqual( Poll.history.order_by("question"), ["Question 1", "Question 2", "Question 3", "Question 4"], attrgetter("question"), ) self.assertTrue( all([history.history_type == "+" for history in Poll.history.all()]) ) created = Poll.history.bulk_create([]) self.assertEqual(created, []) self.assertEqual(Poll.history.count(), 4) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_simple_bulk_history_create_without_history_enabled(self): Poll.history.bulk_history_create(self.data) self.assertEqual(Poll.history.count(), 0) def test_bulk_history_create_with_change_reason(self): for poll in self.data: poll._change_reason = "reason" Poll.history.bulk_history_create(self.data) self.assertTrue( all( [ history.history_change_reason == "reason" for history in Poll.history.all() ] ) ) def test_bulk_history_create_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") Poll.history.bulk_history_create(self.data, default_user=user) self.assertTrue( all([history.history_user == user for history in Poll.history.all()]) ) def test_bulk_history_create_with_default_change_reason(self): Poll.history.bulk_history_create(self.data, default_change_reason="test") self.assertTrue( all( [ history.history_change_reason == "test" for history in Poll.history.all() ] ) ) def test_bulk_history_create_history_user_overrides_default(self): user1 = User.objects.create_user("tester1", "tester1@example.com") user2 = User.objects.create_user("tester2", "tester2@example.com") for data in self.data: data._history_user = user1 Poll.history.bulk_history_create(self.data, default_user=user2) self.assertTrue( all([history.history_user == user1 for history in Poll.history.all()]) ) def test_bulk_history_create_change_reason_overrides_default(self): for data in self.data: data._change_reason = "my_reason" Poll.history.bulk_history_create(self.data, default_change_reason="test") self.assertTrue( all( [ history.history_change_reason == "my_reason" for history in Poll.history.all() ] ) ) def test_bulk_history_create_on_objs_without_ids(self): self.data = [ Poll(question="Question 1", pub_date=datetime.now()), Poll(question="Question 2", pub_date=datetime.now()), Poll(question="Question 3", pub_date=datetime.now()), Poll(question="Question 4", pub_date=datetime.now()), ] with self.assertRaises(IntegrityError): Poll.history.bulk_history_create(self.data) def test_set_custom_history_date_on_first_obj(self): self.data[0]._history_date = datetime(2000, 1, 1) Poll.history.bulk_history_create(self.data) self.assertEqual( Poll.history.order_by("question")[0].history_date, datetime(2000, 1, 1) ) def test_set_custom_history_user_on_first_obj(self): user = User.objects.create_user("tester", "tester@example.com") self.data[0]._history_user = user Poll.history.bulk_history_create(self.data) self.assertEqual(Poll.history.order_by("question")[0].history_user, user) @skipUnlessDBFeature("has_bulk_insert") def test_efficiency(self): with self.assertNumQueries(1): Poll.history.bulk_history_create(self.data) class BulkHistoryUpdateTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=datetime.now()), Poll(id=2, question="Question 2", pub_date=datetime.now()), Poll(id=3, question="Question 3", pub_date=datetime.now()), Poll(id=4, question="Question 4", pub_date=datetime.now()), ] def test_simple_bulk_history_create(self): created = Poll.history.bulk_history_create(self.data, update=True) self.assertEqual(len(created), 4) self.assertQuerySetEqual( Poll.history.order_by("question"), ["Question 1", "Question 2", "Question 3", "Question 4"], attrgetter("question"), ) self.assertTrue( all([history.history_type == "~" for history in Poll.history.all()]) ) created = Poll.history.bulk_create([]) self.assertEqual(created, []) self.assertEqual(Poll.history.count(), 4) def test_bulk_history_create_with_change_reason(self): for poll in self.data: poll._change_reason = "reason" Poll.history.bulk_history_create(self.data) self.assertTrue( all( [ history.history_change_reason == "reason" for history in Poll.history.all() ] ) ) 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) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_middleware.py000066400000000000000000000240061462567636100313620ustar00rootroot00000000000000from datetime import date from unittest import mock from django.http import HttpResponse from django.test import TestCase, override_settings from django.urls import reverse from simple_history.models import HistoricalRecords from simple_history.tests.custom_user.models import CustomUser from simple_history.tests.models import ( BucketDataRegisterRequestUser, BucketMember, Poll, ) from simple_history.tests.tests.utils import middleware_override_settings @override_settings(**middleware_override_settings) class MiddlewareTest(TestCase): def setUp(self): self.user = CustomUser.objects.create_superuser( "user_login", "u@example.com", "pass" ) def test_user_is_set_on_create_view_when_logged_in(self): self.client.force_login(self.user) data = {"question": "Test question", "pub_date": "2010-01-01"} self.client.post(reverse("poll-add"), data=data) polls = Poll.objects.all() self.assertEqual(polls.count(), 1) poll_history = polls.first().history.all() self.assertListEqual( [ph.history_user_id for ph in poll_history], [self.user.id] ) def test_user_is_not_set_on_create_view_not_logged_in(self): data = {"question": "Test question", "pub_date": "2010-01-01"} self.client.post(reverse("poll-add"), data=data) polls = Poll.objects.all() self.assertEqual(polls.count(), 1) poll_history = polls.first().history.all() self.assertListEqual([ph.history_user_id for ph in poll_history], [None]) def test_user_is_set_on_update_view_when_logged_in(self): self.client.force_login(self.user) poll = Poll.objects.create(question="Test question", pub_date=date.today()) data = {"question": "Test question updated", "pub_date": "2010-01-01"} self.client.post(reverse("poll-update", args=[poll.pk]), data=data) polls = Poll.objects.all() self.assertEqual(polls.count(), 1) poll = polls.first() self.assertEqual(poll.question, "Test question updated") poll_history = poll.history.all() self.assertListEqual( [ph.history_user_id for ph in poll_history], [self.user.id, None] ) def test_user_is_not_set_on_update_view_when_not_logged_in(self): poll = Poll.objects.create(question="Test question", pub_date=date.today()) data = {"question": "Test question updated", "pub_date": "2010-01-01"} self.client.post(reverse("poll-update", args=[poll.pk]), data=data) polls = Poll.objects.all() self.assertEqual(polls.count(), 1) poll = polls.first() self.assertEqual(poll.question, "Test question updated") poll_history = poll.history.all() self.assertListEqual([ph.history_user_id for ph in poll_history], [None, None]) def test_user_is_unset_on_update_view_after_logging_out(self): self.client.force_login(self.user) poll = Poll.objects.create(question="Test question", pub_date=date.today()) data = {"question": "Test question updated", "pub_date": "2010-01-01"} self.client.post(reverse("poll-update", args=[poll.pk]), data=data) polls = Poll.objects.all() self.assertEqual(polls.count(), 1) poll = polls.first() self.assertEqual(poll.question, "Test question updated") self.client.logout() new_data = { "question": "Test question updated part 2", "pub_date": "2010-01-01", } self.client.post(reverse("poll-update", args=[poll.pk]), data=new_data) polls = Poll.objects.all() self.assertEqual(polls.count(), 1) poll = polls.first() self.assertEqual(poll.question, "Test question updated part 2") poll_history = poll.history.all() self.assertListEqual( [ph.history_user_id for ph in poll_history], [None, self.user.id, None] ) def test_user_is_set_on_delete_view_when_logged_in(self): self.client.force_login(self.user) poll = Poll.objects.create(question="Test question", pub_date=date.today()) self.client.post(reverse("poll-delete", args=[poll.pk])) polls = Poll.objects.all() self.assertEqual(polls.count(), 0) poll_history = poll.history.all() self.assertListEqual( [ph.history_user_id for ph in poll_history], [self.user.id, None] ) def test_user_is_not_set_on_delete_view_when_not_logged_in(self): poll = Poll.objects.create(question="Test question", pub_date=date.today()) self.client.post(reverse("poll-delete", args=[poll.pk])) polls = Poll.objects.all() self.assertEqual(polls.count(), 0) poll_history = poll.history.all() self.assertListEqual([ph.history_user_id for ph in poll_history], [None, None]) def test_bucket_member_is_set_on_create_view_when_logged_in(self): self.client.force_login(self.user) member1 = BucketMember.objects.create(name="member1", user=self.user) data = {"data": "Test Data"} self.client.post(reverse("bucket_data-add"), data=data) bucket_datas = BucketDataRegisterRequestUser.objects.all() self.assertEqual(bucket_datas.count(), 1) history = bucket_datas.first().history.all() self.assertListEqual([h.history_user_id for h in history], [member1.id]) # The `request` attribute of `HistoricalRecords.context` should be deleted # even if this setting is set to `True` @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""" def assert_has_request_attr(has_attr: bool): self.assertEqual(hasattr(HistoricalRecords.context, "request"), has_attr) def mocked_get(*args, **kwargs): assert_has_request_attr(True) response_ = HttpResponse(status=200) response_.historical_records_request = HistoricalRecords.context.request return response_ func_mock.side_effect = mocked_get self.client.force_login(self.user) mockable_url = reverse("mockable") assert_has_request_attr(False) response = self.client.get(mockable_url) assert_has_request_attr(False) # Check that the `request` attr existed while handling the request self.assertEqual(response.historical_records_request.user, self.user) func_mock.side_effect = RuntimeError() with self.assertRaises(RuntimeError): self.client.get(mockable_url) # The request variable should be deleted even if an exception was raised assert_has_request_attr(False) @override_settings(**middleware_override_settings) class MiddlewareBulkOpsTest(TestCase): def setUp(self): self.user = CustomUser.objects.create_superuser( "user_login", "u@example.com", "pass" ) def test_user_is_set_on_bulk_create_view_when_logged_in(self): self.client.force_login(self.user) self.client.post(reverse("poll-bulk-create"), data={}) polls = Poll.objects.all() self.assertEqual(len(polls), 2) poll_history = Poll.history.all() self.assertCountEqual( [ph.history_user_id for ph in poll_history], [self.user.id, self.user.id] ) def test_user_is_not_set_on_bulk_create_view_not_logged_in(self): self.client.post(reverse("poll-bulk-create"), data={}) polls = Poll.objects.all() self.assertEqual(polls.count(), 2) poll_history = Poll.history.all() self.assertListEqual([ph.history_user_id for ph in poll_history], [None, None]) def test_request_user_is_overwritten_by_default_user_on_bulk_create_view( self, ): self.client.force_login(self.user) self.client.post(reverse("poll-bulk-create-with-default-user"), data={}) polls = Poll.objects.all() self.assertEqual(len(polls), 2) poll_history = Poll.history.all() self.assertFalse(any(ph.history_user_id == self.user.id for ph in poll_history)) self.assertFalse(any(ph.history_user_id is None for ph in poll_history)) def test_user_is_set_on_bulk_update_view_when_logged_in(self): self.client.force_login(self.user) poll_1 = Poll.objects.create(question="Test question 1", pub_date=date.today()) poll_2 = Poll.objects.create( question="Test question 2", pub_date=date(2020, 1, 1) ) self.client.post(reverse("poll-bulk-update"), data={}) polls = Poll.objects.all() self.assertEqual(2, len(polls)) self.assertEqual("1", poll_1.history.latest("history_date").question) self.assertEqual("0", poll_2.history.latest("history_date").question) self.assertEqual( self.user.id, poll_1.history.latest("history_date").history_user_id ) self.assertEqual( self.user.id, poll_2.history.latest("history_date").history_user_id ) def test_user_is_not_set_on_bulk_update_view_when_not_logged_in(self): poll_1 = Poll.objects.create(question="Test question 1", pub_date=date.today()) poll_2 = Poll.objects.create( question="Test question 2", pub_date=date(2020, 1, 1) ) self.client.post(reverse("poll-bulk-update"), data={}) self.assertIsNone(poll_1.history.latest("history_date").history_user_id) self.assertIsNone(poll_2.history.latest("history_date").history_user_id) def test_request_user_is_overwritten_by_default_user_on_bulk_update(self): self.client.force_login(self.user) poll = Poll.objects.create(pub_date=date(2020, 1, 1), question="123") self.client.post(reverse("poll-bulk-update-with-default-user"), data={}) self.assertIsNotNone(poll.history.latest("history_date").history_user_id) self.assertNotEqual( self.user.id, poll.history.latest("history_date").history_user_id ) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_models.py000066400000000000000000003225551462567636100305420ustar00rootroot00000000000000import dataclasses import unittest import uuid import warnings from datetime import datetime, timedelta from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.files.base import ContentFile from django.db import IntegrityError, models from django.db.models.fields.proxy import OrderWrt from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone from simple_history import register from simple_history.exceptions import RelatedNameConflictError from simple_history.models import ( SIMPLE_HISTORY_REVERSE_ATTR_NAME, DeletedObject, HistoricalRecords, ModelChange, ModelDelta, is_historic, to_historic, ) from simple_history.signals import ( pre_create_historical_m2m_records, pre_create_historical_record, ) from simple_history.tests.tests.utils import ( database_router_override_settings, database_router_override_settings_history_in_diff_db, middleware_override_settings, ) from simple_history.utils import get_history_model_for_model, update_change_reason from ..external.models import ( ExternalModel, ExternalModelRegistered, ExternalModelWithCustomUserIdField, ) from ..models import ( AbstractBase, AdminProfile, BasePlace, Book, Bookcase, BucketData, BucketDataRegisterChangedBy, BucketMember, CharFieldChangeReasonModel, CharFieldFileModel, Choice, City, ConcreteAttr, ConcreteExternal, ConcreteUtil, Contact, ContactRegister, Country, CustomManagerNameModel, DefaultTextFieldChangeReasonModel, Document, Employee, ExternalModelSpecifiedWithAppParam, ExternalModelWithAppLabel, FileModel, ForeignKeyToSelfModel, HistoricalChoice, HistoricalCustomFKError, HistoricalPoll, HistoricalPollWithHistoricalIPAddress, HistoricalPollWithManyToMany_places, HistoricalState, InheritedRestaurant, Library, ManyToManyModelOther, ModelWithCustomAttrOneToOneField, ModelWithExcludedManyToMany, ModelWithFkToModelWithHistoryUsingBaseModelDb, ModelWithHistoryInDifferentDb, ModelWithHistoryUsingBaseModelDb, ModelWithMultipleNoDBIndex, ModelWithSingleNoDBIndexUnique, MultiOneToOne, MyOverrideModelNameRegisterMethod1, OverrideModelNameUsingBaseModel1, Person, Place, Poll, PollChildBookWithManyToMany, PollChildRestaurantWithManyToMany, PollInfo, PollWithAlternativeManager, PollWithExcludedFieldsWithDefaults, PollWithExcludedFKField, PollWithExcludeFields, PollWithHistoricalIPAddress, PollWithManyToMany, PollWithManyToManyCustomHistoryID, PollWithManyToManyWithIPAddress, PollWithNonEditableField, PollWithQuerySetCustomizations, PollWithSelfManyToMany, PollWithSeveralManyToMany, Province, Restaurant, SelfFK, Series, SeriesWork, State, Street, Temperature, TestHistoricParticipanToHistoricOrganization, TestHistoricParticipantToOrganization, TestOrganization, TestOrganizationWithHistory, TestParticipantToHistoricOrganization, UnicodeVerboseName, UnicodeVerboseNamePlural, UserTextFieldChangeReasonModel, UUIDDefaultModel, UUIDModel, WaterLevel, ) get_model = apps.get_model User = get_user_model() today = datetime(3021, 1, 1, 10, 0) tomorrow = today + timedelta(days=1) yesterday = today - timedelta(days=1) def get_fake_file(filename): fake_file = ContentFile("file data") fake_file.name = filename return fake_file class HistoricalRecordsTest(TestCase): 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() (record,) = p.history.all() self.assertRecordValues( record, Poll, { "question": "what's up?", "pub_date": today, "id": p.id, "history_type": "+", }, ) self.assertDatetimesEqual(record.history_date, datetime.now()) def test_update(self): Poll.objects.create(question="what's up?", pub_date=today) p = Poll.objects.get() p.pub_date = tomorrow p.save() update_change_reason(p, "future poll") update_record, create_record = p.history.all() self.assertRecordValues( create_record, Poll, { "question": "what's up?", "pub_date": today, "id": p.id, "history_change_reason": None, "history_type": "+", }, ) self.assertRecordValues( update_record, Poll, { "question": "what's up?", "pub_date": tomorrow, "id": p.id, "history_change_reason": "future poll", "history_type": "~", }, ) self.assertDatetimesEqual(update_record.history_date, datetime.now()) def test_delete_verify_change_reason_implicitly(self): p = Poll.objects.create(question="what's up?", pub_date=today) poll_id = p.id p._change_reason = "wrongEntry" p.delete() delete_record, create_record = Poll.history.all() self.assertRecordValues( create_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": None, "history_type": "+", }, ) self.assertRecordValues( delete_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": "wrongEntry", "history_type": "-", }, ) def test_delete_verify_change_reason_explicity(self): p = Poll.objects.create(question="what's up?", pub_date=today) poll_id = p.id p.delete() update_change_reason(p, "wrongEntry") delete_record, create_record = Poll.history.all() self.assertRecordValues( create_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": None, "history_type": "+", }, ) self.assertRecordValues( delete_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": "wrongEntry", "history_type": "-", }, ) def test_cascade_delete_history(self): thames = WaterLevel.objects.create(waters="Thames", level=2.5, date=today) nile = WaterLevel.objects.create(waters="Nile", level=2.5, date=today) self.assertEqual(len(thames.history.all()), 1) self.assertEqual(len(nile.history.all()), 1) nile.delete() self.assertEqual(len(thames.history.all()), 1) self.assertEqual(len(nile.history.all()), 0) def test_save_without_historical_record(self): pizza_place = Restaurant.objects.create(name="Pizza Place", rating=3) pizza_place.rating = 4 pizza_place.save_without_historical_record() pizza_place.rating = 6 pizza_place.save() update_record, create_record = Restaurant.updates.all() self.assertRecordValues( create_record, Restaurant, { "name": "Pizza Place", "rating": 3, "id": pizza_place.id, "history_type": "+", }, ) self.assertRecordValues( update_record, Restaurant, { "name": "Pizza Place", "rating": 6, "id": pizza_place.id, "history_type": "~", }, ) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_save_with_disabled_history(self): anthony = Person.objects.create(name="Anthony Gillard") anthony.name = "something else" anthony.save() self.assertEqual(Person.history.count(), 0) anthony.delete() self.assertEqual(Person.history.count(), 0) def test_save_without_historical_record_for_registered_model(self): model = ExternalModelSpecifiedWithAppParam.objects.create( name="registered model" ) self.assertTrue(hasattr(model, "save_without_historical_record")) def test_save_raises_exception(self): anthony = Person(name="Anthony Gillard") with self.assertRaises(RuntimeError): anthony.save_without_historical_record() self.assertFalse(hasattr(anthony, "skip_history_when_saving")) self.assertEqual(Person.history.count(), 0) anthony.save() self.assertEqual(Person.history.count(), 1) def test_foreignkey_field(self): why_poll = Poll.objects.create(question="why?", pub_date=today) how_poll = Poll.objects.create(question="how?", pub_date=today) choice = Choice.objects.create(poll=why_poll, votes=0) choice.poll = how_poll choice.save() update_record, create_record = Choice.history.all() self.assertRecordValues( create_record, Choice, {"poll_id": why_poll.id, "votes": 0, "id": choice.id, "history_type": "+"}, ) self.assertRecordValues( update_record, Choice, {"poll_id": how_poll.id, "votes": 0, "id": choice.id, "history_type": "~"}, ) def test_foreignkey_still_allows_reverse_lookup_via_set_attribute(self): lib = Library.objects.create() state = State.objects.create(library=lib) self.assertTrue(hasattr(lib, "state_set")) self.assertIsNone( state._meta.get_field("library").remote_field.related_name, "the '+' shouldn't leak through to the original " "model's field related_name", ) def test_file_field(self): filename = str(uuid.uuid4()) model = FileModel.objects.create(file=get_fake_file(filename)) self.assertEqual(model.file.name, f"files/{filename}") model.file.delete() update_record, create_record = model.history.all() self.assertEqual(create_record.file, f"files/{filename}") self.assertEqual(update_record.file, "") def test_file_field_with_char_field_setting(self): # setting means history table's file field is a CharField file_field = CharFieldFileModel.history.model._meta.get_field("file") self.assertIs(type(file_field), models.CharField) self.assertEqual(file_field.max_length, 100) # file field works the same as test_file_field() filename = str(uuid.uuid4()) model = CharFieldFileModel.objects.create(file=get_fake_file(filename)) self.assertEqual(model.file.name, f"files/{filename}") model.file.delete() update_record, create_record = model.history.all() self.assertEqual(create_record.file, f"files/{filename}") self.assertEqual(update_record.file, "") def test_inheritance(self): pizza_place = Restaurant.objects.create(name="Pizza Place", rating=3) pizza_place.rating = 4 pizza_place.save() update_record, create_record = Restaurant.updates.all() self.assertRecordValues( create_record, Restaurant, { "name": "Pizza Place", "rating": 3, "id": pizza_place.id, "history_type": "+", }, ) self.assertRecordValues( update_record, Restaurant, { "name": "Pizza Place", "rating": 4, "id": pizza_place.id, "history_type": "~", }, ) def test_reverse_historical(self): """Tests how we can go from instance to historical record.""" document = Document.objects.create() historic = document.history.all()[0] instance = historic.instance self.assertEqual( getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME).history_date, historic.history_date, ) def test_specify_history_user(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") document = Document.objects.create(changed_by=user1) document.changed_by = user2 document.save() document.changed_by = None document.save() self.assertEqual( [d.history_user for d in document.history.all()], [None, user2, user1] ) def test_specify_history_user_self_reference_delete(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") document = Document.objects.create(changed_by=user1) document.changed_by = user2 document.save() document.changed_by = None document.save() self.assertEqual( [d.history_user for d in document.history.all()], [None, user2, user1] ) # Change back to user1 document.changed_by = user1 document.save() # Deleting user1 will cascade delete the document, # but fails when it tries to make the historical # record for the deleted user1. # This test performs differently on Postgres vs. SQLite # because of how the two database handle database constraints try: user1.delete() except IntegrityError as e: self.fail(e) def test_specify_history_date_1(self): temperature = Temperature.objects.create( location="London", temperature=14, _history_date=today ) temperature.temperature = 16 temperature._history_date = yesterday temperature.save() self.assertEqual( [t.history_date for t in temperature.history.all()], [today, yesterday] ) def test_specify_history_date_2(self): river = WaterLevel.objects.create(waters="Thames", level=2.5, date=today) river.level = 2.6 river.date = yesterday river.save() for t in river.history.all(): self.assertEqual(t.date, t.history_date) def test_non_default_primary_key_save(self): book1 = Book.objects.create(isbn="1-84356-028-1") book2 = Book.objects.create(isbn="1-84356-028-2") library = Library.objects.create(book=book1) library.book = book2 library.save() library.book = None library.save() self.assertEqual( [lib.book_id for lib in library.history.all()], [None, book2.pk, book1.pk] ) def test_string_defined_foreign_key_save(self): library1 = Library.objects.create() library2 = Library.objects.create() state = State.objects.create(library=library1) state.library = library2 state.save() state.library = None state.save() self.assertEqual( [s.library_id for s in state.history.all()], [None, library2.pk, library1.pk], ) def test_self_referential_foreign_key(self): model = SelfFK.objects.create() other = SelfFK.objects.create() model.fk = model model.save() model.fk = other model.save() self.assertEqual( [m.fk_id for m in model.history.all()], [other.id, model.id, None] ) def test_to_field_foreign_key_save(self): country = Country.objects.create(code="US") country2 = Country.objects.create(code="CA") province = Province.objects.create(country=country) province.country = country2 province.save() self.assertEqual( [c.country_id for c in province.history.all()], [country2.code, country.code], ) def test_db_column_foreign_key_save(self): country = Country.objects.create(code="US") city = City.objects.create(country=country) country_field = City._meta.get_field("country") self.assertIn( getattr(country_field, "db_column"), str(city.history.all().query) ) def test_raw_save(self): document = Document() document.save_base(raw=True) self.assertEqual(document.history.count(), 0) document.save() self.assertRecordValues( document.history.get(), Document, {"changed_by_id": None, "id": document.id, "history_type": "~"}, ) def test_unicode_verbose_name(self): instance = UnicodeVerboseName() instance.save() self.assertEqual( "historical \u570b", instance.history.all()[0]._meta.verbose_name ) def test_user_can_set_verbose_name(self): b = Book(isbn="54321") b.save() self.assertEqual("dead trees", b.history.all()[0]._meta.verbose_name) def test_historical_verbose_name_follows_model_verbose_name(self): library = Library() library.save() self.assertEqual( "historical quiet please", library.history.get()._meta.verbose_name ) def test_unicode_verbose_name_plural(self): instance = UnicodeVerboseNamePlural() instance.save() self.assertEqual( "historical \u570b", instance.history.all()[0]._meta.verbose_name_plural ) def test_user_can_set_verbose_name_plural(self): b = Book(isbn="54321") b.save() self.assertEqual( "dead trees plural", b.history.all()[0]._meta.verbose_name_plural ) def test_historical_verbose_name_plural_follows_model_verbose_name_plural(self): library = Library() library.save() self.assertEqual( "historical quiet please plural", library.history.get()._meta.verbose_name_plural, ) def test_foreignkey_primarykey(self): """Test saving a tracked model with a `ForeignKey` primary key.""" poll = Poll(pub_date=today) poll.save() poll_info = PollInfo(poll=poll) poll_info.save() def test_model_with_excluded_fields(self): p = PollWithExcludeFields( question="what's up?", pub_date=today, place="The Pub" ) p.save() history = PollWithExcludeFields.history.all()[0] all_fields_names = [f.name for f in history._meta.fields] self.assertIn("question", all_fields_names) self.assertNotIn("pub_date", all_fields_names) self.assertEqual(history.question, p.question) self.assertEqual(history.place, p.place) most_recent = p.history.most_recent() self.assertIn("question", all_fields_names) self.assertNotIn("pub_date", all_fields_names) self.assertEqual(most_recent.__class__, PollWithExcludeFields) self.assertIn("pub_date", history._history_excluded_fields) self.assertEqual(most_recent.question, p.question) self.assertEqual(most_recent.place, p.place) def test_user_model_override(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") member1 = BucketMember.objects.create(name="member1", user=user1) member2 = BucketMember.objects.create(name="member2", user=user2) bucket_data = BucketData.objects.create(changed_by=member1) bucket_data.changed_by = member2 bucket_data.save() bucket_data.changed_by = None bucket_data.save() self.assertEqual( [d.history_user for d in bucket_data.history.all()], [None, member2, member1], ) def test_user_model_override_registered(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") member1 = BucketMember.objects.create(name="member1", user=user1) member2 = BucketMember.objects.create(name="member2", user=user2) bucket_data = BucketDataRegisterChangedBy.objects.create(changed_by=member1) bucket_data.changed_by = member2 bucket_data.save() bucket_data.changed_by = None bucket_data.save() self.assertEqual( [d.history_user for d in bucket_data.history.all()], [None, member2, member1], ) def test_uuid_history_id(self): entry = UUIDModel.objects.create() history = entry.history.all()[0] self.assertTrue(isinstance(history.history_id, uuid.UUID)) def test_uuid_default_history_id(self): entry = UUIDDefaultModel.objects.create() history = entry.history.all()[0] self.assertTrue(isinstance(history.history_id, uuid.UUID)) def test_default_history_change_reason(self): entry = CharFieldChangeReasonModel.objects.create(greeting="what's up?") history = entry.history.get() self.assertEqual(history.history_change_reason, None) def test_charfield_history_change_reason(self): # Default CharField and length entry = CharFieldChangeReasonModel.objects.create(greeting="what's up?") entry.greeting = "what is happening?" entry.save() update_change_reason(entry, "Change greeting.") history = entry.history.all()[0] field = history._meta.get_field("history_change_reason") self.assertTrue(isinstance(field, models.CharField)) self.assertTrue(field.max_length, 100) def test_default_textfield_history_change_reason(self): # TextField usage is determined by settings entry = DefaultTextFieldChangeReasonModel.objects.create(greeting="what's up?") entry.greeting = "what is happening?" entry.save() reason = "Change greeting" update_change_reason(entry, reason) history = entry.history.all()[0] field = history._meta.get_field("history_change_reason") self.assertTrue(isinstance(field, models.TextField)) self.assertEqual(history.history_change_reason, reason) def test_user_textfield_history_change_reason(self): # TextField instance is passed in init entry = UserTextFieldChangeReasonModel.objects.create(greeting="what's up?") entry.greeting = "what is happening?" entry.save() reason = "Change greeting" update_change_reason(entry, reason) history = entry.history.all()[0] field = history._meta.get_field("history_change_reason") self.assertTrue(isinstance(field, models.TextField)) self.assertEqual(history.history_change_reason, reason) def test_history_diff_includes_changed_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) 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) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) self.assertNotIn("pub_date", delta.changed_fields) def test_history_diff_includes_changed_fields_of_base_model(self): r = InheritedRestaurant.objects.create(name="McDonna", serves_hot_dogs=False) # change base model field r.name = "DonnutsKing" r.save() new_record, old_record = r.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) 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): history_model = obj.history.model self.assertEqual( history_model.__name__, f"Historical{obj._meta.model.__name__}" ) self.assertEqual(history_model._meta.db_table, expected_table_name) place = BasePlace.objects.create(name="Place Name") # This is set in `BasePlace.history` assert_table_name(place, "base_places_history") r = InheritedRestaurant.objects.create(name="KFC", serves_hot_dogs=True) self.assertTrue(isinstance(r, BasePlace)) # The default table name of the history model, # instead of inheriting from `BasePlace` assert_table_name(r, f"tests_Historical{r._meta.model.__name__}".lower()) def test_history_diff_with_incorrect_type(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertRaises(TypeError): new_record.diff_against("something") def test_history_diff_with_excluded_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record, excluded_fields=("question",)) 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) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record, included_fields=[]) 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"]) 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( question="what's up?", pub_date=today ) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) 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) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertRaises(KeyError): with self.assertNumQueries(0): new_record.diff_against(old_record, included_fields=["unknown_field"]) with self.assertNumQueries(0): new_record.diff_against(old_record, excluded_fields=["unknown_field"]) def test_history_with_custom_queryset(self): PollWithQuerySetCustomizations.objects.create( id=1, pub_date=today, question="Question 1" ) PollWithQuerySetCustomizations.objects.create( id=2, pub_date=today, question="Low Id" ) PollWithQuerySetCustomizations.objects.create( id=10, pub_date=today, question="Random" ) self.assertEqual( set( PollWithQuerySetCustomizations.history.low_ids().values_list( "question", flat=True ) ), {"Question 1", "Low Id"}, ) self.assertEqual( set( PollWithQuerySetCustomizations.history.questions().values_list( "question", flat=True ) ), {"Question 1"}, ) self.assertEqual( set( PollWithQuerySetCustomizations.history.low_ids() .questions() .values_list("question", flat=True) ), {"Question 1"}, ) class GetPrevRecordAndNextRecordTestCase(TestCase): def assertRecordsMatch(self, record_a, record_b): self.assertEqual(record_a, record_b) self.assertEqual(record_a.question, record_b.question) def setUp(self): self.poll = Poll(question="what's up?", pub_date=today) self.poll.save() def test_get_prev_record(self): self.poll.question = "ask questions?" self.poll.save() self.poll.question = "eh?" self.poll.save() self.poll.question = "one more?" self.poll.save() first_record = self.poll.history.filter(question="what's up?").get() second_record = self.poll.history.filter(question="ask questions?").get() third_record = self.poll.history.filter(question="eh?").get() fourth_record = self.poll.history.filter(question="one more?").get() with self.assertNumQueries(1): self.assertRecordsMatch(second_record.prev_record, first_record) with self.assertNumQueries(1): self.assertRecordsMatch(third_record.prev_record, second_record) with self.assertNumQueries(1): self.assertRecordsMatch(fourth_record.prev_record, third_record) def test_get_prev_record_none_if_only(self): self.assertEqual(self.poll.history.count(), 1) record = self.poll.history.get() self.assertIsNone(record.prev_record) def test_get_prev_record_none_if_earliest(self): self.poll.question = "ask questions?" self.poll.save() first_record = self.poll.history.filter(question="what's up?").get() self.assertIsNone(first_record.prev_record) def test_get_prev_record_with_custom_manager_name(self): instance = CustomManagerNameModel.objects.create(name="Test name 1") instance.name = "Test name 2" instance.save() first_record = instance.log.filter(name="Test name 1").get() second_record = instance.log.filter(name="Test name 2").get() self.assertEqual(second_record.prev_record, first_record) def test_get_prev_record_with_excluded_field(self): instance = PollWithExcludeFields.objects.create( question="what's up?", pub_date=today ) instance.question = "ask questions?" instance.save() first_record = instance.history.filter(question="what's up?").get() second_record = instance.history.filter(question="ask questions?").get() with self.assertNumQueries(1): self.assertRecordsMatch(second_record.prev_record, first_record) def test_get_next_record(self): self.poll.question = "ask questions?" self.poll.save() self.poll.question = "eh?" self.poll.save() self.poll.question = "one more?" self.poll.save() first_record = self.poll.history.filter(question="what's up?").get() second_record = self.poll.history.filter(question="ask questions?").get() third_record = self.poll.history.filter(question="eh?").get() fourth_record = self.poll.history.filter(question="one more?").get() self.assertIsNone(fourth_record.next_record) with self.assertNumQueries(1): self.assertRecordsMatch(first_record.next_record, second_record) with self.assertNumQueries(1): self.assertRecordsMatch(second_record.next_record, third_record) with self.assertNumQueries(1): self.assertRecordsMatch(third_record.next_record, fourth_record) def test_get_next_record_none_if_only(self): self.assertEqual(self.poll.history.count(), 1) record = self.poll.history.get() self.assertIsNone(record.next_record) def test_get_next_record_none_if_most_recent(self): self.poll.question = "ask questions?" self.poll.save() recent_record = self.poll.history.filter(question="ask questions?").get() self.assertIsNone(recent_record.next_record) def test_get_next_record_with_custom_manager_name(self): instance = CustomManagerNameModel.objects.create(name="Test name 1") instance.name = "Test name 2" instance.save() first_record = instance.log.filter(name="Test name 1").get() second_record = instance.log.filter(name="Test name 2").get() self.assertEqual(first_record.next_record, second_record) def test_get_next_record_with_excluded_field(self): instance = PollWithExcludeFields.objects.create( question="what's up?", pub_date=today ) instance.question = "ask questions?" instance.save() first_record = instance.history.filter(question="what's up?").get() second_record = instance.history.filter(question="ask questions?").get() with self.assertNumQueries(1): self.assertRecordsMatch(first_record.next_record, second_record) class CreateHistoryModelTests(unittest.TestCase): @staticmethod def create_history_model(model, inherited): custom_model_name_prefix = f"Mock{HistoricalRecords.DEFAULT_MODEL_NAME_PREFIX}" records = HistoricalRecords( # Provide a custom history model name, to prevent name collisions # with existing historical models custom_model_name=lambda name: f"{custom_model_name_prefix}{name}", ) records.module = model.__module__ return records.create_history_model(model, inherited) def test_create_history_model_has_expected_tracked_files_attr(self): def assert_tracked_fields_equal(model, expected_field_names): from .. import models history_model = getattr( models, f"{HistoricalRecords.DEFAULT_MODEL_NAME_PREFIX}{model.__name__}" ) self.assertListEqual( [field.name for field in history_model.tracked_fields], expected_field_names, ) assert_tracked_fields_equal( Poll, ["id", "question", "pub_date"], ) assert_tracked_fields_equal( PollWithNonEditableField, ["id", "question", "pub_date", "modified"], ) assert_tracked_fields_equal( PollWithExcludeFields, ["id", "question", "place"], ) assert_tracked_fields_equal( PollWithExcludedFieldsWithDefaults, ["id", "question"], ) assert_tracked_fields_equal( PollWithExcludedFKField, ["id", "question", "pub_date"], ) assert_tracked_fields_equal( PollWithAlternativeManager, ["id", "question", "pub_date"], ) assert_tracked_fields_equal( 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"], ) def test_create_history_model_with_one_to_one_field_to_integer_field(self): try: self.create_history_model(AdminProfile, False) except Exception: self.fail( "SimpleHistory should handle foreign keys to one to one" "fields to integer fields without throwing an exception" ) def test_create_history_model_with_one_to_one_field_to_char_field(self): try: self.create_history_model(Bookcase, False) except Exception: self.fail( "SimpleHistory should handle foreign keys to one to one" "fields to char fields without throwing an exception." ) def test_create_history_model_with_multiple_one_to_ones(self): try: self.create_history_model(MultiOneToOne, False) except Exception: self.fail( "SimpleHistory should handle foreign keys to one to one" "fields to one to one fields without throwing an " "exception." ) class CustomModelNameTests(unittest.TestCase): def verify_custom_model_name_feature( self, model, expected_class_name, expected_table_name ): history_model = model.history.model self.assertEqual(history_model.__name__, expected_class_name) self.assertEqual(history_model._meta.db_table, expected_table_name) def test_instantiate_history_model_with_custom_model_name_as_string(self): try: from ..models import OverrideModelNameAsString except ImportError: self.fail("{}OverrideModelNameAsString is in wrong module") expected_cls_name = "MyHistoricalCustomNameModel" self.verify_custom_model_name_feature( OverrideModelNameAsString(), expected_cls_name, f"tests_{expected_cls_name.lower()}", ) def test_register_history_model_with_custom_model_name_override(self): try: from ..models import OverrideModelNameRegisterMethod1 except ImportError: self.fail("OverrideModelNameRegisterMethod1 is in wrong module") cls = OverrideModelNameRegisterMethod1() expected_cls_name = "MyOverrideModelNameRegisterMethod1" self.verify_custom_model_name_feature( cls, expected_cls_name, f"tests_{expected_cls_name.lower()}" ) from simple_history import register from ..models import OverrideModelNameRegisterMethod2 try: register( OverrideModelNameRegisterMethod2, custom_model_name=lambda x: f"{x}", ) except ValueError: self.assertRaises(ValueError) def test_register_history_model_with_custom_model_name_from_abstract_model(self): cls = OverrideModelNameUsingBaseModel1 expected_cls_name = f"Audit{cls.__name__}" self.verify_custom_model_name_feature( cls, expected_cls_name, "tests_" + expected_cls_name.lower() ) def test_register_history_model_with_custom_model_name_from_external_model(self): from ..models import OverrideModelNameUsingExternalModel1 cls = OverrideModelNameUsingExternalModel1 expected_cls_name = f"Audit{cls.__name__}" self.verify_custom_model_name_feature( cls, expected_cls_name, "tests_" + expected_cls_name.lower() ) from ..models import OverrideModelNameUsingExternalModel2 cls = OverrideModelNameUsingExternalModel2 expected_cls_name = f"Audit{cls.__name__}" self.verify_custom_model_name_feature( cls, expected_cls_name, "external_" + expected_cls_name.lower() ) class AppLabelTest(TestCase): def get_table_name(self, manager): return manager.model._meta.db_table def test_explicit_app_label(self): self.assertEqual( self.get_table_name(ExternalModelWithAppLabel.objects), "external_externalmodelwithapplabel", ) self.assertEqual( self.get_table_name(ExternalModelWithAppLabel.history), "external_historicalexternalmodelwithapplabel", ) def test_default_app_label(self): self.assertEqual( self.get_table_name(ExternalModel.objects), "external_externalmodel" ) self.assertEqual( self.get_table_name(ExternalModel.history), "external_historicalexternalmodel", ) def test_register_app_label(self): self.assertEqual( self.get_table_name(ExternalModelSpecifiedWithAppParam.objects), "tests_externalmodelspecifiedwithappparam", ) self.assertEqual( self.get_table_name(ExternalModelSpecifiedWithAppParam.histories), "external_historicalexternalmodelspecifiedwithappparam", ) self.assertEqual( self.get_table_name(ExternalModelRegistered.objects), "external_externalmodelregistered", ) self.assertEqual( self.get_table_name(ExternalModelRegistered.histories), "tests_historicalexternalmodelregistered", ) self.assertEqual( self.get_table_name(ConcreteExternal.objects), "tests_concreteexternal" ) self.assertEqual( self.get_table_name(ConcreteExternal.history), "tests_historicalconcreteexternal", ) def test_get_model(self): self.assertEqual( get_model("external", "ExternalModelWithAppLabel"), ExternalModelWithAppLabel, ) self.assertEqual( get_model("external", "HistoricalExternalModelWithAppLabel"), ExternalModelWithAppLabel.history.model, ) self.assertEqual(get_model("external", "ExternalModel"), ExternalModel) self.assertEqual( get_model("external", "HistoricalExternalModel"), ExternalModel.history.model, ) self.assertEqual( get_model("tests", "ExternalModelSpecifiedWithAppParam"), ExternalModelSpecifiedWithAppParam, ) self.assertEqual( get_model("external", "HistoricalExternalModelSpecifiedWithAppParam"), ExternalModelSpecifiedWithAppParam.histories.model, ) self.assertEqual( get_model("external", "ExternalModelRegistered"), ExternalModelRegistered ) self.assertEqual( get_model("tests", "HistoricalExternalModelRegistered"), ExternalModelRegistered.histories.model, ) # Test that historical model is defined within app of concrete # model rather than abstract base model self.assertEqual(get_model("tests", "ConcreteExternal"), ConcreteExternal) self.assertEqual( get_model("tests", "HistoricalConcreteExternal"), ConcreteExternal.history.model, ) class HistoryManagerTest(TestCase): def test_most_recent(self): poll = Poll.objects.create(question="what's up?", pub_date=today) poll.question = "how's it going?" poll.save() poll.question = "why?" poll.save() poll.question = "how?" most_recent = poll.history.most_recent() self.assertEqual(most_recent.__class__, Poll) self.assertEqual(most_recent.question, "why?") def test_get_model(self): self.assertEqual(get_model("tests", "poll"), Poll) self.assertEqual(get_model("tests", "historicalpoll"), HistoricalPoll) def test_most_recent_on_model_class(self): Poll.objects.create(question="what's up?", pub_date=today) self.assertRaises(TypeError, Poll.history.most_recent) def test_most_recent_nonexistant(self): # Unsaved poll poll = Poll(question="what's up?", pub_date=today) self.assertRaises(Poll.DoesNotExist, poll.history.most_recent) # Deleted poll poll.save() poll.delete() self.assertRaises(Poll.DoesNotExist, poll.history.most_recent) def test_date_indexing_options(self): records = HistoricalRecords() delattr(settings, "SIMPLE_HISTORY_DATE_INDEX") self.assertTrue(records._date_indexing) settings.SIMPLE_HISTORY_DATE_INDEX = False self.assertFalse(records._date_indexing) settings.SIMPLE_HISTORY_DATE_INDEX = "Composite" self.assertEqual(records._date_indexing, "composite") settings.SIMPLE_HISTORY_DATE_INDEX = "foo" with self.assertRaises(ImproperlyConfigured): records._date_indexing settings.SIMPLE_HISTORY_DATE_INDEX = 42 with self.assertRaises(ImproperlyConfigured): records._date_indexing settings.SIMPLE_HISTORY_DATE_INDEX = None with self.assertRaises(ImproperlyConfigured): records._date_indexing delattr(settings, "SIMPLE_HISTORY_DATE_INDEX") def test_as_of(self): poll = Poll.objects.create(question="what's up?", pub_date=today) poll.question = "how's it going?" poll.save() poll.question = "why?" poll.save() poll.question = "how?" most_recent = poll.history.most_recent() self.assertEqual(most_recent.question, "why?") times = [r.history_date for r in poll.history.all()] def question_as_of(time): return poll.history.as_of(time).question self.assertEqual(question_as_of(times[0]), "why?") self.assertEqual(question_as_of(times[1]), "how's it going?") self.assertEqual(question_as_of(times[2]), "what's up?") def test_as_of_nonexistant(self): # Unsaved poll poll = Poll(question="what's up?", pub_date=today) time = datetime.now() self.assertRaises(Poll.DoesNotExist, poll.history.as_of, time) # Deleted poll poll.save() poll.delete() self.assertRaises(Poll.DoesNotExist, poll.history.as_of, time) def test_as_of_excluded_many_to_many_succeeds(self): other1 = ManyToManyModelOther.objects.create(name="test1") other2 = ManyToManyModelOther.objects.create(name="test2") m = ModelWithExcludedManyToMany.objects.create(name="test") m.other.add(other1, other2) # This will fail if the ManyToMany field is not excluded. self.assertEqual(m.history.as_of(datetime.now()), m) def test_foreignkey_field(self): why_poll = Poll.objects.create(question="why?", pub_date=today) how_poll = Poll.objects.create(question="how?", pub_date=today) choice = Choice.objects.create(poll=why_poll, votes=0) choice.poll = how_poll choice.save() most_recent = choice.history.most_recent() self.assertEqual(most_recent.poll.pk, how_poll.pk) times = [r.history_date for r in choice.history.all()] def poll_as_of(time): return choice.history.as_of(time).poll self.assertEqual(poll_as_of(times[0]).pk, how_poll.pk) self.assertEqual(poll_as_of(times[1]).pk, why_poll.pk) def test_abstract_inheritance(self): for klass in (ConcreteAttr, ConcreteUtil): obj = klass.objects.create() obj.save() update_record, create_record = klass.history.all() self.assertTrue(isinstance(update_record, AbstractBase)) self.assertTrue(isinstance(create_record, AbstractBase)) def test_invalid_bases(self): invalid_bases = (AbstractBase, "InvalidBases") for bases in invalid_bases: self.assertRaises(TypeError, HistoricalRecords, bases=bases) def test_import_related(self): field_object = HistoricalChoice._meta.get_field("poll") related_model = field_object.remote_field.related_model self.assertEqual(related_model, HistoricalChoice) def test_string_related(self): field_object = HistoricalState._meta.get_field("library") related_model = field_object.remote_field.related_model self.assertEqual(related_model, HistoricalState) def test_state_serialization_of_customfk(self): from django.db.migrations import state state.ModelState.from_model(HistoricalCustomFKError) class TestOrderWrtField(TestCase): """Check behaviour of _order field added by Meta.order_with_respect_to. The Meta.order_with_respect_to option adds an OrderWrt field named "_order", where OrderWrt is a proxy class for an IntegerField that sets some default options. The simple_history strategy is: - Convert to a plain IntegerField in the historical record - When restoring a historical instance, add the old value. This may result in duplicate ordering values and non-deterministic ordering. """ def setUp(self): """Create works in published order.""" s = self.series = Series.objects.create( name="The Chronicles of Narnia", author="C.S. Lewis" ) self.w_lion = s.works.create(title="The Lion, the Witch and the Wardrobe") self.w_caspian = s.works.create(title="Prince Caspian") self.w_voyage = s.works.create(title="The Voyage of the Dawn Treader") self.w_chair = s.works.create(title="The Silver Chair") self.w_horse = s.works.create(title="The Horse and His Boy") self.w_nephew = s.works.create(title="The Magician's Nephew") self.w_battle = s.works.create(title="The Last Battle") def test_order(self): """Confirm that works are ordered by creation.""" order = self.series.get_serieswork_order() expected = [ self.w_lion.pk, self.w_caspian.pk, self.w_voyage.pk, self.w_chair.pk, self.w_horse.pk, self.w_nephew.pk, self.w_battle.pk, ] self.assertSequenceEqual(order, expected) self.assertEqual(0, self.w_lion._order) self.assertEqual(1, self.w_caspian._order) self.assertEqual(2, self.w_voyage._order) self.assertEqual(3, self.w_chair._order) self.assertEqual(4, self.w_horse._order) self.assertEqual(5, self.w_nephew._order) self.assertEqual(6, self.w_battle._order) def test_order_field_in_historical_model(self): work_order_field = self.w_lion._meta.get_field("_order") self.assertEqual(type(work_order_field), OrderWrt) history = self.w_lion.history.all()[0] history_order_field = history._meta.get_field("_order") self.assertEqual(type(history_order_field), models.IntegerField) def test_history_object_has_order(self): history = self.w_lion.history.all()[0] self.assertEqual(self.w_lion._order, history.history_object._order) def test_restore_object_with_changed_order(self): # Change a title self.w_caspian.title = "Prince Caspian: The Return to Narnia" self.w_caspian.save() self.assertEqual(2, len(self.w_caspian.history.all())) self.assertEqual(1, self.w_caspian._order) # Switch to internal chronological order chronological = [ self.w_nephew.pk, self.w_lion.pk, self.w_horse.pk, self.w_caspian.pk, self.w_voyage.pk, self.w_chair.pk, self.w_battle.pk, ] self.series.set_serieswork_order(chronological) self.assertSequenceEqual(self.series.get_serieswork_order(), chronological) # This uses an update, not a save, so no new history is created w_caspian = SeriesWork.objects.get(id=self.w_caspian.id) self.assertEqual(2, len(w_caspian.history.all())) self.assertEqual(1, w_caspian.history.all()[0]._order) self.assertEqual(1, w_caspian.history.all()[1]._order) self.assertEqual(3, w_caspian._order) # Revert to first title, old order old = w_caspian.history.all()[1].history_object old.save() w_caspian = SeriesWork.objects.get(id=self.w_caspian.id) self.assertEqual(3, len(w_caspian.history.all())) self.assertEqual(1, w_caspian.history.all()[0]._order) self.assertEqual(1, w_caspian.history.all()[1]._order) self.assertEqual(1, w_caspian.history.all()[2]._order) self.assertEqual(1, w_caspian._order) # The order changed w_lion = SeriesWork.objects.get(id=self.w_lion.id) self.assertEqual(1, w_lion._order) # and is identical to another order # New order is non-deterministic around identical IDs series = Series.objects.get(id=self.series.id) order = series.get_serieswork_order() self.assertEqual(order[0], self.w_nephew.pk) self.assertTrue(order[1] in (self.w_lion.pk, self.w_caspian.pk)) self.assertTrue(order[2] in (self.w_lion.pk, self.w_caspian.pk)) self.assertEqual(order[3], self.w_horse.pk) self.assertEqual(order[4], self.w_voyage.pk) self.assertEqual(order[5], self.w_chair.pk) self.assertEqual(order[6], self.w_battle.pk) def test_migrations_include_order(self): from django.db.migrations import state model_state = state.ModelState.from_model(SeriesWork.history.model) found = False for name, field in model_state.fields.items(): if name == "_order": found = True self.assertEqual(type(field), models.IntegerField) self.assertTrue(found, "_order not in fields " + repr(model_state.fields)) class TestLatest(TestCase): """Test behavior of `latest()` without any field parameters""" def setUp(self): poll = Poll.objects.create(question="Does `latest()` work?", pub_date=yesterday) poll.pub_date = today poll.save() def write_history(self, new_attributes): poll_history = HistoricalPoll.objects.all() for historical_poll, new_values in zip(poll_history, new_attributes): for fieldname, value in new_values.items(): setattr(historical_poll, fieldname, value) historical_poll.save() def test_ordered(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": today}] ) self.assertEqual(HistoricalPoll.objects.latest().pk, 2) def test_jumbled(self): self.write_history( [{"pk": 1, "history_date": today}, {"pk": 2, "history_date": yesterday}] ) self.assertEqual(HistoricalPoll.objects.latest().pk, 1) def test_sameinstant(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": yesterday}] ) self.assertEqual(HistoricalPoll.objects.latest().pk, 2) class TestMissingOneToOne(TestCase): def setUp(self): self.manager1 = Employee.objects.create() self.manager2 = Employee.objects.create() self.employee = Employee.objects.create(manager=self.manager1) self.employee.manager = self.manager2 self.employee.save() self.manager1_id = self.manager1.id self.manager1.delete() def test_history_is_complete(self): historical_manager_ids = list( self.employee.history.order_by("pk").values_list("manager_id", flat=True) ) self.assertEqual(historical_manager_ids, [self.manager1_id, self.manager2.id]) def test_restore_employee(self): historical = self.employee.history.order_by("pk")[0] original = historical.instance self.assertEqual(original.manager_id, self.manager1_id) with self.assertRaises(Employee.DoesNotExist): original.manager class CustomTableNameTest1(TestCase): @staticmethod def get_table_name(manager): return manager.model._meta.db_table def test_custom_table_name(self): self.assertEqual(self.get_table_name(Contact.history), "contacts_history") def test_custom_table_name_from_register(self): self.assertEqual( self.get_table_name(ContactRegister.history), "contacts_register_history" ) class ExcludeFieldsTest(TestCase): def test_restore_pollwithexclude(self): poll = PollWithExcludeFields.objects.create( question="what's up?", pub_date=today ) historical = poll.history.order_by("pk")[0] with self.assertRaises(AttributeError): historical.pub_date original = historical.instance self.assertEqual(original.pub_date, poll.pub_date) class ExcludeFieldsForDeletedObjectTest(TestCase): def setUp(self): self.poll = PollWithExcludedFieldsWithDefaults.objects.create( question="what's up?", pub_date=today, max_questions=12 ) self.historical = self.poll.history.order_by("pk")[0] self.poll.delete() def test_restore_deleted_poll_exclude_fields(self): original = self.historical.instance # pub_date don't have default value so it will be None self.assertIsNone(original.pub_date) # same for max_questions self.assertIsNone(original.max_questions) def test_restore_deleted_poll_exclude_fields_with_defaults(self): poll = self.poll original = self.historical.instance self.assertEqual(original.expiration_time, poll.expiration_time) self.assertEqual(original.place, poll.place) self.assertEqual(original.min_questions, poll.min_questions) class ExcludeForeignKeyTest(TestCase): def setUp(self): self.poll = PollWithExcludedFKField.objects.create( question="Is it?", pub_date=today, place=Place.objects.create(name="Somewhere"), ) def get_first_historical(self): """ Retrieve the idx'th HistoricalPoll, ordered by time. """ return self.poll.history.order_by("history_date")[0] def test_instance_fk_value(self): historical = self.get_first_historical() original = historical.instance self.assertEqual(original.place, self.poll.place) def test_history_lacks_fk(self): historical = self.get_first_historical() with self.assertRaises(AttributeError): historical.place def test_nb_queries(self): with self.assertNumQueries(2): historical = self.get_first_historical() historical.instance def test_changed_value_lost(self): new_place = Place.objects.create(name="More precise") self.poll.place = new_place self.poll.save() historical = self.get_first_historical() instance = historical.instance self.assertEqual(instance.place, new_place) def add_static_history_ip_address(sender, **kwargs): history_instance = kwargs["history_instance"] history_instance.ip_address = "192.168.0.1" def add_static_history_ip_address_on_m2m(sender, rows, **kwargs): for row in rows: row.ip_address = "192.168.0.1" class ExtraFieldsStaticIPAddressTestCase(TestCase): def setUp(self): pre_create_historical_record.connect( add_static_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_static_history_ip_address", ) def tearDown(self): pre_create_historical_record.disconnect( add_static_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_static_history_ip_address", ) def test_extra_ip_address_field_populated_on_save(self): poll = PollWithHistoricalIPAddress.objects.create( question="Will it blend?", pub_date=today ) poll_history = poll.history.first() self.assertEqual("192.168.0.1", poll_history.ip_address) def test_extra_ip_address_field_not_present_on_poll(self): poll = PollWithHistoricalIPAddress.objects.create( question="Will it blend?", pub_date=today ) with self.assertRaises(AttributeError): poll.ip_address def add_dynamic_history_ip_address(sender, **kwargs): history_instance = kwargs["history_instance"] history_instance.ip_address = HistoricalRecords.context.request.META["REMOTE_ADDR"] @override_settings(**middleware_override_settings) class ExtraFieldsDynamicIPAddressTestCase(TestCase): def setUp(self): pre_create_historical_record.connect( add_dynamic_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_dynamic_history_ip_address", ) def tearDown(self): pre_create_historical_record.disconnect( add_dynamic_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_dynamic_history_ip_address", ) def test_signal_is_able_to_retrieve_request_from_context(self): data = {"question": "Will it blend?", "pub_date": "2018-10-30"} self.client.post(reverse("pollip-add"), data=data) polls = PollWithHistoricalIPAddress.objects.all() self.assertEqual(1, polls.count()) poll_history = polls[0].history.first() self.assertEqual("127.0.0.1", poll_history.ip_address) class WarningOnAbstractModelWithInheritFalseTest(TestCase): def test_warning_on_abstract_model_with_inherit_false(self): with warnings.catch_warnings(record=True) as w: class AbstractModelWithInheritFalse(models.Model): string = models.CharField() history = HistoricalRecords() class Meta: abstract = True self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) self.assertEqual( str(w[0].message), "HistoricalRecords added to abstract model " "(AbstractModelWithInheritFalse) without " "inherit=True", ) class MultiDBWithUsingTest(TestCase): """Asserts historical manager respects `using()` and the `using` keyword argument in `save()`. """ databases = {"default", "other"} db_name = "other" def test_multidb_with_using_not_on_default(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) self.assertRaises(ObjectDoesNotExist, model.history.get, name="1-84356-028-1") def test_multidb_with_using_is_on_dbtwo(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) try: model.history.using(self.db_name).get(name="1-84356-028-1") except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_and_fk_not_on_default(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) parent_model = ModelWithFkToModelWithHistoryUsingBaseModelDb.objects.using( self.db_name ).create(fk=model) self.assertRaises(ObjectDoesNotExist, parent_model.history.get, fk=model) def test_multidb_with_using_and_fk_on_dbtwo(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) parent_model = ModelWithFkToModelWithHistoryUsingBaseModelDb.objects.using( self.db_name ).create(fk=model) try: parent_model.history.using(self.db_name).get(fk=model) except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_not_on_default(self): model = ModelWithHistoryUsingBaseModelDb(name="1-84356-028-1") model.save(using=self.db_name) self.assertRaises(ObjectDoesNotExist, model.history.get, name="1-84356-028-1") def test_multidb_with_using_keyword_in_save_on_dbtwo(self): model = ModelWithHistoryUsingBaseModelDb(name="1-84356-028-1") model.save(using=self.db_name) try: model.history.using(self.db_name).get(name="1-84356-028-1") except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_with_fk(self): model = ModelWithHistoryUsingBaseModelDb(name="1-84356-028-1") model.save(using=self.db_name) parent_model = ModelWithFkToModelWithHistoryUsingBaseModelDb(fk=model) parent_model.save(using=self.db_name) # assert not created on default self.assertRaises(ObjectDoesNotExist, parent_model.history.get, fk=model) # assert created on dbtwo try: parent_model.history.using(self.db_name).get(fk=model) except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_and_update(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) model.save(using=self.db_name) self.assertEqual( ["+", "~"], [ obj.history_type for obj in model.history.using(self.db_name) .all() .order_by("history_date") ], ) def test_multidb_with_using_keyword_in_save_and_delete(self): HistoricalModelWithHistoryUseBaseModelDb = get_history_model_for_model( ModelWithHistoryUsingBaseModelDb ) model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) model.save(using=self.db_name) model.delete(using=self.db_name) self.assertEqual( ["+", "~", "-"], [ obj.history_type for obj in HistoricalModelWithHistoryUseBaseModelDb.objects.using( self.db_name ) .all() .order_by("history_date") ], ) class ForeignKeyToSelfTest(TestCase): def setUp(self): self.model = ForeignKeyToSelfModel self.history_model = self.model.history.model def test_foreign_key_to_self_using_model_str(self): self.assertEqual( self.model, self.history_model.fk_to_self.field.remote_field.model ) def test_foreign_key_to_self_using_self_str(self): self.assertEqual( self.model, self.history_model.fk_to_self_using_str.field.remote_field.model ) class SeveralManyToManyTest(TestCase): def setUp(self): self.model = PollWithSeveralManyToMany self.history_model = self.model.history.model self.place = Place.objects.create(name="Home") self.book = Book.objects.create(isbn="1234") self.restaurant = Restaurant.objects.create(rating=1) self.poll = PollWithSeveralManyToMany.objects.create( question="what's up?", pub_date=today ) def test_separation(self): self.assertEqual(self.poll.history.all().count(), 1) self.poll.places.add(self.place) self.poll.books.add(self.book) self.poll.restaurants.add(self.restaurant) self.assertEqual(self.poll.history.all().count(), 4) restaurant, book, place, add = self.poll.history.all() self.assertEqual(restaurant.restaurants.all().count(), 1) self.assertEqual(restaurant.books.all().count(), 1) self.assertEqual(restaurant.places.all().count(), 1) self.assertEqual(restaurant.restaurants.first().restaurant, self.restaurant) self.assertEqual(book.restaurants.all().count(), 0) self.assertEqual(book.books.all().count(), 1) self.assertEqual(book.places.all().count(), 1) self.assertEqual(book.books.first().book, self.book) self.assertEqual(place.restaurants.all().count(), 0) self.assertEqual(place.books.all().count(), 0) self.assertEqual(place.places.all().count(), 1) self.assertEqual(place.places.first().place, self.place) self.assertEqual(add.restaurants.all().count(), 0) self.assertEqual(add.books.all().count(), 0) self.assertEqual(add.places.all().count(), 0) class InheritedManyToManyTest(TestCase): def setUp(self): self.model_book = PollChildBookWithManyToMany self.model_rstr = PollChildRestaurantWithManyToMany self.place = Place.objects.create(name="Home") self.book = Book.objects.create(isbn="1234") self.restaurant = Restaurant.objects.create(rating=1) self.poll_book = self.model_book.objects.create( question="what's up?", pub_date=today ) self.poll_rstr = self.model_rstr.objects.create( question="what's up?", pub_date=today ) def test_separation(self): self.assertEqual(self.poll_book.history.all().count(), 1) self.poll_book.places.add(self.place) self.poll_book.books.add(self.book) self.assertEqual(self.poll_book.history.all().count(), 3) self.assertEqual(self.poll_rstr.history.all().count(), 1) self.poll_rstr.places.add(self.place) self.poll_rstr.restaurants.add(self.restaurant) self.assertEqual(self.poll_rstr.history.all().count(), 3) book, place, add = self.poll_book.history.all() self.assertEqual(book.books.all().count(), 1) self.assertEqual(book.places.all().count(), 1) self.assertEqual(book.books.first().book, self.book) self.assertEqual(place.books.all().count(), 0) self.assertEqual(place.places.all().count(), 1) self.assertEqual(place.places.first().place, self.place) self.assertEqual(add.books.all().count(), 0) self.assertEqual(add.places.all().count(), 0) restaurant, place, add = self.poll_rstr.history.all() self.assertEqual(restaurant.restaurants.all().count(), 1) self.assertEqual(restaurant.places.all().count(), 1) self.assertEqual(restaurant.restaurants.first().restaurant, self.restaurant) self.assertEqual(place.restaurants.all().count(), 0) self.assertEqual(place.places.all().count(), 1) self.assertEqual(place.places.first().place, self.place) self.assertEqual(add.restaurants.all().count(), 0) self.assertEqual(add.places.all().count(), 0) def test_self_field(self): poll1 = PollWithSelfManyToMany.objects.create() poll2 = PollWithSelfManyToMany.objects.create() self.assertEqual(poll1.history.all().count(), 1) poll1.relations.add(poll2) self.assertIn(poll2, poll1.relations.all()) self.assertEqual(poll1.history.all().count(), 2) class ManyToManyWithSignalsTest(TestCase): def setUp(self): self.model = PollWithManyToManyWithIPAddress self.places = ( Place.objects.create(name="London"), Place.objects.create(name="Paris"), ) self.poll = self.model.objects.create(question="what's up?", pub_date=today) pre_create_historical_m2m_records.connect( add_static_history_ip_address_on_m2m, dispatch_uid="add_static_history_ip_address_on_m2m", ) def tearDown(self): pre_create_historical_m2m_records.disconnect( add_static_history_ip_address_on_m2m, dispatch_uid="add_static_history_ip_address_on_m2m", ) def test_ip_address_added(self): self.poll.places.add(*self.places) places = self.poll.history.first().places self.assertEqual(2, places.count()) for place in places.all(): self.assertEqual("192.168.0.1", place.ip_address) def test_extra_field(self): self.poll.places.add(*self.places) m2m_record = self.poll.history.first().places.first() self.assertEqual( m2m_record.get_class_name(), "HistoricalPollWithManyToManyWithIPAddress_places", ) def test_diff(self): self.poll.places.clear() self.poll.places.add(*self.places) new = self.poll.history.first() old = new.prev_record 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): def setUp(self): self.model = PollWithManyToManyCustomHistoryID self.history_model = self.model.history.model self.place = Place.objects.create(name="Home") self.poll = self.model.objects.create(question="what's up?", pub_date=today) class ManyToManyTest(TestCase): def setUp(self): self.model = PollWithManyToMany self.history_model = self.model.history.model self.place = Place.objects.create(name="Home") self.poll = PollWithManyToMany.objects.create( question="what's up?", pub_date=today ) 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): # There should be 1 history record for our poll, the create from setUp self.assertEqual(self.poll.history.all().count(), 1) # The created history row should be normal and correct (record,) = self.poll.history.all() self.assertRecordValues( record, self.model, { "question": "what's up?", "pub_date": today, "id": self.poll.id, "history_type": "+", }, ) self.assertDatetimesEqual(record.history_date, datetime.now()) historical_poll = self.poll.history.all()[0] # There should be no places associated with the current poll yet self.assertEqual(historical_poll.places.count(), 0) # Add a many-to-many child self.poll.places.add(self.place) # A new history row has been created by adding the M2M self.assertEqual(self.poll.history.all().count(), 2) # The new row has a place attached to it m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.count(), 1) # And the historical place is the correct one historical_place = m2m_record.places.first() self.assertEqual(historical_place.place, self.place) def test_remove(self): # Add and remove a many-to-many child self.poll.places.add(self.place) self.poll.places.remove(self.place) # Two new history exist for the place add & remove self.assertEqual(self.poll.history.all().count(), 3) # The newest row has no place attached to it m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.count(), 0) # The previous one should have one place previous_m2m_record = m2m_record.prev_record self.assertEqual(previous_m2m_record.places.count(), 1) # And the previous row still has the correct one historical_place = previous_m2m_record.places.first() self.assertEqual(historical_place.place, self.place) def test_clear(self): # Add some places place_2 = Place.objects.create(name="Place 2") place_3 = Place.objects.create(name="Place 3") place_4 = Place.objects.create(name="Place 4") self.poll.places.add(self.place) self.poll.places.add(place_2) self.poll.places.add(place_3) self.poll.places.add(place_4) # Should be 5 history rows, one for the create, one from each add self.assertEqual(self.poll.history.all().count(), 5) # Most recent should have 4 places m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.all().count(), 4) # Previous one should have 3 prev_record = m2m_record.prev_record self.assertEqual(prev_record.places.all().count(), 3) # Clear all places self.poll.places.clear() # Clearing M2M should create a new history entry self.assertEqual(self.poll.history.all().count(), 6) # Most recent should have no places m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.all().count(), 0) def test_delete_child(self): # Add a place original_place_id = self.place.id self.poll.places.add(self.place) self.assertEqual(self.poll.history.all().count(), 2) # Delete the place instance self.place.delete() # No new history row is created when the Place is deleted self.assertEqual(self.poll.history.all().count(), 2) # The newest row still has a place attached to it m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.count(), 1) # Place instance cannot be created... historical_place = m2m_record.places.first() with self.assertRaises(ObjectDoesNotExist): historical_place.place.id # But the values persist historical_place_values = m2m_record.places.all().values()[0] self.assertEqual(historical_place_values["history_id"], m2m_record.history_id) self.assertEqual(historical_place_values["place_id"], original_place_id) self.assertEqual(historical_place_values["pollwithmanytomany_id"], self.poll.id) def test_delete_parent(self): # Add a place self.poll.places.add(self.place) self.assertEqual(self.poll.history.all().count(), 2) # Delete the poll instance self.poll.delete() # History row is created when the Poll is deleted, but all m2m relations have # been deleted self.assertEqual(self.model.history.all().count(), 3) # Confirm the newest row (the delete) has no relations m2m_record = self.model.history.all()[0] self.assertEqual(m2m_record.places.count(), 0) # Confirm the previous row still has one prev_record = m2m_record.prev_record self.assertEqual(prev_record.places.count(), 1) # And it is the correct one historical_place = prev_record.places.first() self.assertEqual(historical_place.place, self.place) def test_update_child(self): self.poll.places.add(self.place) # Only two history rows, one for create and one for the M2M add self.assertEqual(self.poll.history.all().count(), 2) self.place.name = "Updated" self.place.save() # Updating the referenced M2M does not add history self.assertEqual(self.poll.history.all().count(), 2) # The newest row has the updated place m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.count(), 1) historical_place = m2m_record.places.first() self.assertEqual(historical_place.place.name, "Updated") def test_update_parent(self): self.poll.places.add(self.place) # Only two history rows, one for create and one for the M2M add self.assertEqual(self.poll.history.all().count(), 2) self.poll.question = "Updated?" self.poll.save() # Updating the model with the M2M on it creates new history self.assertEqual(self.poll.history.all().count(), 3) # The newest row still has the associated Place m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.count(), 1) historical_place = m2m_record.places.first() self.assertEqual(historical_place.place, self.place) def test_bulk_add_remove(self): # Add some places Place.objects.create(name="Place 2") Place.objects.create(name="Place 3") Place.objects.create(name="Place 4") # Bulk add all of the places self.poll.places.add(*Place.objects.all()) # Should be 2 history rows, one for the create, one from the bulk add self.assertEqual(self.poll.history.all().count(), 2) # Most recent should have 4 places m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.all().count(), 4) # Previous one should have 0 prev_record = m2m_record.prev_record self.assertEqual(prev_record.places.all().count(), 0) # Remove all places but the first self.poll.places.remove(*Place.objects.exclude(pk=self.place.pk)) self.assertEqual(self.poll.history.all().count(), 3) # Most recent should only have the first Place remaining m2m_record = self.poll.history.all()[0] self.assertEqual(m2m_record.places.all().count(), 1) 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) place_2 = Place.objects.create(name="Place 2") poll_2.places.add(self.place) poll_2.places.add(place_2) 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): 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) skip_poll.skip_history_when_saving = True skip_poll.question = "huh?" 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) del skip_poll.skip_history_when_saving place_2 = Place.objects.create(name="Place 2") skip_poll.places.add(place_2) self.assertEqual(skip_poll.history.all().count(), 2) self.assertEqual(skip_poll.history.all()[0].places.count(), 2) @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() 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}] ) expected_delta = ModelDelta( [expected_change], ["places"], create_record, add_record ) self.assertEqual(delta, expected_delta) with self.assertNumQueries(2): # Once for each record delta = add_record.diff_against(create_record, included_fields=["places"]) self.assertEqual(delta, expected_delta) 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() 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}], [] ) expected_delta = ModelDelta( [expected_change], ["places"], add_record, del_record ) self.assertEqual(delta, expected_delta) @override_settings(**database_router_override_settings) class MultiDBExplicitHistoryUserIDTest(TestCase): databases = {"default", "other"} def setUp(self): self.user = get_user_model().objects.create( username="username", email="username@test.com", password="top_secret" ) def test_history_user_with_fk_in_different_db_raises_value_error(self): instance = ExternalModel(name="random_name") instance._history_user = self.user with self.assertRaises(ValueError): instance.save() def test_history_user_with_integer_field(self): instance = ExternalModelWithCustomUserIdField(name="random_name") instance._history_user = self.user instance.save() self.assertEqual(self.user.id, instance.history.first().history_user_id) self.assertEqual(self.user, instance.history.first().history_user) def test_history_user_is_none(self): instance = ExternalModelWithCustomUserIdField.objects.create(name="random_name") self.assertIsNone(instance.history.first().history_user_id) self.assertIsNone(instance.history.first().history_user) def test_history_user_does_not_exist(self): instance = ExternalModelWithCustomUserIdField(name="random_name") instance._history_user = self.user instance.save() self.assertEqual(self.user.id, instance.history.first().history_user_id) self.assertEqual(self.user, instance.history.first().history_user) user_id = self.user.id self.user.delete() self.assertEqual(user_id, instance.history.first().history_user_id) self.assertIsNone(instance.history.first().history_user) class RelatedNameTest(TestCase): def setUp(self): 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( username="username_two", email="second@user.com", password="top_secret" ) self.one = Street(name="Test Street") self.one._history_user = self.user_one self.one.save() self.two = Street(name="Sesame Street") self.two._history_user = self.user_two self.two.save() self.one.name = "ABC Street" self.one._history_user = self.user_two self.one.save() def test_relation(self): self.assertEqual(self.one.history.count(), 2) self.assertEqual(self.two.history.count(), 1) def test_filter(self): self.assertEqual( Street.objects.filter(history__history_user=self.user_one.pk).count(), 1 ) self.assertEqual( Street.objects.filter(history__history_user=self.user_two.pk).count(), 2 ) def test_name_equals_manager(self): with self.assertRaises(RelatedNameConflictError): register(Place, manager_name="history", related_name="history") def test_deletion(self): self.two.delete() self.assertEqual(Street.log.filter(history_relation=2).count(), 2) self.assertEqual(Street.log.count(), 4) def test_revert(self): id = self.one.pk self.one.delete() self.assertEqual( Street.objects.filter(history__history_user=self.user_one.pk).count(), 0 ) self.assertEqual(Street.objects.filter(pk=id).count(), 0) old = Street.log.filter(id=id).first() old.history_object.save() self.assertEqual( Street.objects.filter(history__history_user=self.user_one.pk).count(), 1 ) self.one = Street.objects.get(pk=id) self.assertEqual(self.one.history.count(), 4) @override_settings(**database_router_override_settings_history_in_diff_db) class SaveHistoryInSeparateDatabaseTestCase(TestCase): databases = {"default", "other"} def setUp(self): self.model = ModelWithHistoryInDifferentDb.objects.create(name="test") def test_history_model_saved_in_separate_db(self): self.assertEqual(0, self.model.history.using("default").count()) self.assertEqual(1, self.model.history.count()) self.assertEqual(1, self.model.history.using("other").count()) self.assertEqual( 1, ModelWithHistoryInDifferentDb.objects.using("default").count() ) self.assertEqual(1, ModelWithHistoryInDifferentDb.objects.count()) self.assertEqual( 0, ModelWithHistoryInDifferentDb.objects.using("other").count() ) def test_history_model_saved_in_separate_db_on_delete(self): id = self.model.id self.model.delete() self.assertEqual( 0, ModelWithHistoryInDifferentDb.history.using("default") .filter(id=id) .count(), ) self.assertEqual(2, ModelWithHistoryInDifferentDb.history.filter(id=id).count()) self.assertEqual( 2, ModelWithHistoryInDifferentDb.history.using("other").filter(id=id).count(), ) self.assertEqual( 0, ModelWithHistoryInDifferentDb.objects.using("default").count() ) self.assertEqual(0, ModelWithHistoryInDifferentDb.objects.count()) self.assertEqual( 0, ModelWithHistoryInDifferentDb.objects.using("other").count() ) class ModelWithMultipleNoDBIndexTest(TestCase): def setUp(self): self.model = ModelWithMultipleNoDBIndex self.history_model = self.model.history.model def test_field_indices(self): for field in ["name", "fk"]: # dropped index self.assertTrue(self.model._meta.get_field(field).db_index) self.assertFalse(self.history_model._meta.get_field(field).db_index) # keeps index keeps_index = "%s_keeps_index" % field self.assertTrue(self.model._meta.get_field(keeps_index).db_index) self.assertTrue(self.history_model._meta.get_field(keeps_index).db_index) class ModelWithSingleNoDBIndexUniqueTest(TestCase): def setUp(self): self.model = ModelWithSingleNoDBIndexUnique self.history_model = self.model.history.model def test_unique_field_index(self): # Ending up with deferred fields (dont know why), using work around self.assertTrue(self.model._meta.get_field("name").db_index) self.assertFalse(self.history_model._meta.get_field("name").db_index) # keeps index self.assertTrue(self.model._meta.get_field("name_keeps_index").db_index) self.assertTrue(self.history_model._meta.get_field("name_keeps_index").db_index) class HistoricForeignKeyTest(TestCase): """ Tests chasing foreign keys across time points naturally with HistoricForeignKey. """ def test_non_historic_to_historic(self): """ Non-historic table foreign key 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". """ org = TestOrganizationWithHistory.objects.create(name="original") part = TestParticipantToHistoricOrganization.objects.create( name="part", organization=org ) before_mod = timezone.now() self.assertEqual(part.organization.id, org.id) self.assertEqual(org.participants.count(), 1) self.assertEqual(org.participants.all()[0], part) historg = TestOrganizationWithHistory.history.as_of(before_mod).get( name="original" ) self.assertEqual(historg.participants.count(), 1) self.assertEqual(historg.participants.all()[0], part) self.assertEqual(org.history.count(), 1) org.name = "modified" org.save() self.assertEqual(org.history.count(), 2) # drop internal caches, re-select part = TestParticipantToHistoricOrganization.objects.get(name="part") self.assertEqual(part.organization.name, "modified") def test_historic_to_non_historic(self): """ Historic table foreign key to non-historic table. In this case it should simply behave like ForeignKey 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 = TestHistoricParticipantToOrganization.objects.create( name="original", organization=org ) self.assertEqual(part.organization.id, org.id) self.assertEqual(org.participants.count(), 1) self.assertEqual(org.participants.all()[0], part) histpart = TestHistoricParticipantToOrganization.objects.get(name="original") self.assertEqual(histpart.organization.id, org.id) def test_historic_to_historic(self): """ Historic table foreign key 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, two participants, however the org's name has changed. At t3 we have one org, and one participant has left. """ org = TestOrganizationWithHistory.objects.create(name="original") p1 = TestHistoricParticipanToHistoricOrganization.objects.create( name="p1", organization=org ) t1_one_participant = timezone.now() p2 = TestHistoricParticipanToHistoricOrganization.objects.create( name="p2", organization=org ) org.name = "modified" org.save() t2_two_participants = timezone.now() p1.delete() t3_one_participant = timezone.now() # forward relationships - see how natural chasing timepoint relations is p1t1 = TestHistoricParticipanToHistoricOrganization.history.as_of( t1_one_participant ).get(name="p1") self.assertEqual(p1t1.organization, org) self.assertEqual(p1t1.organization.name, "original") p1t2 = TestHistoricParticipanToHistoricOrganization.history.as_of( t2_two_participants ).get(name="p1") self.assertEqual(p1t2.organization, org) self.assertEqual(p1t2.organization.name, "modified") p2t2 = TestHistoricParticipanToHistoricOrganization.history.as_of( t2_two_participants ).get(name="p2") self.assertEqual(p2t2.organization, org) self.assertEqual(p2t2.organization.name, "modified") p2t3 = TestHistoricParticipanToHistoricOrganization.history.as_of( t3_one_participant ).get(name="p2") self.assertEqual(p2t3.organization, org) self.assertEqual(p2t3.organization.name, "modified") # reverse relationships # at t1 ot1 = TestOrganizationWithHistory.history.as_of(t1_one_participant).all()[0] self.assertEqual(ot1.historic_participants.count(), 1) self.assertEqual(ot1.historic_participants.all()[0].name, p1.name) # at t2 ot2 = TestOrganizationWithHistory.history.as_of(t2_two_participants).all()[0] self.assertEqual(ot2.historic_participants.count(), 2) self.assertIn(p1.name, [item.name for item in ot2.historic_participants.all()]) self.assertIn(p2.name, [item.name for item in ot2.historic_participants.all()]) # at t3 ot3 = TestOrganizationWithHistory.history.as_of(t3_one_participant).all()[0] self.assertEqual(ot3.historic_participants.count(), 1) self.assertEqual(ot3.historic_participants.all()[0].name, p2.name) # current self.assertEqual(org.historic_participants.count(), 1) self.assertEqual(org.historic_participants.all()[0].name, p2.name) 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/jazzband/django-simple-history/issues/983 pt1h = TestHistoricParticipanToHistoricOrganization.history.all()[0] pt1i = pt1h.instance self.assertEqual(pt1i.organization.name, "modified") pt1h = TestHistoricParticipanToHistoricOrganization.history.all().order_by( "history_date" )[0] pt1i = pt1h.instance self.assertEqual(pt1i.organization.name, "original") jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_signals.py000066400000000000000000000100211462567636100306750ustar00rootroot00000000000000from datetime import datetime from django.test import TestCase from simple_history.signals import ( post_create_historical_m2m_records, post_create_historical_record, pre_create_historical_m2m_records, pre_create_historical_record, ) from ..models import Place, Poll, PollWithManyToMany today = datetime(2021, 1, 1, 10, 0) class PrePostCreateHistoricalRecordSignalTest(TestCase): def setUp(self): self.signal_was_called = False self.signal_instance = None self.signal_history_instance = None self.signal_sender = None self.field = None self.rows = None def test_pre_create_historical_record_signal(self): def handler(sender, instance, **kwargs): self.signal_was_called = True self.signal_instance = instance self.signal_history_instance = kwargs["history_instance"] self.signal_sender = sender pre_create_historical_record.connect(handler) p = Poll(question="what's up?", pub_date=today) p.save() self.assertTrue(self.signal_was_called) self.assertEqual(self.signal_instance, p) self.assertIsNotNone(self.signal_history_instance) self.assertEqual(self.signal_sender, p.history.first().__class__) def test_post_create_historical_record_signal(self): def handler(sender, instance, history_instance, **kwargs): self.signal_was_called = True self.signal_instance = instance self.signal_history_instance = history_instance self.signal_sender = sender post_create_historical_record.connect(handler) p = Poll(question="what's up?", pub_date=today) p.save() self.assertTrue(self.signal_was_called) self.assertEqual(self.signal_instance, p) self.assertIsNotNone(self.signal_history_instance) self.assertEqual(self.signal_sender, p.history.first().__class__) def test_pre_create_historical_m2m_records_signal(self): def handler(sender, rows, history_instance, instance, field, **kwargs): self.signal_was_called = True self.signal_instance = instance self.signal_history_instance = history_instance self.signal_sender = sender self.rows = rows self.field = field pre_create_historical_m2m_records.connect(handler) p = PollWithManyToMany( question="what's up?", pub_date=today, ) p.save() self.setUp() p.places.add( Place.objects.create(name="London"), Place.objects.create(name="Paris") ) self.assertTrue(self.signal_was_called) self.assertEqual(self.signal_instance, p) self.assertIsNotNone(self.signal_history_instance) self.assertEqual(self.signal_sender, p.history.first().places.model) self.assertEqual(self.field, PollWithManyToMany._meta.many_to_many[0]) self.assertEqual(len(self.rows), 2) def test_post_create_historical_m2m_records_signal(self): def handler(sender, created_rows, history_instance, instance, field, **kwargs): self.signal_was_called = True self.signal_instance = instance self.signal_history_instance = history_instance self.signal_sender = sender self.rows = created_rows self.field = field post_create_historical_m2m_records.connect(handler) p = PollWithManyToMany( question="what's up?", pub_date=today, ) p.save() self.setUp() p.places.add( Place.objects.create(name="London"), Place.objects.create(name="Paris") ) self.assertTrue(self.signal_was_called) self.assertEqual(self.signal_instance, p) self.assertIsNotNone(self.signal_history_instance) self.assertEqual(self.signal_sender, p.history.first().places.model) self.assertEqual(self.field, PollWithManyToMany._meta.many_to_many[0]) self.assertEqual(len(self.rows), 2) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_template_utils.py000066400000000000000000000322561462567636100323060ustar00rootroot00000000000000from datetime import datetime from typing import Tuple 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)) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_templatetags.py000066400000000000000000000006071462567636100317400ustar00rootroot00000000000000from django.test import TestCase from simple_history.templatetags.getattributes import getattribute class Foo: bar = "bar" class TestGetAttributes(TestCase): def test_get_existing_attributes_return_it(self): self.assertEqual(getattribute(Foo(), "bar"), "bar") def test_get_missing_attributes_return_None(self): self.assertIsNone(getattribute(Foo(), "baz")) jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/test_utils.py000066400000000000000000000564171462567636100304200ustar00rootroot00000000000000import unittest from datetime import datetime from unittest import skipUnless from unittest.mock import Mock, patch import django from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction from django.test import TestCase, TransactionTestCase, override_settings from django.utils import timezone from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError from simple_history.tests.models import ( BulkCreateManyToManyModel, Document, Place, Poll, PollChildBookWithManyToMany, PollChildRestaurantWithManyToMany, PollWithAlternativeManager, PollWithExcludeFields, PollWithHistoricalSessionAttr, PollWithManyToMany, PollWithManyToManyCustomHistoryID, PollWithManyToManyWithIPAddress, PollWithSelfManyToMany, PollWithSeveralManyToMany, PollWithUniqueQuestion, Street, ) from simple_history.utils import ( 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 = [ Poll(id=1, question="Question 1", pub_date=timezone.now()), Poll(id=2, question="Question 2", pub_date=timezone.now()), Poll(id=3, question="Question 3", pub_date=timezone.now()), Poll(id=4, question="Question 4", pub_date=timezone.now()), Poll(id=5, question="Question 5", pub_date=timezone.now()), ] self.data_with_excluded_fields = [ PollWithExcludeFields(id=1, question="Question 1", pub_date=timezone.now()), PollWithExcludeFields(id=2, question="Question 2", pub_date=timezone.now()), PollWithExcludeFields(id=3, question="Question 3", pub_date=timezone.now()), PollWithExcludeFields(id=4, question="Question 4", pub_date=timezone.now()), PollWithExcludeFields(id=5, question="Question 5", pub_date=timezone.now()), ] self.data_with_alternative_manager = [ PollWithAlternativeManager( id=1, question="Question 1", pub_date=timezone.now() ), PollWithAlternativeManager( id=2, question="Question 2", pub_date=timezone.now() ), PollWithAlternativeManager( id=3, question="Question 3", pub_date=timezone.now() ), PollWithAlternativeManager( id=4, question="Question 4", pub_date=timezone.now() ), PollWithAlternativeManager( id=5, question="Question 5", pub_date=timezone.now() ), ] self.data_with_duplicates = [ PollWithUniqueQuestion( pk=1, question="Question 1", pub_date=timezone.now() ), PollWithUniqueQuestion( pk=2, question="Question 2", pub_date=timezone.now() ), PollWithUniqueQuestion( pk=3, question="Question 1", pub_date=timezone.now() ), ] def test_bulk_create_history(self): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 5) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_bulk_create_history_with_disabled_setting(self): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 0) def test_bulk_create_history_alternative_manager(self): bulk_create_with_history( self.data, PollWithAlternativeManager, ) self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5) self.assertEqual(PollWithAlternativeManager.history.count(), 5) def test_bulk_create_history_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") bulk_create_with_history(self.data, Poll, default_user=user) self.assertTrue( all([history.history_user == user for history in Poll.history.all()]) ) def test_bulk_create_history_with_default_change_reason(self): bulk_create_with_history( self.data, Poll, default_change_reason="my change reason" ) self.assertTrue( all( [ history.history_change_reason == "my change reason" for history in Poll.history.all() ] ) ) def test_bulk_create_history_with_default_date(self): date = datetime(2020, 7, 1) bulk_create_with_history(self.data, Poll, default_date=date) self.assertTrue( all([history.history_date == date for history in Poll.history.all()]) ) def test_bulk_create_history_num_queries_is_two(self): with self.assertNumQueries(2): bulk_create_with_history(self.data, Poll) def test_bulk_create_history_on_model_without_history_raises_error(self): self.data = [ Place(id=1, name="Place 1"), Place(id=2, name="Place 2"), Place(id=3, name="Place 3"), ] with self.assertRaises(NotHistoricalModelError): bulk_create_with_history(self.data, Place) def test_num_queries_when_batch_size_is_less_than_total(self): with self.assertNumQueries(6): bulk_create_with_history(self.data, Poll, batch_size=2) def test_bulk_create_history_with_batch_size(self): bulk_create_with_history(self.data, Poll, batch_size=2) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 5) def test_bulk_create_works_with_excluded_fields(self): bulk_create_with_history(self.data_with_excluded_fields, PollWithExcludeFields) self.assertEqual(Poll.objects.count(), 0) self.assertEqual(Poll.history.count(), 0) self.assertEqual(PollWithExcludeFields.objects.count(), 5) self.assertEqual(PollWithExcludeFields.history.count(), 5) def test_bulk_create_history_with_relation_name(self): self.data = [ Street(name="Street 1"), Street(name="Street 2"), Street(name="Street 3"), Street(name="Street 4"), ] bulk_create_with_history(self.data, Street) self.assertEqual(Street.objects.count(), 4) self.assertEqual(Street.log.count(), 4) def test_bulk_create_history_with_duplicates(self): with transaction.atomic(), self.assertRaises(IntegrityError): bulk_create_with_history( self.data_with_duplicates, PollWithUniqueQuestion, ignore_conflicts=False, ) self.assertEqual(PollWithUniqueQuestion.objects.count(), 0) self.assertEqual(PollWithUniqueQuestion.history.count(), 0) def test_bulk_create_history_with_duplicates_ignore_conflicts(self): bulk_create_with_history( self.data_with_duplicates, PollWithUniqueQuestion, ignore_conflicts=True ) self.assertEqual(PollWithUniqueQuestion.objects.count(), 2) self.assertEqual(PollWithUniqueQuestion.history.count(), 2) def test_bulk_create_history_with_no_ids_return(self): pub_date = timezone.now() objects = [ Poll(question="Question 1", pub_date=pub_date), Poll(question="Question 2", pub_date=pub_date), Poll(question="Question 3", pub_date=pub_date), Poll(question="Question 4", pub_date=pub_date), Poll(question="Question 5", pub_date=pub_date), ] _bulk_create = Poll._default_manager.bulk_create def mock_bulk_create(*args, **kwargs): _bulk_create(*args, **kwargs) return [ Poll(question="Question 1", pub_date=pub_date), Poll(question="Question 2", pub_date=pub_date), Poll(question="Question 3", pub_date=pub_date), Poll(question="Question 4", pub_date=pub_date), Poll(question="Question 5", pub_date=pub_date), ] with patch.object( Poll._default_manager, "bulk_create", side_effect=mock_bulk_create ): with self.assertNumQueries(3): result = bulk_create_with_history(objects, Poll) self.assertEqual( [poll.question for poll in result], [poll.question for poll in objects] ) self.assertNotEqual(result[0].id, None) class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=timezone.now()), Poll(id=2, question="Question 2", pub_date=timezone.now()), Poll(id=3, question="Question 3", pub_date=timezone.now()), Poll(id=4, question="Question 4", pub_date=timezone.now()), Poll(id=5, question="Question 5", pub_date=timezone.now()), ] @patch( "simple_history.manager.HistoryManager.bulk_history_create", Mock(side_effect=Exception), ) def test_transaction_rolls_back_if_bulk_history_create_fails(self): with self.assertRaises(Exception): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 0) self.assertEqual(Poll.history.count(), 0) def test_bulk_create_history_on_objects_that_already_exist(self): Poll.objects.bulk_create(self.data) with self.assertRaises(IntegrityError): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 0) def test_bulk_create_history_rolls_back_when_last_exists(self): Poll.objects.create(id=5, question="Question 5", pub_date=timezone.now()) self.assertEqual(Poll.objects.count(), 1) self.assertEqual(Poll.history.count(), 1) with self.assertRaises(IntegrityError): bulk_create_with_history(self.data, Poll, batch_size=1) self.assertEqual(Poll.objects.count(), 1) self.assertEqual(Poll.history.count(), 1) def test_bulk_create_fails_with_wrong_model(self): with self.assertRaises(AttributeError): bulk_create_with_history(self.data, Document) self.assertEqual(Poll.objects.count(), 0) self.assertEqual(Poll.history.count(), 0) @patch("simple_history.utils.get_history_manager_for_model") def test_bulk_create_no_ids_return(self, hist_manager_mock): objects = [Place(id=1, name="Place 1")] model = Mock( _default_manager=Mock( bulk_create=Mock(return_value=[Place(name="Place 1")]), filter=Mock(return_value=Mock(order_by=Mock(return_value=objects))), ), _meta=Mock(get_fields=Mock(return_value=[])), ) result = bulk_create_with_history(objects, model) self.assertEqual(result, objects) hist_manager_mock().bulk_history_create.assert_called_with( objects, batch_size=None, default_user=None, default_change_reason=None, default_date=None, custom_historical_attrs=None, ) class BulkCreateWithManyToManyField(TestCase): def setUp(self): self.data = [ BulkCreateManyToManyModel(name="Object 1"), BulkCreateManyToManyModel(name="Object 2"), BulkCreateManyToManyModel(name="Object 3"), BulkCreateManyToManyModel(name="Object 4"), BulkCreateManyToManyModel(name="Object 5"), ] def test_bulk_create_with_history(self): bulk_create_with_history(self.data, BulkCreateManyToManyModel) self.assertEqual(BulkCreateManyToManyModel.objects.count(), 5) class BulkUpdateWithHistoryTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=timezone.now()), Poll(id=2, question="Question 2", pub_date=timezone.now()), Poll(id=3, question="Question 3", pub_date=timezone.now()), Poll(id=4, question="Question 4", pub_date=timezone.now()), Poll(id=5, question="Question 5", pub_date=timezone.now()), ] bulk_create_with_history(self.data, Poll) self.data[3].question = "Updated question" def test_bulk_update_history(self): bulk_update_with_history( self.data, Poll, fields=["question"], ) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.objects.get(id=4).question, "Updated question") self.assertEqual(Poll.history.count(), 10) self.assertEqual(Poll.history.filter(history_type="~").count(), 5) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_bulk_update_history_without_history_enabled(self): self.assertEqual(Poll.history.count(), 5) # because setup called with enabled settings bulk_update_with_history( self.data, Poll, fields=["question"], ) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.objects.get(id=4).question, "Updated question") self.assertEqual(Poll.history.count(), 5) self.assertEqual(Poll.history.filter(history_type="~").count(), 0) def test_bulk_update_history_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") bulk_update_with_history( self.data, Poll, fields=["question"], default_user=user ) self.assertTrue( all( [ history.history_user == user for history in Poll.history.filter(history_type="~") ] ) ) def test_bulk_update_history_with_default_change_reason(self): bulk_update_with_history( self.data, Poll, fields=["question"], default_change_reason="my change reason", ) self.assertTrue( all( [ history.history_change_reason == "my change reason" for history in Poll.history.filter(history_type="~") ] ) ) def test_bulk_update_history_with_default_date(self): date = datetime(2020, 7, 1) bulk_update_with_history( self.data, Poll, fields=["question"], default_date=date ) self.assertTrue( all( [ history.history_date == date for history in Poll.history.filter(history_type="~") ] ) ) def test_bulk_update_history_num_queries_is_two(self): with self.assertNumQueries(2): bulk_update_with_history( self.data, Poll, fields=["question"], ) def test_bulk_update_history_on_model_without_history_raises_error(self): self.data = [ Place(id=1, name="Place 1"), Place(id=2, name="Place 2"), Place(id=3, name="Place 3"), ] Place.objects.bulk_create(self.data) self.data[0].name = "test" with self.assertRaises(NotHistoricalModelError): bulk_update_with_history(self.data, Place, fields=["name"]) def test_num_queries_when_batch_size_is_less_than_total(self): with self.assertNumQueries(6): bulk_update_with_history(self.data, Poll, fields=["question"], batch_size=2) def test_bulk_update_history_with_batch_size(self): bulk_update_with_history(self.data, Poll, fields=["question"], batch_size=2) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.filter(history_type="~").count(), 5) @skipUnless(django.VERSION >= (4, 0), "Requires Django 4.0 or above") def test_bulk_update_with_history_returns_rows_updated(self): rows_updated = bulk_update_with_history( self.data, Poll, fields=["question"], ) self.assertEqual(rows_updated, 5) class BulkUpdateWithHistoryAlternativeManagersTestCase(TestCase): def setUp(self): self.data = [ PollWithAlternativeManager( id=1, question="Question 1", pub_date=timezone.now() ), PollWithAlternativeManager( id=2, question="Question 2", pub_date=timezone.now() ), PollWithAlternativeManager( id=3, question="Question 3", pub_date=timezone.now() ), PollWithAlternativeManager( id=4, question="Question 4", pub_date=timezone.now() ), PollWithAlternativeManager( id=5, question="Question 5", pub_date=timezone.now() ), ] bulk_create_with_history( self.data, PollWithAlternativeManager, ) def test_bulk_update_history_default_manager(self): self.data[3].question = "Updated question" bulk_update_with_history( self.data, PollWithAlternativeManager, fields=["question"], ) self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5) self.assertEqual( PollWithAlternativeManager.all_objects.get(id=4).question, "Updated question", ) self.assertEqual(PollWithAlternativeManager.history.count(), 10) self.assertEqual( PollWithAlternativeManager.history.filter(history_type="~").count(), 5 ) def test_bulk_update_history_other_manager(self): # filtered by default manager self.data[0].question = "Updated question" bulk_update_with_history( self.data, PollWithAlternativeManager, fields=["question"], manager=PollWithAlternativeManager.all_objects, ) self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5) self.assertEqual( PollWithAlternativeManager.all_objects.get(id=1).question, "Updated question", ) self.assertEqual(PollWithAlternativeManager.history.count(), 10) self.assertEqual( PollWithAlternativeManager.history.filter(history_type="~").count(), 5 ) def test_bulk_update_history_wrong_manager(self): with self.assertRaises(AlternativeManagerError): bulk_update_with_history( self.data, PollWithAlternativeManager, fields=["question"], manager=Poll.objects, ) class CustomHistoricalAttrsTest(TestCase): def setUp(self): self.data = [ PollWithHistoricalSessionAttr(id=x, question=f"Question {x}") for x in range(1, 6) ] def test_bulk_create_history_with_custom_model_attributes(self): bulk_create_with_history( self.data, PollWithHistoricalSessionAttr, custom_historical_attrs={"session": "jam"}, ) self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 5) self.assertEqual( PollWithHistoricalSessionAttr.history.filter(session="jam").count(), 5, ) def test_bulk_update_history_with_custom_model_attributes(self): bulk_create_with_history( self.data, PollWithHistoricalSessionAttr, custom_historical_attrs={"session": None}, ) bulk_update_with_history( self.data, PollWithHistoricalSessionAttr, fields=[], custom_historical_attrs={"session": "training"}, ) self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 5) self.assertEqual( PollWithHistoricalSessionAttr.history.filter(session="training").count(), 5, ) def test_bulk_manager_with_custom_model_attributes(self): history_manager = get_history_manager_for_model(PollWithHistoricalSessionAttr) history_manager.bulk_history_create( self.data, custom_historical_attrs={"session": "co-op"} ) self.assertEqual(PollWithHistoricalSessionAttr.objects.count(), 0) self.assertEqual( PollWithHistoricalSessionAttr.history.filter(session="co-op").count(), 5, ) class UpdateChangeReasonTestCase(TestCase): def test_update_change_reason_with_excluded_fields(self): poll = PollWithExcludeFields( question="what's up?", pub_date=timezone.now(), place="The Pub" ) poll.save() update_change_reason(poll, "Test change reason.") most_recent = poll.history.order_by("-history_date").first() self.assertEqual(most_recent.history_change_reason, "Test change reason.") jazzband-django-simple-history-2a2bec9/simple_history/tests/tests/utils.py000066400000000000000000000044151462567636100273500ustar00rootroot00000000000000from enum import Enum import django from django.conf import settings from simple_history.tests.models import HistoricalModelWithHistoryInDifferentDb request_middleware = "simple_history.middleware.HistoryRequestMiddleware" OTHER_DB_NAME = "other" middleware_override_settings = { "MIDDLEWARE": (settings.MIDDLEWARE + [request_middleware]) } class TestDbRouter: def db_for_read(self, model, **hints): if model._meta.app_label == "external": return OTHER_DB_NAME return None def db_for_write(self, model, **hints): if model._meta.app_label == "external": return OTHER_DB_NAME return None def allow_relation(self, obj1, obj2, **hints): if obj1._meta.app_label == "external" and obj2._meta.app_label == "external": return True return None def allow_migrate(self, db, app_label, model_name=None, **hints): if app_label == "external": return db == OTHER_DB_NAME elif db == OTHER_DB_NAME: return False else: return None database_router_override_settings = { "DATABASE_ROUTERS": ["simple_history.tests.tests.utils.TestDbRouter"] } class TestModelWithHistoryInDifferentDbRouter: def db_for_read(self, model, **hints): if model == HistoricalModelWithHistoryInDifferentDb: return OTHER_DB_NAME return None def db_for_write(self, model, **hints): if model == HistoricalModelWithHistoryInDifferentDb: return OTHER_DB_NAME return None def allow_relation(self, obj1, obj2, **hints): if isinstance(obj1, HistoricalModelWithHistoryInDifferentDb) or isinstance( obj2, HistoricalModelWithHistoryInDifferentDb ): return False return None def allow_migrate(self, db, app_label, model_name=None, **hints): if model_name == HistoricalModelWithHistoryInDifferentDb._meta.model_name: return db == OTHER_DB_NAME return None database_router_override_settings_history_in_diff_db = { "DATABASE_ROUTERS": [ "simple_history.tests.tests.utils.TestModelWithHistoryInDifferentDbRouter" ] } class PermissionAction(Enum): ADD = "add" CHANGE = "change" DELETE = "delete" VIEW = "view" jazzband-django-simple-history-2a2bec9/simple_history/tests/urls.py000066400000000000000000000036671462567636100260430ustar00rootroot00000000000000from django.contrib import admin from django.urls import path, re_path from simple_history.tests.view import ( BucketDataRegisterRequestUserCreate, BucketDataRegisterRequestUserDetail, MockableView, PollBulkCreateView, PollBulkCreateWithDefaultUserView, PollBulkUpdateView, PollBulkUpdateWithDefaultUserView, PollCreate, PollDelete, PollDetail, PollList, PollUpdate, PollWithHistoricalIPAddressCreate, ) from . import other_admin admin.autodiscover() urlpatterns = [ path("admin/", admin.site.urls), path("other-admin/", other_admin.site.urls), path( "bucket_data/add/", BucketDataRegisterRequestUserCreate.as_view(), name="bucket_data-add", ), re_path( r"^bucket_data/(?P[0-9]+)/$", BucketDataRegisterRequestUserDetail.as_view(), name="bucket_data-detail", ), path("poll/add/", PollCreate.as_view(), name="poll-add"), path( "pollwithhistoricalipaddress/add", PollWithHistoricalIPAddressCreate.as_view(), name="pollip-add", ), re_path(r"^poll/(?P[0-9]+)/$", PollUpdate.as_view(), name="poll-update"), re_path(r"^poll/(?P[0-9]+)/delete/$", PollDelete.as_view(), name="poll-delete"), re_path(r"^polls/(?P[0-9]+)/$", PollDetail.as_view(), name="poll-detail"), path("polls/", PollList.as_view(), name="poll-list"), path("polls-bulk-create/", PollBulkCreateView.as_view(), name="poll-bulk-create"), path( "polls-bulk-create-default-user/", PollBulkCreateWithDefaultUserView.as_view(), name="poll-bulk-create-with-default-user", ), path("polls-bulk-update/", PollBulkUpdateView.as_view(), name="poll-bulk-update"), path( "polls-bulk-update-default-user/", PollBulkUpdateWithDefaultUserView.as_view(), name="poll-bulk-update-with-default-user", ), path("mockable/", MockableView.as_view(), name="mockable"), ] jazzband-django-simple-history-2a2bec9/simple_history/tests/view.py000066400000000000000000000064261462567636100260240ustar00rootroot00000000000000from datetime import date from django.http import HttpResponse from django.urls import reverse_lazy from django.views import View from django.views.generic import ( CreateView, DeleteView, DetailView, ListView, UpdateView, ) from simple_history.tests.custom_user.models import CustomUser from simple_history.tests.models import ( BucketDataRegisterRequestUser, Poll, PollWithHistoricalIPAddress, ) from simple_history.utils import bulk_create_with_history, bulk_update_with_history class PollCreate(CreateView): model = Poll fields = ["question", "pub_date"] class PollBulkCreateView(View): def post(self, request, *args, **kwargs): poll_info_list = [ {"question": "1", "pub_date": date(2020, 1, 1)}, {"question": "2", "pub_date": date(2020, 1, 2)}, ] polls_to_create = [Poll(**poll_info) for poll_info in poll_info_list] bulk_create_with_history(polls_to_create, Poll) return HttpResponse(status=200) class PollBulkCreateWithDefaultUserView(View): def post(self, request, *args, **kwargs): default_user = CustomUser.objects.create_superuser( "test_user", "test_user@example.com", "pass" ) # Bulk create objects with history poll_info_list = [ {"question": "1", "pub_date": date(2020, 1, 1)}, {"question": "2", "pub_date": date(2020, 1, 2)}, ] polls_to_create = [Poll(**poll_info) for poll_info in poll_info_list] bulk_create_with_history(polls_to_create, Poll, default_user=default_user) return HttpResponse(status=200) class PollBulkUpdateView(View): def post(self, request, *args, **kwargs): polls = Poll.objects.order_by("pub_date") for i, poll in enumerate(polls): poll.question = str(i) bulk_update_with_history(polls, fields=["question"], model=Poll) return HttpResponse(status=201) class PollBulkUpdateWithDefaultUserView(View): def post(self, request, *args, **kwargs): default_user = CustomUser.objects.create_superuser( "test_user", "test_user@example.com", "pass" ) polls = Poll.objects.all() for i, poll in enumerate(polls): poll.question = str(i) bulk_update_with_history( polls, fields=["question"], model=Poll, default_user=default_user ) return HttpResponse(status=201) class PollWithHistoricalIPAddressCreate(CreateView): model = PollWithHistoricalIPAddress fields = ["question", "pub_date"] class PollUpdate(UpdateView): model = Poll fields = ["question", "pub_date"] class PollDelete(DeleteView): model = Poll success_url = reverse_lazy("poll-list") class PollList(ListView): model = Poll fields = ["question", "pub_date"] class PollDetail(DetailView): model = Poll fields = ["question", "pub_date"] class BucketDataRegisterRequestUserCreate(CreateView): model = BucketDataRegisterRequestUser fields = ["data"] class BucketDataRegisterRequestUserDetail(DetailView): model = BucketDataRegisterRequestUser fields = ["data"] class MockableView(View): """This view exists to easily mock a response.""" def get(self, request, *args, **kwargs): return HttpResponse(status=200) jazzband-django-simple-history-2a2bec9/simple_history/utils.py000066400000000000000000000226141462567636100250450ustar00rootroot00000000000000from django.db import transaction from django.db.models import Case, ForeignKey, ManyToManyField, Q, When from django.forms.models import model_to_dict from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError def update_change_reason(instance, reason): attrs = {} model = type(instance) manager = instance if instance.pk is not None else model history = get_history_manager_for_model(manager) history_fields = [field.attname for field in history.model._meta.fields] for field in instance._meta.fields: if field.attname not in history_fields: continue value = getattr(instance, field.attname) if field.primary_key is True: if value is not None: attrs[field.attname] = value else: attrs[field.attname] = value record = history.filter(**attrs).order_by("-history_date").first() record.history_change_reason = reason record.save() def get_history_manager_for_model(model): """Return the history manager for a given app model.""" try: manager_name = model._meta.simple_history_manager_attribute except AttributeError: raise NotHistoricalModelError(f"Cannot find a historical model for {model}.") return getattr(model, manager_name) def get_history_manager_from_history(history_instance): """ Return the history manager, based on an existing history instance. """ key_name = get_app_model_primary_key_name(history_instance.instance_type) return get_history_manager_for_model(history_instance.instance_type).filter( **{key_name: getattr(history_instance, key_name)} ) def get_history_model_for_model(model): """Return the history model for a given app model.""" return get_history_manager_for_model(model).model def get_app_model_primary_key_name(model): """Return the primary key name for a given app model.""" if isinstance(model._meta.pk, ForeignKey): return model._meta.pk.name + "_id" return model._meta.pk.name def 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, batch_size=None, ignore_conflicts=False, default_user=None, default_change_reason=None, default_date=None, custom_historical_attrs=None, ): """ 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) 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 :param batch_size: Number of objects that should be created in each batch :param default_user: Optional user to specify as the history_user in each historical record :param default_change_reason: Optional change reason to specify as the change_reason in each historical record :param default_date: Optional date to specify as the history_date in each historical record :param custom_historical_attrs: Optional dict of field `name`:`value` to specify values for custom fields :return: List of objs with IDs """ # Exclude ManyToManyFields because they end up as invalid kwargs to # model.objects.filter(...) below. exclude_fields = [ field.name for field in model._meta.get_fields() if isinstance(field, ManyToManyField) ] history_manager = get_history_manager_for_model(model) model_manager = model._default_manager second_transaction_required = True with transaction.atomic(savepoint=False): objs_with_id = model_manager.bulk_create( objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts ) if objs_with_id and objs_with_id[0].pk and not ignore_conflicts: second_transaction_required = False history_manager.bulk_history_create( objs_with_id, batch_size=batch_size, default_user=default_user, default_change_reason=default_change_reason, default_date=default_date, custom_historical_attrs=custom_historical_attrs, ) 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 cumulative_filter = None obj_when_list = [] for i, obj in enumerate(objs_with_id): attributes = dict( filter( lambda x: x[1] is not None, model_to_dict(obj, exclude=exclude_fields).items(), ) ) q = Q(**attributes) cumulative_filter = (cumulative_filter | q) if cumulative_filter else q # https://stackoverflow.com/a/49625179/1960509 # DEV: If an attribute has `then` as a key # then they'll also run into issues with `bulk_update` # due to shared implementation # https://github.com/django/django/blob/4.0.4/django/db/models/query.py#L624-L638 obj_when_list.append(When(**attributes, then=i)) obj_list = ( list( model_manager.filter(cumulative_filter).order_by( Case(*obj_when_list) ) ) if objs_with_id else [] ) history_manager.bulk_history_create( obj_list, batch_size=batch_size, default_user=default_user, default_change_reason=default_change_reason, default_date=default_date, custom_historical_attrs=custom_historical_attrs, ) objs_with_id = obj_list return objs_with_id def bulk_update_with_history( objs, model, fields, batch_size=None, default_user=None, default_change_reason=None, default_date=None, manager=None, custom_historical_attrs=None, ): """ Bulk update the objects specified by objs while also bulk creating their history (all in one transaction). :param objs: List of objs of type model to be updated :param model: Model class that should be updated :param fields: The fields that are updated. If empty, no model objects will be changed, but history records will still be created. :param batch_size: Number of objects that should be updated in each batch :param default_user: Optional user to specify as the history_user in each historical record :param default_change_reason: Optional change reason to specify as the change_reason in each historical record :param default_date: Optional date to specify as the history_date in each historical record :param manager: Optional model manager to use for the model instead of the default manager :param custom_historical_attrs: Optional dict of field `name`:`value` to specify values for custom fields :return: The number of model rows updated, not including any history objects """ history_manager = get_history_manager_for_model(model) model_manager = manager or model._default_manager if model_manager.model is not model: raise AlternativeManagerError("The given manager does not belong to the model.") with transaction.atomic(savepoint=False): if not fields: # Allow not passing any fields if the user wants to bulk-create history # records - e.g. with `custom_historical_attrs` provided # (Calling `bulk_update()` with no fields would have raised an error) rows_updated = 0 else: rows_updated = model_manager.bulk_update( objs, fields, batch_size=batch_size ) history_manager.bulk_history_create( objs, batch_size=batch_size, update=True, default_user=default_user, default_change_reason=default_change_reason, default_date=default_date, custom_historical_attrs=custom_historical_attrs, ) return rows_updated def get_change_reason_from_object(obj): if hasattr(obj, "_change_reason"): return getattr(obj, "_change_reason") return None jazzband-django-simple-history-2a2bec9/tox.ini000066400000000000000000000032731462567636100215740ustar00rootroot00000000000000[tox] envlist = 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 `psycopg` provides binaries for 3.13 py313-dj{42,50,main}-{sqlite3,mysql,mariadb}, docs, lint [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 3.11: py311, docs, lint 3.12: py312 3.13: py313 [gh-actions:env] DJANGO = 4.2: dj42 5.0: dj50 main: djmain [flake8] ignore = N802,F401,W503 max-complexity = 10 max-line-length = 88 exclude = __init__.py,simple_history/registry_tests/migration_test_app/migrations/* [testenv] deps = -rrequirements/test.txt dj42: Django>=4.2,<4.3 dj50: Django>=5.0,<5.1 djmain: https://github.com/django/django/tarball/main postgres: -rrequirements/postgres.txt mysql: -rrequirements/mysql.txt mariadb: -rrequirements/mysql.txt commands = sqlite3: coverage run -a runtests.py {posargs} postgres: coverage run -a runtests.py --database=postgres {posargs} mysql: coverage run -a runtests.py --database=mysql {posargs} mariadb: coverage run -a runtests.py --database=mariadb {posargs} coverage report [testenv:format] deps = -rrequirements/lint.txt commands = isort docs simple_history runtests.py black docs simple_history runtests.py flake8 simple_history [testenv:lint] deps = pre-commit commands = pre-commit run --all-files [testenv:docs] changedir = docs deps = -rrequirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html