pax_global_header00006660000000000000000000000064134176577130014530gustar00rootroot0000000000000052 comment=7afa08a0da2dd6a4e94ed822f9b9057ff12456a4 django-simple-history-2.7.0/000077500000000000000000000000001341765771300157665ustar00rootroot00000000000000django-simple-history-2.7.0/.codeclimate.yml000066400000000000000000000016431341765771300210440ustar00rootroot00000000000000version: "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/" django-simple-history-2.7.0/.coveragerc000066400000000000000000000001121341765771300201010ustar00rootroot00000000000000[run] include = simple_history/* omit = simple_history/tests/* branch = 1 django-simple-history-2.7.0/.editorconfig000066400000000000000000000006641341765771300204510ustar00rootroot00000000000000; 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 django-simple-history-2.7.0/.github/000077500000000000000000000000001341765771300173265ustar00rootroot00000000000000django-simple-history-2.7.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001341765771300215115ustar00rootroot00000000000000django-simple-history-2.7.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013761341765771300242120ustar00rootroot00000000000000--- 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. django-simple-history-2.7.0/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000010031341765771300252300ustar00rootroot00000000000000--- 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. django-simple-history-2.7.0/.gitignore000066400000000000000000000002201341765771300177500ustar00rootroot00000000000000*.egg *.egg-info/ *.eggs *.pyc .coverage .idea .tox/ /.project /.pydevproject /.ve build/ dist/ docs/_build htmlcov/ MANIFEST test_files/ venv/ django-simple-history-2.7.0/.travis.yml000066400000000000000000000022151341765771300200770ustar00rootroot00000000000000language: python sudo: false python: - 2.7 - 3.4 - 3.5 - 3.6 env: - DJANGO="Django>=1.11,<1.12" - DJANGO="Django>=2.0,<2.1" - DJANGO="Django>=2.1,<2.2" install: - pip install -U coverage codecov - pip install -U flake8==3.6.0 - pip install -U $DJANGO - if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then pip install black; fi - if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then pip install black; fi - pip freeze script: - flake8 simple_history - if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then black --check simple_history; fi - if [[ $TRAVIS_PYTHON_VERSION == '3.7' ]]; then black --check simple_history; fi - coverage run setup.py test matrix: exclude: - python: 2.7 env: DJANGO="Django>=2.0,<2.1" - python: 2.7 env: DJANGO="Django>=2.1,<2.2" - python: 3.4 env: DJANGO="Django>=2.1,<2.2" include: - python: 3.7 env: DJANGO="Django>=1.11,<1.12" dist: xenial sudo: true - python: 3.7 env: DJANGO="Django>=2.0,<2.1" dist: xenial sudo: true - python: 3.7 env: DJANGO="Django>=2.1,<2.2" dist: xenial sudo: true after_success: codecov django-simple-history-2.7.0/AUTHORS.rst000066400000000000000000000056601341765771300176540ustar00rootroot00000000000000Maintainers =========== - 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 - Ben Lawson (`blawson `_) - `bradford281 `_ - Brian Armstrong (`barm `_) - Buddy Lindsey, Jr. - Brian Dixon - Christopher Broderick (`uhurusurfa `_) - Corey Bertram - Damien Nozay - Daniel Gilge - Daniel Levy - Daniel Roschka - David Grochowski (`ThePumpingLemma `_) - David Hite - Eduardo Cuducos - Erik van Widenfelt (`erikvw `_) - Filipe Pina (@fopina) - Florian Eßer - Frank Sachsenheim - George Vilches - Gregory Bataille - Grzegorz Bialy - Hamish Downer - Hanyin Zhang - James Muranga (`jamesmura `_) - James Pulec - Jesse Shapiro - Jim Gomez - Joao Junior (`joaojunior `_) - Joao Pedro Francese - `jofusa `_ - John Whitlock - Jonathan Leroy - Jonathan Sanchez - Jonathan Zvesper (`zvesp `_) - Josh Fyne - Kevin Foster - Klaas van Schelven - Kris Neuharth - Kyle Seever (`kseever `_) - Leticia Portella - Lucas Wiman - Maciej "RooTer" Urbański - Mark Davidoff - Martin Bachwerk - Marty Alchin - Matheus Cansian (`mscansian `_) - Mauricio de Abreu Antunes - Micah Denbraver - Michael England - Miguel Vargas - Mike Spainhower - Nathan Villagaray-Carski (`ncvc `_) - Nianpeng Li - Phillip Marshall - Rajesh Pappula - Ray Logel - Roberto Aguilar - Rod Xavier Bondoc - Ross Lote - Ross Mechanic (`rossmechanic `_) - Ross Rogers - Sergey Ozeranskiy (`ozeranskiy `_) - Shane Engelman - Steeve Chailloux - Steven Klass - Tommy Beadle (`tbeadle `_) - Trey Hunner (`treyhunner `_) - Ulysses Vilela - `vnagendra `_ 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. django-simple-history-2.7.0/CHANGES.rst000066400000000000000000000201211341765771300175640ustar00rootroot00000000000000Changes ======= 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) 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. django-simple-history-2.7.0/CODE_OF_CONDUCT.md000066400000000000000000000062131341765771300205670ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ross@cadre.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ django-simple-history-2.7.0/CONTRIBUTING.rst000066400000000000000000000047371341765771300204420ustar00rootroot00000000000000Contributing to django-simple-history ===================================== 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 - `coverage`_: used for analyzing test coverage for tests - `Sphinx`_: used for generating documentation If not using a virtualenv, the command should be prepended with ``sudo``. .. _tox: http://testrun.org/tox/latest// .. _coverage: http://nedbatchelder.com/code/coverage/ .. _sphinx: http://sphinx-doc.org/ Documentation ------------- 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 setup.py test Code Formatting --------------- We make use of `black`_ for code formatting. .. _black: https://black.readthedocs.io/en/stable/installation_and_usage.html Once it is installed you can make sure the code is properly formatted by running:: make format 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 django-simple-history-2.7.0/LICENSE.txt000066400000000000000000000030011341765771300176030ustar00rootroot00000000000000BSD 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.django-simple-history-2.7.0/MANIFEST.in000066400000000000000000000002441341765771300175240ustar00rootroot00000000000000include MANIFEST.in include *.rst include *.txt recursive-include docs *.rst recursive-include simple_history/locale * recursive-include simple_history/templates * django-simple-history-2.7.0/Makefile000066400000000000000000000015071341765771300174310ustar00rootroot00000000000000all: init docs clean test clean: clean-build clean-pyc rm -fr htmlcov/ clean-build: rm -fr build/ rm -fr dist/ rm -fr *.egg-info clean-pyc: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + init: pip install "tox>=1.8" coverage Sphinx test: coverage erase tox coverage html docs: documentation documentation: sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html dist: clean pip install -U wheel python setup.py sdist python setup.py bdist_wheel 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: black simple_history django-simple-history-2.7.0/PULL_REQUEST_TEMPLATE.md000066400000000000000000000033221341765771300215670ustar00rootroot00000000000000 ## 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 `make format` command to format my code - [ ] 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. django-simple-history-2.7.0/README.rst000066400000000000000000000040121341765771300174520ustar00rootroot00000000000000django-simple-history ===================== .. image:: https://secure.travis-ci.org/treyhunner/django-simple-history.svg?branch=master :target: http://travis-ci.org/treyhunner/django-simple-history :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/treyhunner/django-simple-history/master.svg :target: http://codecov.io/github/treyhunner/django-simple-history?branch=master :alt: Test Coverage .. image:: https://img.shields.io/pypi/v/django-simple-history.svg :target: https://pypi.python.org/pypi/django-simple-history :alt: PyPI Version .. image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability :target: https://codeclimate.com/github/treyhunner/django-simple-history/maintainability :alt: Maintainability .. image:: https://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/ambv/black :alt: Code Style django-simple-history stores Django model state on every create/update/delete. This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= 1.11 2.7, 3.4, 3.5, 3.6, 3.7 2.0 3.4, 3.5, 3.6, 3.7 2.1 3.5, 3.6, 3.7 ========== ======================= Getting Help ------------ Documentation is available at https://django-simple-history.readthedocs.io/ Pull requests are welcome. Read the `CONTRIBUTING`_ file for tips on submitting a pull request. .. _CONTRIBUTING: https://github.com/treyhunner/django-simple-history/blob/master/CONTRIBUTING.rst License ------- This project is licensed under the `BSD 3-Clause license `_. django-simple-history-2.7.0/doc-requirements.txt000066400000000000000000000000461341765771300220150ustar00rootroot00000000000000Sphinx==1.2.3 sphinx-autobuild==0.3.0 django-simple-history-2.7.0/docs/000077500000000000000000000000001341765771300167165ustar00rootroot00000000000000django-simple-history-2.7.0/docs/Makefile000066400000000000000000000152461341765771300203660ustar00rootroot00000000000000# 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." django-simple-history-2.7.0/docs/_static/000077500000000000000000000000001341765771300203445ustar00rootroot00000000000000django-simple-history-2.7.0/docs/_static/.keep000066400000000000000000000000001341765771300212570ustar00rootroot00000000000000django-simple-history-2.7.0/docs/admin.rst000066400000000000000000000043221341765771300205410ustar00rootroot00000000000000Admin 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 django-simple-history-2.7.0/docs/common_issues.rst000066400000000000000000000107631341765771300223420ustar00rootroot00000000000000Common 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_ 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 .. 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 for each record in the bulk create, you can add `changeReason` on each instance: .. code-block:: pycon >>> for poll in data: poll.changeReason = 'reason' >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> Poll.history.get(id=data[0].id).history_change_reason 'reason' QuerySet Updates with History ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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.0/ref/models/querysets/#update 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. See :ref:`register`. 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 django-webtest with Middleware ------------------------------------ When using django-webtest_ to test your Django project with the django-simple-history middleware, you may run into an error similar to the following:: django.db.utils.IntegrityError: (1452, 'Cannot add or update a child row: a foreign key constraint fails (`test_env`.`core_historicaladdress`, CONSTRAINT `core_historicaladdress_history_user_id_0f2bed02_fk_user_user_id` FOREIGN KEY (`history_user_id`) REFERENCES `user_user` (`id`))') .. _django-webtest: https://github.com/django-webtest/django-webtest This error occurs because ``django-webtest`` sets ``DEBUG_PROPAGATE_EXCEPTIONS`` to true preventing the middleware from cleaning up the request. To solve this issue, add the following code to any ``clean_environment`` or ``tearDown`` method that you use: .. code-block:: python from simple_history.middleware import HistoricalRecords if hasattr(HistoricalRecords.thread, 'request'): del HistoricalRecords.thread.request 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 django-simple-history-2.7.0/docs/conf.py000066400000000000000000000177161341765771300202310ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # 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 sys, os # 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('..')) from simple_history import __version__ # -- 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'] # 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 = u'django-simple-history' copyright = u'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. # # The short X.Y version. version = __version__ # The full version, including alpha/beta/rc tags. release = __version__ # 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 = 'default' # 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', u'django-simple-history Documentation', u'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', u'django-simple-history Documentation', [u'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', u'django-simple-history Documentation', u'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 django-simple-history-2.7.0/docs/historical_model.rst000066400000000000000000000265641341765771300230060ustar00rootroot00000000000000Historical 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 ``history_id``s 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() 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. .. code-block:: python class ModelNameExample(models.Model): history = HistoricalRecords( custom_model_name='SimpleHistoricalModelNameExample' ) 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'] # thread.request for use only when the simple_history middleware is on and enabled history_instance.ip_address = HistoricalRecords.thread.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 ``changeReason`` of the instance. Also, is possible to pass the ``changeReason`` explicitly. For this, after a save or delete in an instance, is necessary 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.changeReason = '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. 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") django-simple-history-2.7.0/docs/history_diffing.rst000066400000000000000000000015701341765771300226420ustar00rootroot00000000000000History Diffing =============== When you have two instances of the same ``HistoricalRecord`` (such as the ``HistoricalPoll`` example above), you can perform diffs to see what changed. This will result in a ``ModelDelta`` containing the following properties: 1. A list with each field changed between the two historical records 2. A list with the names of all fields that incurred changes from one record to the other 3. the old and new records. This may be useful when you want to construct timelines and need to get only the model modifications. .. code-block:: python p = Poll.objects.create(question="what's up?") p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() delta = new_record.diff_against(old_record) for change in delta.changes: print("{} changed from {} to {}".format(change.field, change.old, change.new)) django-simple-history-2.7.0/docs/index.rst000066400000000000000000000040211341765771300205540ustar00rootroot00000000000000django-simple-history ===================== .. image:: https://secure.travis-ci.org/treyhunner/django-simple-history.svg?branch=master :target: http://travis-ci.org/treyhunner/django-simple-history :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/treyhunner/django-simple-history/master.svg :target: http://codecov.io/github/treyhunner/django-simple-history?branch=master :alt: Test Coverage .. image:: https://img.shields.io/pypi/v/django-simple-history.svg :target: https://pypi.python.org/pypi/django-simple-history :alt: PyPI Version .. image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability :target: https://codeclimate.com/github/treyhunner/django-simple-history/maintainability :alt: Maintainability .. image:: https://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/ambv/black :alt: Code Style django-simple-history stores Django model state on every create/update/delete. This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= 1.11 2.7, 3.4, 3.5, 3.6, 3.7 2.0 3.4, 3.5, 3.6, 3.7 2.1 3.5, 3.6, 3.7 ========== ======================= Contribute ---------- - Issue Tracker: https://github.com/treyhunner/django-simple-history/issues - Source Code: https://github.com/treyhunner/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 django-simple-history-2.7.0/docs/make.bat000066400000000000000000000145311341765771300203270ustar00rootroot00000000000000@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 django-simple-history-2.7.0/docs/multiple_dbs.rst000066400000000000000000000027241341765771300221400ustar00rootroot00000000000000Multiple 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`. django-simple-history-2.7.0/docs/querying_history.rst000066400000000000000000000114341341765771300230770ustar00rootroot00000000000000Querying 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, a.k. 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 ----- This method will return an instance of the model as it would have existed at the provided date and time. .. code-block:: pycon >>> from datetime import datetime >>> poll.history.as_of(datetime(2010, 10, 25, 18, 4, 0)) >>> poll.history.as_of(datetime(2010, 10, 25, 18, 5, 0)) 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() .. _register: 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() django-simple-history-2.7.0/docs/quick_start.rst000066400000000000000000000113751341765771300220100ustar00rootroot00000000000000Quick 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`` prepended 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 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. django-simple-history-2.7.0/docs/screens/000077500000000000000000000000001341765771300203605ustar00rootroot00000000000000django-simple-history-2.7.0/docs/screens/1_poll_history.png000066400000000000000000000641151341765771300240440ustar00rootroot00000000000000PNG  IHDRD pHYs  tIME {: IDATx}XT׹// DQ44`5%<'zNcrLbƶ&&֤&mL/6 ii5F-DP@AaDafp?fe`F..u{{]v[B!B!rB/h!B!'(qW* !B!R*+$B!B$B!B!$B!BqeHRH!B!S*e&R!B!Y !B!$J% B!B!%IB!BI"B!B\IV)%Qy'[LڊvB!p==;{Nu"/>(QqK{S0DQ-λ]B!B"QJ2~F:?{zŸyf蒁6؏j];sֲP>'B!5Dd^5/*HsGl7.C͌Qߘ괝o]NB!.J"ե`χ gQ` Yqr٥g3kxFʭBǘQ%ťlYdb}Fna-,3:|/>Ъt,^3iaQ $b^x>TX3@z s&W&wʿ"So5:Po'1hj=SOBYg  |/[JGĬV`rd/pfޙxv3y0R&C^v'8˵/7ߺc5?_Nn!;݁,)r fbz Vn'>'B\?܈~/hbٖɮ*3"GId7N_r#^c+הÉώ aO#\O~y"+FdddaXIB"w_Hdۚ>됞}D$o=_ڍsoFok;F΢wk?e Tt)س_Ţ9FLxPh;mk>)QqRmB@h7riӛZYݝ(u6"-_ւ,6`{yiZ(?Ňg񿥃{ok0#;ϲQïg#ltk; *K1+iȴNd]JPƴKx)(0:\sۼ<9LI/>8Ay@#Q!?^hS>3n%g4UŶTw];VF04wG#!Zffc_ܘ8EC2Iw(4H/3ZlЇGI1VtҟwN'.!w}r8SgA@]S0|QW-·Z*0?'jwo_ po[]禾eUu6JZxƝfbҚIpVH}O1iML݁8ܝxyɝ?[w}8n n9{N 9e]_O_bZBzgZ2|<1fj?`y/WjwECC/+UhUX<[h{D1B!qz*=B<bbv_'-!:&Vg hJ832QR(uP\8+g%X`pn1]fHOhndKIW#w_EFDy`o)NŅ`lrx'og8%jKNO8eRz'' DR56-? #]fEyE=wꙔqJZհwMƴ &6_f/r{f]*fEy6wg1k-jwXqP=75k4$ega?]3+5`T{DR@)H}a%xUPc^:ԀfKGJey-ql\g Z _|K)--?|*+ѰlIQeq,+3}{B5>7~8Yom^~r,?΃eﳭ~Q3*`b*/0SYILV=9PU*1ϚP+ MU]LJ383kB[OmZ5~:+%lQp{CRu5:x2}=~8;s":wr}-Da!ؗcz3 :m]ZCk٦.h0YP^&T Ԫ&jWsTPMyVcȲ)~ S%IoQ{-&ǿ6&*ѐ0ŏԱÝj5ť|Tv*jhtwIpG#!Zg2pĂhoM:ug#-a$h88x};,LҺYNĆs,L]7H ȳ&SÈfF^Q;F&.{2KW[ފ!2 4T9(QcT-6GxORryLO#9ED5&P7Ɵ*cV*x0z;:ȸˏ)>^:Icy3Th5S2tGE /h&>+mnZe(JІ\/YTQ1_vkG2%#6PIZ4t ZeYʆSnAD [; p>+*buNqB5F/ʸ`&b7FGPL^ :ue&L]FpÿUtTN $xR{ѩe W+s"IΈ?`|#D'*"/NnuhUP ?O3^#îG؝Epl ?`|;1\(d@a?jk[U6ceIa$EO{Ư?3{Աqal,=6uw) 8WOmcmSA^x1ݧ\;(tJ'L+~i~601; .y*LJPRS`C~sUOӻj{4ޫ"V t(9?Դ3߰e[U7JQ@W_BHke>cYqJ#`Pok?q*kƄ]mT :Q*s!s&x2;t9F*,^CxrށWuYmD&9{"Z+(=.$;㭪җߟNZ7@%uTp״>zڍ\ˬ @X&,󱶁  =TdN K+;,? shBr`DIJ$_ԱwS[RC+rZX2'X4-vs&Dnt ALn9OKB*Pmvho"8APT$_4&/uI9"z-[JQfֽI 4F1٦.%{θrf].ڥ$;u ýR1ԣg< Vk17.u=9WQ2Äl9tYġ{™}BIG3Y̑:kbyNOXŪ+s4,L`ց9^}3@4֝cN +&2OYp*tX'e!ˡTcJMcSQD$DE4@ea,?Kyn,mF(=Ȍdr^G[J3yC,~XqKn:~^(.Z:p$1vXV>֛Զ{cuŬeylk ^Y&Oa (;9<>ll4/ԒT*$kF:\T%qV (jB6kK|bkfK~}X/~̖N mbr O;{Sge +ΰollhN.bM%?(kyDm[os{OĦN|xʽgg"6ܽzO^󨄁43u^/DidWiDze3[gcupvzk=xM? h:XD# Jw/e[l]b֍u?{;4<ùzǺ??jaܭ;L2zfΚ~F"46f^S^읋{wg>Kn0HJ\J>xWgR;.lLZ\ ٔ-t^^Z*S&V)aC7Οdc{F~<9iL'ܾzw3vڏ7B!BS cuoZ3FUZ0e&jj+*I B!bC1TNҢ 0IC~UTXXUpd&tR;NscBZ^!B!$RʓiF T|.7(Ijcm:9YÖ^r#!B!VdRp4B!B@!B!$B!B!$B!B!IB!BI"B!BH)B!BH!B!b(Jk$B!B19$I!B!bXU!B!Ô B"!B!±$]&$B!B OVCR~ 1Zp㷆PSo+r>:T91|;XÐ U|(`wT|r-(t\3a+)A{-W^t`-⽽y5_I }R-cfmk47"# FN8B|$B!줃3nnuw]KpSz,RVy9O{;nnWorm?2D6jd{u7WƉ8_w5SɾEI9Iѷr6<Ǭ*3pSP-%B!Cp\M_־ 4o6A ˘Of̹I_ay+`cMROk|'<9>۹ب OP Br{_^{:.Tw+? d(ot9X}_jm:'@V'p}%6%Qy`iCTS9Edz;rMɜ[PU:ڷ$쩥*y$ F77Xm8_m@gZOgۮY]GL⽟s.r=i9|&BX'ʃ=ZIK(YDhB!n$m]Jf4Vߛ={_X|: g{v?=Fۼ|yyjfrXū8$?Pj/81ewk-~ce5&O)D[W$^]z ^&ѻAՓH~>Dcc;+[O g{%;]9Cb=+y(n ^Uhj⭿5ߢ4Tt$CtQs0o|~cIG߶'yVfd%Z瑴]W+_%R._Vmڣw%0p< t)ݙ@Pe1QGښH쇈 ]eQνrM&h?*}6]\c_~Q S#rGX \.pwW2a6o_~O$NWnP(hj;JK  u5MOE9ѷjQ5f?a>Qa٢X3mG+ cLf*MT ~9vJ-S45 5]?;nncL>˯'"Vn n49N ƪqS0mohøc?)8v[AB!7fy(`;tV&V;k3xiv_+H8?ϩ %`]Q5vމB߂h4ŚfNj#Xmrns7Rpw4sF.TC,c,Iq 411%Ӌ@Zm IDATJ౲5te-Ts}r>=kEct1j0 77HiUi+[ q9v^y;>]퍴2vD0pMDA"k).ܺh8Eu(OCkH:p^g&7cͭ79e\7_L`ʺ0t8-QV9KL T& GYPc}!MGm, OrFk5Ǻ1 rsL|B( Gf0kl&aݞ̝M#:WCc4io']s775S|ox'voyvgyۘ=KkU9_S3Gvfkh;A;|ϯ:uw8#`s8fGݺeeܬ6{s _etSg'K^Dd|s$bd뷌ّˮRz".hҲWϞ~\YS z_R^xZ`OAMuF1;3 ^vعv}Eg?-L^k"zglZ -_XBde!:R&U|a9%ֺTH"iWGߴzwTxuk.IUȴh (kgйd,eFT/,ƺ"eNJ7f;9PTOZZ9o\:t$g-${I!Bq3%{q~(pt`l~Nd(ٯ3a.*_+Pٓ_Jp[\frM4"IMellđ܁CE.EnD5 MVSr UxtD\vHRt:*0mrD1Kg3-go~!POP1!"/h"l*3&J6ɥg3sIdF粡{g?Al`G|DL@n #MUTT{,ugNE~}#uP`bV6D_GrUͪ fe˵ شFEҸXdvK:w jv=%TOxӂG޸Ru2y$qk,DR!7M xE(j)e䗹̎cup]uVWIDҏYeCM[#ibJZx7|HcUEeiHMN)@ 2g~Q*?]DM*`K7_}tw"$JJΚa+х%>6@e3gы/dn"A6 *=Յ%_GUL\xx c֣/?&}7ůȣqlW#4n _x'GV܈iB]")WӻJӺ}./\n9igɺ_95uq3 7Þ7:(Y8t.1+87L=BkG*̄]Mv`~nf9y{:T`~dz~*=ټc&tMt󪂎!ql9=}yu3MgOO֙掓7%jl8˼v R&]OusGϘg&?eltW'wd6l?!Isp8f^n~d+箅61{nf9fǚ掳KN0{QEqނd*׮Nԯ~R;.ac\l*_؍k{y31o2gL7L\a]A{T>g3K~sU:l؏~Ne|l6g=5Ӧ]+E~c[|lU_Yd_vXrNm| {lAw>sƁ3eaW'3Qg>yCǒBq V f":CIK$>1OQsNl:SYX,>Oc,Kd4 N:k(+;M3ETe{Yt5 E!8>,Dj a鏑+c _wmg_g*KDN_S1m ->v?aٍM&$yjM9X~6vk Js셣2Ք\'şӜu餾xtnup7|ۭOH:Êv2||;ɂHq軟r~K%t, CH_4]ѵ+́}_'q֌'yz>6:ܢxxnMϲUKנo9ʊ2z QtB) ~ezj5L*z d Ѷ,st8 x||zh\ٮitz}.s聐!˧s,#Yw4eLͬ;ZCRcq49P !u~om?H}f*e9I0poFʹ9w,!ĶZ]O;2VQ_,k7G!j|ԃζ qI|f} >1H(B~a^}dd:yN}'o;ї5=;5K|`<:3{Y.?]g;y̏R=8ӧ͝#^z}3wb[9^QbpayX%*aD}as]+G7u'~jnN~%a~B\nK.>d[7w2:دOGi>͹fqu=Bo藳H8ss^ϲ7F'Uuۊin1;cwu:P\@S~YA+dFGk`|޳smi|Щ9sf{Nӑd~X:G×gƎ6Sl :ff&a7\\cѕmtx7Gjo at7l}g 4ᄒ>dFhq9X;oigW~ #Yӣz[ v`\nӕQmkgX?Od}usRAIux,!7A eCΪl^w`٬ءgixe-D^Gز7y.A3ٱ"IL|Z3h4Q,{Og$x7.WhbӬx0ًxSbtj\4{dXLlgh2˜X2^MbtՃtl6DF?ly\P?{mhy4IOof݃Iq_ƛb-DG7\\ǧuXRu@d:>Ns7l}I$lHGW?ASvve!d^l22bbi~=hvsdj,w3R{`iLVI+ )-?~Sϙ@Gx|Yc~(^x% Ց u !=aǖ,[%p]DVR0H6c?HƟ0~"Wn&X5]YdB&97qՄ,^|EA'6V+w'UFia!K0-}A.k!-#%zFGϨ&<O`ǖǦJOY w\3/Mf~z Q/3/K2e}{|r f&QUQnoM|0Z~k&-:@wxb6ZWGc:_1dg1a_q~uvX3; [vLtd|Wկ`ݶhNnώdzY͏*b x`.?|):'|M(W"xL⶛ǟz08~pf 4ys?[' rl:]AO~~ƋMX:^ه4*!`3/{K]XG~Bw&>6^#AOMޡO1,^OJgrt:_10:uYeA9ξm!׷Hxd|g> 1K/Fs#X-5\2;«;0~e˞;;<״X#OstľR+a\u}h k]?4۽u;$fc9< A?^Ǻm]OJgOxqxnsMpt;m mϽTƕ:/+q\>LI{2o=|3@`~")O')X'Ls`D'GDF)- F$B }'ѣ} VfGz~CVr ǑN=LN&2 xk@}LfG\l߼;uM?Vc'afFV]5-Lq0yfZyQhLPlsōS̙ #۬v%!8a&1{|s~x+|(ZL&㚞+3s=YNgdsfOX]$[&ۿe[a΍yn;o榄ԙW6O{f1'Z6lؙ! GF +sm+Hs`p+?$@ΛZL毞 PQvlslix8, (38xF   рa0zFz0r= *"2d=d\r>n \'hki}O/ nF\JWN.1a89&Ns|>N}6^$X?``| Q|q*${ǿ]K??[Mi$$y/Us<7ErKYMN I`o+^Jn<<ƍk7ͭ m`m Nv|\xCsG}łagJ:/"[8 7a-?-'O__?aT񇙑0U}rFS\K\jpcjJ;>ȃSWe%KMřjKqecϱr$SW>߹A`;nȆOR0uqʺ[wz1cEnV?~y?39ߢeR8x 'OVG=a\ [vwVW\~\wXOe7$lΝ8/]W. I!?[uݠ3"Ѻ'W c'26^_n澿g%4?>&-WCvY&c ܻ~+`JDDh\nE?E_m&Ql.w)" DDƕUDvuu'E.S"uODJK"Ly ԍ""""""$RDDDDDDDHQ)""""""J"EDDDDDDIEuxG%/%ɲ8p}[y|A=˜Rx]2O?y;S @$Hd˟S'+wñvogco>6̞Mjs2oä+@;yedG_֜]Ac -#eT/\)߉uxV<4᧩~Z wF+Xڒ7j6wXTHUk/8hrP&~1+ڜT:}#;VQmE'N?|P2;+e8+Yך v4*,ReOE$,qgV^;FqjmF`K) (Us%#[gg],u`IJ)X:gp1[0[,2՜o+oNf=Ò6Nm s_k^!8[yyop93y1EqM~0F0fL%OA+cq%raB46_9S,"L[J w:t 9))YDYfnM #e̯YA˰d˒WICݍ߂ff-b] j&"\BNB}PƲ7Vԙ(-qRSEr{?J9o4Pߔ16i dgy7&duw fhh2|fRdήuԻf)ڃҟRȒJDYu(q"Gؼa+Mؓ;l20+9DOҸ'(;{z9pxݎVlNf==,dfVrݽ:ģEmcryjiHi?̱YKfA]Tr_d/mla y-'ܓnEʟqfbMʒ;xpvnwOǦcۻ^ŽTs%3gMλl726&Zy>a:[ЀjZZLx3qiݒWQCM-T7d" x_a{UOS5cO9qlQHMgZX2-,iI^?UuJȵrkyߒ˲7^},.q{(l+46ÙLK~Y55\DuM-/uH8Θ~fmh- ZTOCF`!g 44֐QGPtM ,2K kۂ۫ %t T.z)3׳`{5uBj]֕$Nwy7;Y`X0c,. {3} (N'N ý}L3{c{xmoX}. W]a890o/FG&6Xo5Y'ݚNڛ{{#/}0@w/y>|c2޻4&־#{v>9پgH͌^a{#/1vf* y~u&|z8LZ:|2[y,U) m$qxH_7G.`ve&MiN+.BN0;rɾ\65͸/]5Ժϝwճge .:UPgKџflXPZf!OQ>ڔQaľ/r0<5fBRv2 D=`HbE?4ٞ؞b!ŎEu$??=/6_MXS,{ta `œHhEwGO-l{8tr{sth]9Ǝ ?Ǹ p=dbEI76eB8mF`q_{cҍѾFR'€Q.`lL!? g;̾zL QS!&jfic[T{y:_ Mxku p}udӈ ocm{/ݻKq7FgJ7@}ss&nvl2-`fJ}7 z)SS'v A_K5NoMS 6Q\5noP>5>=.ato820w m>}]YL'SצrV汦ލ?vjQ4MԔJ֔g[BtT\ "A\ 촥N6aK@8ޏBܚxF<4;{' e`}D'2mSz?HP]d"_l>4pZl鉧qK/~1&%2-ϣWT]cH:O(ݵtd,&hjjeN|ߥZ| uQ^Մ/y[X  ͔WV%\LtMAqHcc# =eQ[d~BŠOӶV giľg;s\=E=+cˠdRr 9>t8eMLcXǝ?985MM45(PGUk*]Җ *hy ]5T uQbc#5;Y[PZEMuĿ_P(OLss3fGUVD.G}q%kmwr]!{Y:Z.EcEKCoHRm21 6kTl{?`AdM\{^nao4k+0D>[O!Ծ1rff>|2(=Jn`Ϧ7>Rme&3=N׉$-cn;Ws_,[DBɯ wPm&-cn;{tՔ,gnu"Nn,wT3ґ2 `ꬠۖJNf-mj"58f!^/%d-Է\o,dE'5u_!OFAMFӒ>2|rV&RIW<i좳NIm%.YtBI'%6[ָjb"HJ!K*=d⣱ B|W:V ev*+n2}viA+^$[cdcj& #lްFIzo+ 6˧Iiܓt=8<nyx+ 6'Q*r5&GBr)gp]r2ރtxαXc1Iٱj%N${m"Gز#\oi: %Ov(zovY\r v mT$poSaտk{ܖeւ1\gv+rTb*6;܉{ VaBAkȷV̥CD͝o4A{, 8i*}@|Xb $|tf" L 'KZR,[ǭOsUZru|aO-,{# 5lYRn>W ;.#}^&_V6J]z.5-KM[Yș%8Xr̶| N_rv B6PEYBmu6|xJߵ9ڃ[Nek YEM:0WөxF"Gذ?i:dZo:J.|posf0<|;; ={ IDATOZG\I7=Gxv~ǸYؐLvB҈^LljQ/d0&HMJCDФk&F@xyfG>YhҔh"# OlkqwQ_Mku],,oYYγFsk-%YRlᡴBn;>(t}&si/r0<5fBRv2 D=N}(v{@?`;Nq]twtDBO(1@b]¢:fqƴl}3HSG66 y~u&|z8LZd>ƥN$p=dbEI76eB8mF`q_{ūrM&aa#I6#\nuRqgfc9}.~?EyNkt.y~rϑϙL.|6vM^,XoruAo5]xjj.!XAB`0 bIiM |y&ˠڍb=NfىE$>#sg P%58))k^O@o~gm9D%W*qFɑ݆Ծߧ0u7Ihf0‰>"G`K@wE8n1;xlf3z"n~,k>? ~"Dk^׻kXM455˜ֿK7;5=$@:ʫŒ;oKk3!|]#<ᵙ  Ѳ)H;#NW>illa2j{ O7QXqi~6ucwG#}<HKa.⑔VʫcOy`G ͭyo3K*i”MA`ϲݴt\;Ƙc?'-Lߡ#g|\.yO`N<9 6qGOΌ^4NcG#'qAݏg7 GzsqgNk91я9?Gٶ>؃{FCz#ENrPXSUUw2Xd fBg'oq>ŒTb&Dc)Q¢9Mv)OBe-i!דpahcH˙,^@Rd9۴=E3p%'-٬rW /׋SO(}'EDDDDDD~1ө^F7""""""$RDDDDDDDHQ)""""""J"EDDDDDDIHQ)""""""J"EDDDDDDI(%"""""""J"EDDDDDDI(%""""""$RDDDDDDD(%""""""r$  <%IENDB`django-simple-history-2.7.0/docs/screens/2_revert.png000066400000000000000000000552431341765771300226270ustar00rootroot00000000000000PNG  IHDRJ pHYs  tIME hz IDATx{\Tu?0W`ۀ\BP`acH@WFـ6deٖY~oifiyݯ`m`h)¬\Qa2\Dzs>3k>ƶ3NBҭ\ycƲXL&F-iDfQ ȬwYY,dVX,f6QYYVY`TeVIkoeR6NLKL_|p3zVl7M=Z96M& όYbgdhfe b ",vR~PvlrqNi;w, }{P3+PXϸyX~1U}:w)j7v J;W[ H6le2ZL?OfZ6qԉNDGb )8ײ$SI󯪇ԳҢ`7@2F#;۞Cû~\y].i;hsDD"9 swT!e "sw^i iбlQ}k&jkXdSc Bi35is Xp|]xTT)xt7xǖA~N!2RۉuҸ\D^@}5hX76/,H6~'XD*徃%is׵$_['q 8Qˡ}E{纝/O1!"ZqUfdIzэWr>vM̨mŞ#q>cXںVv6&KՖ5W2=׆ch=uH~w6։Ϭ:ޅF1OޓMKU-qOk7ދMDӿRh蹜ie|V{1'+kw/c~D?9aE/'ԥx<%O~'En,"U;'ҪivrNQ5hZUN>Ʊ%}i:C4^_Dߘ[)vnKk~2Ωgf%X&QdfbtM.@F7﬷u;/e)"46R[*|ҧLmY˼Qωl Y \PJC}zSsM -{6z:vNy=Y4@fv12Z"g4oη׭ C<^ڇ6#Cv Uk0ŋ}hR}&kN5[״vq[KRf};c˟3 35]َWRLbLlwϵOY {i4pK披Dy^]OSHE?,J5zf3´kiFc S'$*W\)G'j[<鵋}0՝*s/|ONc3<_.ӧ|kI~<~S^^3椒_QY̳6^yrS _6gLIUcދz6١Tli4%xOr>ۓVOa6./3Ml-1*S]h48~r\W*Ne.~&gCFrU觎w';SMYӏboi4~K”+O_%" T#>ikOjsbs-\a8խ.',Vw U ŷ;r$}WJXg%^ xjAϡG{mIDF"^Xm|"9{jf:yӡc)ʣr;vo*<-CODjf;1F"L}٩'!t_*Z+ V#P2ꯔۺ[.E2_tGSݼ\l&ԛbj=_Nv*"C5Qg"2]jӤkyr [AjۧfV[Hjv'+mϱkZ Fbߌ2^詝5A;k // x\G5GURŹ7lP&]6DZM+ރ~+Z?\k 99hsl-UU>mUvyiS$\c;=DD>nꪆ*Gb&#umGXc|Mc~mfP^3{/ͰH&:^kd^Bb&AbN ruXSj?DDD<Ӧ;M}SFmm.L" l]r ^$`q<7t&َ slLtd}Hkĭ욺\݂"uL7Io;YmeVO ;>D瑺g*A[Qvm3X*ދ;pMwN3*3Sl^;LVz"[^[;~f{32hQ#sVuj@uVQe2JKDd]}+.$mvkkBfC}#ܵC޷o-3X)W[ N+bTv_g^!"Cy{yk;&̳*UʉvŋOHo\<~H77~"2^)Le-玩U\cRkHk\־xE^U/ kSފ[6HD+}q9NKwC&B|w{s +G7SokoexkjslBڿq%GN?}ﱽ+m+75M r0L Xv $%n J2wKEqzvw1|Z`s{>q|),"jnnXk`c@άVVXmы;m+X;.y{l56~ܞ/PWsʰ_=X#by kl9wN0ı1ƻ="2\.79v[ \I]>zwS_W:[77Q4#fg+EEecƺX:Mʲ+7B'ވ68q,NNpMV2:AİA}` w S2[$?I{[Z1:HkqR4lzl5P ܵ .o.QR.}Ξ$]Mqxyxw)E掎Iv+bNTH=ʊd,_9ZE;! pZ[J#Y2I{k6@niJ2k_4`ǟted:7Q~^lݳXm%ɞ3@6n>\y=YhC_ڦج6~m8wj2'W 'KJuںx?o,OPl۬4ZhKGQ=4?xeҌ]ARVՔg3IyrzjϿ?Z  ;@;ol(?_Y8Hy=5> [:TyS|MdPIJz'<&.3Mv`3@kRs^R#zS;t{~ZYg$wfaj=o;0kM~swRS6gt8/+~R;?7]  m]_L""ulosdR~͙ q+rcRW]gK&QFOO&"҇V>MljkT|wL3b t"ei7\f?Pf~n`YG&1󭬘:AayUZaY13xL;زʆ$ױVɬ-Uj3'VmY5剘)N|}{t̯=a~版/T4'RV "+&Ac>.85Z@2DL&ɤ~6=n(JWW[0U/Ӽ$"㍂^V5 ^)wu{LL& m82L+bj jc2V bG?,MmdFoed99nmgZu~IwY \~N^mm-W*#|s&3H(٢~xVc;i|ի^L1֚ɴ%ۊt(wʊ`XY3++Aǯ]^Vz߲9]Ő^+[8b5>2\"{ًR?.zz; FF`VɽsPyIwb{@>ѳm|kB֍91&s%"b&:OcS??Bԥ9naK 7UL& ?+"bz{SEVw2^`#H񸭧WSr=`0n4){Uf_y:ș=p3e턀q~H8xl ƠEvKF`: 5vGսiR207N4YOvߑqϖ:`nVo86amL-U- ,2R>uaf\9'q4Ǝoy m `;Na_u>r ygu}'[o'Oy*'@f0߲GY,&Ki]|0ݾVy1>kJaRV\tg]yxv~58 |y~"6L;vNWi2#m$OѫLyڃf`dtN0,XwB]b]X{N~wXw,bX IDATd|:9-9d9a罷ΝϴmX=zԣ 1+gZiH~` O3~SyKi+mD.3\{7霁GyΑagLt鏯 &.غd"ktL:md~J  O֭ [Ԡ0kݤ'>{.&&D1AOΕ" Cu~Tg[z'Cd=f3On b 玦5&gؓ dzDdd3AFbt=q^d WYQd] q1}8Td )Z|45]h2?c0900:>Y003kHHFe6F;dV@f@fdVdVdV@f@fdVdVdVx0|u2DDmYl!dMO𴙳#}F_dRed+5'yH9SKk]1sZ3Ӌ!?#|qyMuBђY-4M)ίx}}芭EnƩl nDž9 3;67_ U"أlkir0䑳2<{Yb|lZgskDDGg-q@a8eM)ˇ,W`A^S] "j.Nٔlqh:o0AVzz>|X#*4QYCccCm(WZnАH YdkWqpّ>Dd(Oà[_QP!Xٌ1o[in\8o5_X)ftݾb#ir?7m(|'F"žl"2T_f^i?A~ܮXvB7qQ7w |=A~)\YgBOqPl\\X>##'k{v}׮%"28U{ukf(y;;Vt])Np3r{lT#*m og/[ƚ/3nvƼ4~w^9{a0ǵgE`+Sŝ͊Y|a$߳f̂n5*j N}zo\ݶ#uS;><\mF\g_D6܌⎏*Ifv=e ""rBb8) R_<6*DSր`"C|sβ Z9eTh3fNiU(l3w9{żql"b -^*B^Kb;LDP2-{8h[egeF""렄W 4?@&"{]EtwO#qٷg?Ua$j.ػxuDi-whHqdu\>SCfUd?faB\@煞 +x^X6sy;D5'ʣ9KL 2,Vv\LiC){<罵{u5dDIJ 4]Zhlk{k"s4zԁ"ou A\.Wظ }#}#;X_BY|RqWQf'""VkɢĨ"HS-o%{+Kq jw&q M\V^)5g?nDlqB2^~cgJ6+DD3pc4d>>cek8r(8Υw~tuZ܎@>l"Cu:l瀨Ā(""R^ #Qs%a{V5ن:_ޙvH(eΨ;[|vHD73ҋ-!{ݤ|p^X;Y^S^|);z k?n}H!3®u6ο:&X7KwXe̜tio \fGXNK2GV;rk*9ؾRzvs"~*߿HX?V(ڱj茦6v轰ʌD=7E;Z]$k d-ACK6[oјYM{܃ٳ>twd+nl[zH H;oYPf"QLD׷dcLW5|XCD5lUWFv4GuA57*qղ Ynft^;^؇f"cٔg."Uɽ;{.'t٪yOV֮1/ i?mZ\x8W;6Z =f; 7!&h\XBϠ]R3{ea}μ ĀZmn&"ֿ[@<0Xa&`[ bi ~U7v 9rj0ckMkȬ9a<\ȬȬ ȬȬ: m.աUw ]Ƙm$qރV2mU\DR3fmqNq+o_I߫WN8?@c!nuY%gEf%" ؐ{}o|p^GsH\\CP^QsQ:qH!Md ]ᚘUے%j6/XS5dmLH$ͥ:Ktѝ_qcҘkKc"$Iܪʮ-]ZT7/Y9*#fv. bbtNDޜ!DįJ҈$y^G]iښD"H֘ Ym҄8Gm`[]Cz41ܔ1,Ug66AߣaB[mhQ;|9=a<%>;V}H/[4&1~<p Glk,zP"_^O;!tfȢvmE!KN7k/Ps||^ۮnfzuշ7()oH뢣7h~};2$|ї͹f/:V_]}UK%vmű#×nno%jsߎ^wY;hV,rs{{O4ܒ9oȉK:nE՚|؍vmŗs:;>fXlsrNo_v`v PZF| czֳ1wΡx8a<Y(vqbX<h#37mo֗\.ixa3="B"pjuR_dKDWZ%GDEjtu2YHLA+8 º:]?uXP 0F%⊢D eY:f7(NF}_@O*d'Tozpξ:<2X#MO#\# u uޚgb:NEvؼuj݊ [TrHWŌkUi$)'"ih{ZvCTr^Kp""P$t@Wd '\XVIH\ӻ}B3<,_8Puܐhjs^#:\9.|P:E4g,v׃Lax4#Gq_߇jDO[;b*EJ :tٹߗ](A9>iW<4?$"scH`GzG^QAZ-zc"qWzt.5_w/ڜzT'^% ')*GUqB"sgJ#-#rTu DST3C Ͱ<8=R=Db_ZZ%7$QOa!Bg/z ÈrcO8wydǻ~['΀ Zw3_t_/`Z*gtY*"|#u8뵪#Es"5䥞|s\ ɶmÅ! RaaX5߶l8ZΡx`G;dx;ޣ'`Zz X,W Zy[VHPYXN]i02B/7W~0wD">wUjJGkK$r>ңQIDXWw}j \zx嚹q ҀsxXw=>N4‰<7B2}iOW{ݞAa@cS=冼*QU2jܥ|H=D΁R!8ZlaUoF4 -m>Q(vvGO*X]B0wD2eTB;Ul a}μ tIϬ|zh/]L(hT |2+ qg.`@o, 5k0`YYYYYYFbݚQr325P\2+  2+2+2+  !KodVD z P_*H$)\d*3JH$ID\⚔{\EerD"8x# 9\6@Wt'EDqpE׋N||ކCc_k0pO{їS23>|PG D@D$D"Xj^,fn^'H$ILbRZõkiLBRfm昹눈_"H+{]ܸc'imKږ(H$)t<-h:?L']yD{W34isb6 RFXgVD8qA × HUٹ | ,+:l  ,Z`NŘs!3gΉ{:Gg{ ;"2i7Q]'7)Җlmʑry嫥Eblں5KN K.xax_9'=+S̪S(8BAC\HHDwFޱj"׭X*iU>{ߙZ^'"P$Nx}WZrYbIHh]RҚg E r])Fr'u]If6ﺎH ^nݺu+Ysj_L$u#"";9KYϺI ڬ"&ձaaaaaawkG_?5_K/μ5q΂"&gY%mV(xƆK8g< + UWUU*n;DDu>}EDD NCt'ǹS+R*u(a;B"v=P$ "@@D]99"...N*⎒Pp#ʬJK'Y+_̟4''''koɕG7eO~'o"Qb^%""es=D+GZ\ьMzV5ݯ` ]DEɩ+|D D$ *9DD*H];݅Du$n]N+Hܕ-ɲwٞ/2~ ?@GQ:]3WU[ ":]2kQ[2#2'ٰDmOrh|g?;~b[]nfV:°[Jm0DDssixÙY9 ˮ[8X,PD$~{I t"wO˺$ 4TUUD($lTTY'"K|wQ5I'ל񜐐3nð۷b|]]}#~yUc~\A[1ޑM 'M|&żuR_{ȷr2LmlȠɓ&/]{u4JD޾ol@ϯ i_g}μgܼcU^ZrV@ xey+mJOc0T{ r` 5NnwR"VjI`yNj 'ؒy'dzDEb>ίOx焚 eǏGضGcG`Cb9x24%_={4Uc3}!ښXޛrR0q/.|'ž+`qV2߁0^Niʌgmzqhg0^NteVʕwzL\]"r-SaI߫M;+J滑}""BQ(Xb K36{_V6ۆl6 #5go{m;r5s蜅sBV<7iKv&uh*irs0DVޤ%C(?r/ikO؞BD$?(M޹Iz3ᚚ-rvfoZs]M=CXl"ʷaCr~NAi$ kզ >7M&7ߙLN~,ԃZgU|q"nÉ?_NW˺؏.$#u뢅]z{ (d-gSz0mGպOY3ᣵ+)gČGQXaSFÐ&9퉈mM9[ǻDDǺPɝg.oQ?|6ב3y:ߧc4LO0>`0W^`}蓄 ? ^TDiU@DyMRJrX'RON:} #2=奥E3>Me4"6۠,=Y 2h:>z\-o5AXTs6Zِ{i jtxK |D)kelbxM%MO=+{yq{_"f&@] 12V`VV[Khz$m"G]k$5UOqO<ҜVt$MmŦb+S &8f> :9pN<>K{ے(z6c޶}md/t_/w/AD$:e^rw?ڡQj+"򶘄1B[jζ!#OptDHN5|p0htJ'U;W^^ZA1 ;%?8{ՊOm(.Z"[6=0e N4Y2ǒ*y/rvo.IL=|Yn e}3M"#f=~ࡸܷZQA{O3<1uΟ?S xNwX9SzHXmYfY,asKS\G >4?I]\SrUfSh 5[sEFϱ&"{SNl=ڱ}%%% 7obEqUy['?3J;E$l̑֎#?ͺp߿򲧚fw ׫%\:~wM6))z)NsνSrS/G}ĕ HoؾE۱}KC{qvMT-~WYxpC$M咻ں[S ''?t<.Rx<]?ͷ33YS@V[[8k@ q~t[¤ۺ]kO[eYhѢ_F*6̾]+1?E+m'~sA+b֛{N 2UOHeee!pCe^ 4'>휄kT YC'gEn,@lj5"iszIDD[V]Nipj=e9k/f֊HힵYsҬVj_y~d[V뜥[;V96jg&k;o7usZ(4%>jh+ZK š1ri&.25s‡3bڪ][)!RWnMӟ?.^Ķ_<sʥ. 15]`־29-9={oޭ?t77Ly+iML_VtqՅۛE$`Kd1TD:Bn)=Ҿ2={osTƦ9{Eb^j%WEivٛܕ q W- \17_f5BE$g.g{ob&FHd):u٪Km{@D$"uEҜoi044׹DDC+)"*"8:w0͙sӽ7mXL/>mKdSu"b0g4D"**{USl ڴ'DEEq [Xa"b "ubjլ]X(rJ\61\])m7Lf|uCg똹srr.ݸh"@ͬEgO-DDžy*g{AEwUOee]zȬm6'[b"]D"bC/+ifE,*b6U+,kMKL Z) ;5U<.%iY\4MR"999V@߃"n$9RDB㗮z81Bͩ SCZfZC%b~yEuY?Yr(2mU']E7I=*-L6H]Qގ9\L`=YdoUU%{+% ċn]9[/4y/3|t }+n^o\c ٴlcM_@oDKy(Q}|M;n+B:6lҥK6yҥKW<9:|5ϯy,>x@(|::{bX, /jg$-dX,B>|g.tsxR٥U){Ou|#JOI~)8.(&q񉣃Es׫^,t1y^Fx<5?^r,*\ 9=1.į;s]|ư6ŋ_),oC{C[z. ?;wy%WzD# 7i7 ;7\ۖʼn?XO)QJ233c)", @?>kdڦ& P`i=JF?Ӆc-R='^cO|c'`Ôfz0,au۫S;78JSl0sV!npE]ν_mIwͯun9WYfGK_$5[tHs#F7zL+j-L1KRJuOO|z}"rypdV &Lqq#DLӬMq "--߫Xڏ'0yl(䬳4g?USy8KaRo*8Wܦ$piʲDf%8 Q&,uǏEdq|􊈻-&!"$`Ǟ2=yTXe乒au#JR}ᄡ\Sq;sWDMɘbW0Jzm}[Q0z^Ep8"msS DD۪MJ;n`$q"2|G-W7qpGy6!!;nw$c*1kQJt*>{#;oQWDp[Yzz|Dӟ9ҪvfO5foV*ZwOWTW5l7'ݕwܛ;ѪF6mtrg^^^K˹Ϝ9}t}#//M6}G\]/۷ ؚn5Qwk HNNiȧ=4f%f̘S_om,;A%L͋-əp_f3/z)?^ \VȬv7$TȬ(x>+ȬdV Y@fȬ S_:s ':+Y)Qҥ#l"Yz>JJ}V[EHR4wOERZ_s#ӕM2)UDi"*6. eVh::!^s`]g>Miꌦ]Jgtl%"Y̪hJMSJiA;flБ 3jzT^+&]K4MDWJGV4DD >u%kPֶ[>ofh{nI3$H:Ǯ57E>j]5+a޺3|Pӹ/kȬ$p%-ώ7|ysUmpOu'G=bv?X>%nĘaz QqKVY|lu_ +Yp+D4neGbbG}d=#'Ynom_&CB4go:3SveaA24!ubܑ"sgJB##D92$d&ڌqIQ}ǏZ<Ө41faD]~:VDGIKղ1dVxn(Q 03'W}%lė4F(4Q"pѼt1M! 'Ny%*qھj Qu 1~)KJ(J(五t]aƘ+"z_''wGƿz<9cU?>q_ Yݻa.x=3tmSB %PB %Pr=JDD:DzbZWDԯX9[n;;J4q;7(]W][;BDxXܪ%Y#J7]Z%Riұjg4;PB} rAUZ`-V%iJy_{on$B}V%ԩT)Mu>˿]SݮD>v^ۈ|=7 =U07dVgV`gӧOs0x3krr2g NȬdV Y@fȬ dV 2+@fȬ dVR4gid{|IENDB`django-simple-history-2.7.0/docs/screens/3_poll_reverted.png000066400000000000000000000512641341765771300241660ustar00rootroot00000000000000PNG  IHDR$p pHYs  tIME"\jf IDATx{|՝W23L.dB0`"6@Ih,ZatZ]+k]mUKԭP+[ T ֠FDK"$=3\@r(d{9sNs߯3O=3b<"""""""9qh7Fl-T4DDDDDDWM0o4 `ߠ$RDDDDDD$RDDDDDDDȅI"F%""""""7Q3""""""OFEBDDDDDDDWDDDDDDDH'!kٸFRԻcpqUlI## W쿬m>'""""2|W:Vo%**[h"uRgup=lҞCDDDDD#<:׊S)"-.R[)<:l޼?m@v?fsp(.Lz;ED6UfK_4xȞ]ÿ9|?NFqI_vmh\<AJ"1Z,05{/ ]!{'7fɺI|DoDU5.&;5-F>z?v:%&k Hv)ڊ Vo &in-f.C,Nn#]D?eͬ~;m*x{Vފodq p,6̭ӛn@Nt8gwHTo+6Gӏx1Aw@{ω|yL +aދu4cY3?M6DO3ٽnmPAQ`?5za<X4Ej=M-NsQq0 Զ")qZk8e߮%"5kC ̞QM=Qg\? C"/wxZp! e̼?0&Ot w=,-ءl;X8I;ISkH~a(]L:ldbEZ v&hn3Gs+g9p6*yZ)]^$hf 4 *vXxH3~VYZ(ScnG<89BM#Ya:>u1|t M'`trV\Aa|#IQ5f6TEA9`3n?a&#r(B>0DFzkL2qhiC4m&jq&HWEK |q[߭+.׹ԅZo6ľJHZZϸ Nbq6d72.ȏ=~߭Nw:=&ܷ= ᭫kܚz Gc621/?9SoUuHcB|_v?dS:ߖln K8N33;tAP+aڊ@vw4)oH~|b$""rk>jhP<imSa3פ^+Mì w7dGpOZع' [:6}f!dڽyfhՕXQ>r%o#.'ȟOP6>JINtIӐxV,8G=2=5jl,`#9'1F?58cxP>3gQSlٰG,)k,La(lk6_̹Dyؿ/,Qͼg3H<]ޭRx)TBTC;jqa ye]Oʣ'-s 0#uGWhL ]~;:~65y )63}~&$۰6 @OɌǯ5mW2z :5㹡c[8[J03m?`qpӇ oTlatc}hk,L5}/w #ef?Wt-+oBFji`լiF͜C3/g#ޱL@ɫܹ,V_A]Ⱥ疳$$O&Dz,'/'=f3HhQ+r,Ov)y5'{.[@fvT 3!29ZdFL󘛑iQ2C ~e/Cyr? -{xm:e׆YsxXN`K{+!=@iguVgWb${n?OqFkʈг_m2Z&)C*@Y gm'Z mnb`_ ԣײ;. S7Io2tyMng\NBJ8X̮D2R@Fj1 "1ǜ"9ԯr =Ʋ` â=FlbGHF!>m\v6:p5q\읥,kƙ}D0oB(QDjd$Q@SQ)ly)2~pR 0=J@q!*-L %j;sf\cat[fUոױ#eokcqBq 'I($U˻ōCTRi';1[Nz۱p/9) '60J+ɡt[%YR67N!gx kNcjNTP9d#[XF),ei:C"iN-p5q bf8OB\^6;}}&xg}(:K$c6>p/G'F rOL@||\$2"p_7I'x=l業&*qϳ]ImroΣFzE>I?(u11UɭoafO -_HDDFSSL3n Œ4+#:OTcw=L`ӧt:kg9< q=̲kqfk76qJЖp5:a W6&,aPlyBRsV|lm7f~vfoDqc1V#D(<.T i>zaCt.f3V^jFƳ0˹HH ޵ h5G#1/71 c5TF#'{tRP;^3C7{;u:7FxUR g(rfg FGxc gw'[P4ߘ7"J ph@Ltgq>v!c>HNHS{hd |m@BegLQL#IKNd-[Z6Sd4 Tkn~# ¨HCEv_3l:p]Ieqc%TF%'WtTۏHo%]z᱌l`KB{+Som0hp_ 653_4 4O^:( Eա,%G5;{ wlH?wRCwmNh\c$ԟYKw.Ko h* ZNZo].o.#k|舁)nLAmؐSAG_ """_%-lgqX#Nc6atIXu<ƲՁܓ3usNxF:u6lN&N<}cxY8owF@f˺[I)ܐsÛ Waz7e߈d~nG$ϣ AIaZvuF68ssϾ[kgG<{˶z,i M- 'q# am绩L#':eù'g}m^n,Eȫ'={.[<47ޛT({Mu1eRSTI3N6Σv_>桷gb_`=Ay84l2w0jvu_ Ow%/=6讬=}C?յvjjZ7R"[QsϽLֽ='CX1 ^+?={^u05Ƅ'?`6 u11A*D>3p718̾1]8=ܰšA h,-z[hJY#O)":<'2_~&3"Z"%X=?~sY#h6"Lzs` y=I;1'?ģFAkOX J=|e[FӛC2K񭥑/w1p0Cyk[0OmvSZ˴GQa9sw{EwVad̹;H5?“G)`3*dt wboSې+[z鏈tu!:=om?YQmNhJؤRDDDDD\bd:ɞw ,2:8n#>޻~./4EDDDDD.$2 ۲]8LvXx^<~=bd\W^k@Q+7u#5lEϻZvɎ7""""""_!%""""""$RDDDDDDDHQ)""""""J"EDDDDDDzcO IdCC!""""""}rV7#`P$DDDDDDI&$EDDDDDIdܵ<\ĿN)|?"toP`)\c׌0LwǞ+c&rAT.O%(DŽC(]uEQ|דls:|Id3~~g]L3Sk~t?\k~ᇿ^g~r!Q3pugv^>o,Hz< @H$gb r_=SB@5R̔٣Hʚ&^ŵJ`Oܜ9o*<,JJ=\k7l`2ܑ;+W 1dBv9J 1Rz)${z0<:kED[xektcd*@}ϾȔS6_Id8د˙!k?>w۷<S`/L?-@}9>vꮳt>s͈>_bw0-?{ya?t'O. vuQyJO(hϖ5ycxݲ!s&r{=s}s %!8SIŀ0 acH-WTr:ֿD!oD"vn~5IsوD( D^xgnfc4F7&k28K?Z_Ig0Q=ԮK:*D'߇RZ|eɏ16eeLv\Fο Ϩ87v@un@I_[wf к cF4_Fi懟Ϸoc>8 +/eXx8Yр)>ergzmYf^aӻjn>(cF0rd(@|n\A1IX8XDzx&8].mq`Xr8è_-~$sED:~<W+I4cݥ~L䧹-,} `O䧹QDv350ü-sL_?I=DfISGq>Cɼ6h;7ϧj> |"DvS.AUms;O^mA-gDHQ)""""""_$#={ _9n!"""""";R""""""2J"EDDDDDDI >@6C6`0Ɏ!vERDDDDDЯCYys'?~퟾{^͋﫨wcI5_a<%Ƥ9W8'sSm^} c1i27K󸎱<\PTMÉqDw8V~^ZUN0~N6F^6cAo1:K7Xm,2m<~2hoY| +̳xp =NYl%kIJ L$]f'{s{&%(8FeSҸNe&%7Ż mx  ޛUͪ֓7'7jZ: Nc@x Y cAlnJ:[vN4&I,5h{ߏ XbE6OI!;0y>ں˾#R;},*zM $g7_K7Xӱ^ܝMP8~}H_/\ǯS*"""y$Dkmeҿ~];KrPH*ۼ-eSfy]ǯJ\cEB.we=VQPlidx~m .:ƪS9iO;xX7EI,)Is&c~ ygT],HR&s ؾ݆=8\ץ֝*cLUTHZvMbSH\Fy =kFo)b/(O)pœ3 3`NGnT $)3˘ v`»ɽ,7}l{\aѱ$Y'Y2O&BIK lV:LCRYT/>.7g&ur" #YqdmTpc=:IH>Dz?s}J˒:Cں޷onvw13Kϱox?=2[t2 ux@ĜLvQ""""ѕ_Ak!G&?î|#2gIs})klUthp`Ծ$儸Ssfwr42b:5WM`w# .:Qos9z+IW$C֮^fĦ!wq;dsa{)K(+o:)̢*#kFoIb+ey6z&tR{kL6v}{7nЯvoVk@oo=ǦΡ&l+6zj y'0òl Idd5@n?^}b‡ ~FxxK~8[v}ꋈ^g"a/S6wW\6}~75'$zg,XPp2 3eIMy&%n()ᾙW{8D9+>p>z>a,X:_k HaRZ01ݯMX$+q-$t'][/s觶=A}*xɾor2̺юy[ʪ=?OpS S^eAU_l\X~{/妋Uf;%]yWvq &Ӣp k68C[y8I.qWXs_|y|w-^fC .=Ň)wndCy(iJ /d^^ƑRDDDP/|e<RT @7#; T+=8aKb/?6 n 3ȉS\.L~ܷCYxOfEDDDDs9s9H;%""""""$RDDDDDD,R%III`GZ*""""""$RDDDDDDDHQ)""""""J"EDDDDDDI(Q)""""""J"EDDDDDDI(%""""""$RDDDDDDDI(%""""""$RDDDDDD \ڨ|g]qȃ>v& BRRW]u111)>O~r\TWWS]]Ν;?~<3fhKD\ly'|iwbU\DDDRO |M*++{nN'33N$VwH I4s0gFF`Օ"""""O?#$F+n"HM Š{A9 wv*Klͽڦ҅뮮j 6)C6XR23g*I~aqU/ 6_DYUnc0ք,bfk:.t:Qκ,Hi|pr6ϧE¹9lڸ%b%>%99d& U`w{6֮z]l1bM"CWy6좬[w{G%)m*&s;=x(hPb l+i/dfQô.~,l."""v)>!$qƾdo'61qubc:{\>J'fl-y' :ic3^E<6}-++ԩS RBia_-])3HtLt\#ϰuG%WHn߻q}qXgȯ경ہP!E LCxyGQUgg?<. vب(ŠnVnʍi˲{rDZS L0)Y9dbrTQVCf Ilf_I.#L:[4w?} sRʧWSyim-ND=V,?G;tMr 4PbReZUƿV`Mgzv*ټw[>y9,J).܅3O p4b۪l>䆆ݬ͟ØݶSxz. sR0mՅD;]+;$xrsH3(ظUOEDDDTE䕜LH[ތjL}uyL:Yh٢^l),Vo5G|:(ӾԔI-(`wp9oUAg?.$x< e{g)rF![^B^IR-ZʂL3F)j*η_DDD.mAAA477cCJ030"6-!5eSY]X{;k'3I3Xv5sCUgvdEZNj8ęU}6<|;[=C.aP=,%er4cMĥ9.pynG%vHT+iRA/;4]@p٢}<?*I͜\씽0lQz\ݽAR{LD]Ვu$DJ8T4_?EKZEDD7dȐsN" 1H4C<>(@X4&bI:HFxFߞyŌĤDU955xHcqJmN~qw4٤f\ș *q h.8K K¥φ-s߽UH#)wqKVTn6:I r+9X]DDDϏd>|{3*@ٳz'%K 9ّ2 cō唞haS8i7N h؎4.00m̷i4Q{: رc/DܖOYx2,dX9f,XX{X>>qS:l_Ŷj{ML.}!E=oVR98`c]lUɎ`/sV)ნf^q>DۡIdO&`~p"eM |36 ՓbA""""0J||<-+y˲/m'k^8)^Wny|5ݬuV󅗚JBBA/Xµ %%%p,sNFYn*4;7el/ & IqaBrgbƆ3biY޻~ZN1 )Z4o^3{L)1꫞.[O5Vά@-#/f*WR{peN!;Vx?8J^zUl ڸ#EDDUW]Ess3 H?v-dll,SN%00pP5YUVQR]ɧپB=cenV?̓/J&z  0䯦$̦YH @*EkΝOb>wJ$8EB-(xxw}ϓipoe:zV̔%% Y4#p>y>DXc56pi|OTT&M9:(^RRRkZrD҂{LJ!35lA[xpMgX,,zW,33 Ɠ6i \|=x=+~2gR Y)ܥ~~v\'qMw~%ſ~[f3!շ>?xaٟzVgL^ri/1|ҬA9z*w-F#Xx;LOL""""H"i2!33'lVp`4 '55d2 J Umss+ڃEx`o%'LJРVHA4 ׄȥ@ٟf"_Wop ݾg .2\DDDDIB2tu 丸A;_W%""""$R)wGic| HݝU.S|Ijߔ\>_w:?k]/v_N~ٌ ^qW_\κ5e֬{us:N݁ͬ]ޡ];G7yŗ?@wϋh=Ɨ>[DDDDz5(3~~_x,9>v8?>9"S S O>WSNyh)oț/5-Uywo[oʛ<(ۍ龍XWWd15r%+@"R2f7oe{Nyq;s(IƁNN"~|򙃄d퇷GeyC0O%tSU>aI|.i.)nK]Gh!8g1jü#’"p4botqLM 1P^ϟώ;:6aV^}N}eS_ϧF3|Uwbkk=W,gH"(s0%1??OL'fGȳȞn޲Ox 6k:^BX}MDOoId ¾:0<c0 \>ZI4Dy1s*=M]ɻ8w O"5($2=&V>wјN~13@+vw f =RȨٳI|>l:>|2P~wV??< > u}cǓfSQ]rdž\.]ʂ z\y{Y6:L _pY}\A=yql^4Hg@!F_d }pUw&$䉄M!I!Z []Pq|XWuZutZ;ukwN]wZ#S| "B 6 {s$B_3}9ss~9d&6QüM]J9|#K!NvyY#5S:ksƷ&b'XSc7sȕd[KUxى@ y=٤v&$#9=?~<%%%Gz#KJJ?~kΚai>Yp7#`nM$%4Fє>Y;}>{MJR 25 DdrvhqJMQHl's(P(gCQdm($I:!S&lKmbZ%Zz5Z{ -~1!u됳9朦c拵>.ۿGky^Sm!2uHwƦ;N8#LpGfs<ם> $ps@S"ԫ֙㦣FN:q^ wRUQI?dm*`([K5z}k;}q۵C𘡴$I:svuֆRjR'p=pO뿿=؎F!)w8m4b# @ @MM_;q D9|1J}U]se]I%%L_sz#϶^5iwnR$z'~~5M)$'?Kk:3:k$P]$Pٕ64I71Z[Cl8z&ȣF0}B-o;Jc@MGS &,t\ns"+nWZ֙Άy/$?qSC̿aY<~c1ohլ~O/`m0Wf9u4߳tOX X ~ ?V[ʛTiW11`$ӧ1H͌鹤LftPcSkX)MK$1%@$I)$qS\\|7 /#>N|'[YYIII]իWtY$IRW=|xsB G)I$] R8$I!RYn"I$"%uh <؆$I!RZ~ I$Sj$IlI$I!R$Id$I$"%I$I.NYY-)I$I{䕕y>I$IBgsBJ$I $I‘H~/@ piRSS:u*Pȭ!I$I=5D 6t8]yy9gvkH$IRO H\8x>P^^~~h&0۹{5#eOK|'T$I al{g;3ldoÙt=-ʠTq $It vuֶPdz̚j c8  Mzpczo/tK$I趞Ȯqžַʟz4yL4gSAuWG 'R$I.^sx`0lRP4xpUǏ HI$ICdcr% Arj=oa =I%A{['HI$I:Bd4X/s^[[[+Dg$I.DTTTPPP477v {dO`/ Ϳ>HI$I C)SXz5UUU$$$ph4JRRSN=)I$IBdff&'N$u8o  --)I$I=9DA{V5 $ICBdW2DJ$IE"u%$I$̜n[5Z_$Izl>I$I!R$Id$I$"%I$IHI$I$]֮^A  D"Io+tK9̖xy^>mI ZȬ)C֧|'b<4j60f"N|QKK>`gdṂRS%I$]!eq0`@ӕrRvi.!–WQHf}}ݾ'+qݣֻ;>!_erBy̽.rRygδ y#<Ivߔ$It궞h4JccI{ ?F"Ӭ#yEvqRI%;58QYM |B!#U&FWށzRY$>d5kwf70t|ch`öغi F0kT6Cya")%I$ֻ phwhH :p̈́Ff6-^5GE]EUfrV䊯3;yL"+4"kY>L"DJ$I2Dv Sy?œ#=y U)ӗ4ZZcHca H/@3&0g~3'qU5lF^pOڤzl`mFo*Ɂ{$I {Ѐ@xFlOuUY^$3n\&)5w~y%/-e'f>\K |r_/coj7_ł'Dz"9В @x_%V@졃\U;Yl3[A=w#J6S(59C7JF1$I$up֫&MߥNFILkfB 9ko={XW䷻$I$iY0XsǷ v M&$I$]x!2 7+$IH/@$Id$I$"%I$IHI$IK.9Э+%I$IsD׭$I$I.4I$IR9$I$C$I$)I$I2DJ$I $I$C$I$)I$I!R$Id$I$"%I$IHI$I!R$Id$I$)I$I2DJ$I.yͷ "]GXpsI$I҅"S?F~&l}I$IBRT:l(/"}tu 4F<!"ߺ_TJmBE\]\`*I$IQsfCO[wJ$IR9/ϤELȮeݞQ= ! D3oLA7$I$I!$I$";5DH^ؼR ҿv9k#@uoPR 7$I$us|NdO'JF`]z~vNtM`.ՋUSאL`nd/ 7S^G$Its~S=!Jr~c⑱'Z(ըK$IV/@$IY->RF_v I$I"-)I$I=Y%I$IfO$I$)I$I2DJ$I $I$C$I$i$I$C!R$Id$I$"%I$Iblnn> $Xְ5a kXְ5i 6D55a kXְ5a kX BY%I$g8z"%I$I^6$I$'̖$I$Cۊ$IC8U$Id$I$"%I$IHI$I!R$Id$I$"%I$I2DJ$I $I$C$I$)I$I2DJ$I $I$"%I$IHI$I!R$Id$I$"%I$IHI$Iq0ɐIENDB`django-simple-history-2.7.0/docs/screens/4_history_after_poll_reverted.png000066400000000000000000000714621341765771300271330ustar00rootroot00000000000000PNG  IHDRA pHYs  tIME! IDATx{|TO2 &`$D 4i; []э+TnbV~T5-HhCdKIB̄yɁI&$(z"Z]keZk_yM\"""""""9]Q`0)""""""ү5 h0kP)""""""^0""""""ryHQE hLx}:Q/H# xKE PGުSryE/;y3\YZs1'""""2}WqؘNpmmTט). :ܯ:ST+STf)v"""""2"K?,5bL 8Ƕq o/o>R't@6_͖VnOS|<.O[ƜTD6 `3=4tyY#s|8r=f$: 5O|hr-"""""TD?F0"%-,N bQeA l|}$?v?F-d\g#f3`k2R\Ć]|֣ͳw6(Fh3o79_W9?LvayQ<1B$&0^Ǫ'J6ͦ3$?1qIV '$9ک9^QG$il}"w4/ߘH<9jy6Sn?_d5 3F>k! r1v3v;=h"۸=N'Ub)iÇ1h: ƙ1Ucp6k0Z)b[u|!~:n "hUȇ]HI6ކgAMWGMѰkN#\DګCXt腧/"`M`qY6OQ灾}{,N '¿y`:H}+#M; NˏN,AD&Îq-06SY^Ctr8;m&dH8wZ --/ hg1G{ov-=YeK9fq۔>|x}7m} r 2=ټmWzĢ?l3~+3DW9J B6A,'<G׌z l[Mg)E3lϝ󲘼,E$m bç5NLPLSuzh&4N8ȸ<4Zi`0SZq͏E}V3:8#ds}MVraێ jֳ/Շ/n% K%HDDJVϾLc5G)F^(c(&Rt9id4!vqb5gbv,Fp恲02v`ê[x? ͜ij9c9L9KJD$6/N"ou0f7x6 _v _ce|ڍpxrRxaխD{nkap=F2g/O Ok5z߿0&4{SnC^_MDc G4s& NJћjk`\6unsロL^ÍFe{Oa-~lawpxC+:ӁiT ~o*{:xU6st XD1 8 <ǩv8?Ŗ4 "0U>w0n:7sLwc0x`+ЦQXuK(y/m`I7d &ϧu"I qJqmd f9y\{ vhRy/Jul`Y#.ڃ/~lS9\4 Ԙ&mtj5sh7PS#DÑaGm3^ضN*|\EzWglbn?RooggXKy(2qh|_RDDDtG,J "a!g|R̡LDZD35caB?wE9ϸ` N ɘ*s 8:8Dם =xj>9 >j-bΞhlk J]t5ؒ]@n/kX4%I 'h*9ڼzG'G {/pY,O+Ic`"F\}iB FMaL ti8בWPJܳfF4I# Ɓ- pq:)mt/c F=3`Wɷnp8 ߝ΁`l>۶=KYw]3xlԌwGA駼ST19Q,& 9Wƍ7E3fd L'yLI DWesB>95EIw.Kd>W8,Mx nSTgگ 2kΣF<"p;fgjokgRLo<0zٮxoDDDlN>)mHgKiAa%d%YnvUNP{1іغVՁq1q3Ҥ@lYFX{?|"lw8y%h[sk+g=Hw\}4`2m:]ِ&~f[ %⑴v+k z+}cBV+23`v粿O_19i,-xoǛΰb3ְ3sc|҆gc sLCήc{P8lW#`:Ȝ:/-2z $u@V熰,5cf*Lt/`1ez(^`R0fT҃G ض>7[#}Fƒ!l@k\ WEDDD+P2E)NjJHa}ٽZ*cXlr\>ƇZ ,Jp DCӉԄ&;uqScZGa; $$x\Vg ^euư859ce ɑLpUVӘ$7?̭glᦩ\1'sv$f4%Ob3G Oaqr;fb㯽h,t1E=% (vulmHb~}  %-)3 BYtS85%'iNQ>Xw0tn w(65 S_Z8K;NcMMCK,K"`6Sk0s!6uJj`t=I┦ O0̗[YA]7Ŀk ӏqGRG:oWۮO.Gpsŧ;0;];׵A/9q1u GEDDLWҐFq'Td±3T?X9\?NeC}J#yYGpѢy5[YjZ;hjk:~{GKY侩LMk6~U<}998S}.O3hv>Kmx|~4BMI>/ 8Vi2gKjhѢݧ[_|yиsli'֟?:s0i{jx:7JLG4UD]I$yyw~* o5.ܶ0 hXs=F_y[ǟ7Ƞv~v9PqέIvm~^|AXk@V\ʭ4~mo'z_^'S45Sϋo'ŧLTv2ۈ7h--FA?^o& d߃s""" gb :{7Sg~_(j0 |1W ~ e@!DFq{d6_/ SYrO@9 _Xy5 0ywpHnbR297u| *ُN&2Ƿz\_>qE_>?,pmLnm_ǭWŧFvɼFv!?7:?]vczg+#xa9u 9"""rE;CQyw1F0anfk_xrEr4¯\=y&.SVbz/asԑ:`'Ct颉('S(/5K1Lv2mdNwÏa`uL3NbƵrx]?7y+4G5L;s&z;?5׻k6 ԍPDDDDDD"hG?n8pY N """""""RDDDDDDTDHQ)""""""#?P$DDDDDD_ou H׌AH__MH w~w%u0q21طW^<đbHPG/< *r8x#zλ:NŘRb?p4_ Yፃy4~#eU;iLѡj~%ָXr㵣 p/ Qc>!!O0X"g7""""rc/g"}}||}_c[9Ye(krw>_|ݯvˮc?r[t~8ostO1\ -ܝBX0^kdx <=>CNߋǼӷ{0E>gȱ#e*"EDDD*4L={OS¹33myr}(h-Jcy3porKkꦾc^k 'c:w'V3{p[^O?/V0Cupa_vY+x2E 0:['x N&\Ϩ*ּR˿,S.=:LBjSܑʼ;p`&T*Iw37N&E@}N0#HVZ.['F!s ѣFĵ* EDDD,";c"'Zkd 5#ƒ>u\q[Vq51 gdb0@Tl#- Nm|9kܘ5Co:g.|02" `鉾C!#G2?&ďI#+lWi /ͮS h M;mtky~ |}@@XF4ZcڸQ0:-ڭiHHW?#%c{ 4c cma|bGQue<St ,ɿ%̋p@LD*|҅qQ+=JTShWeaMV5p>xc_yh㰎òsIdNV~Fy -z8lK6&̵'ϷUtA1u8ɊG9?qV^N cΕ,tpSL8#zqs}wemow{sԾx}z]n{lΫdm[ͧlK3M>H$T6p8W~J.CGA}>a0|cwܻ8޽̝O:Zjq ~>δ06؈lv|xšJ>pCΚcOG(#'d{țE@ۯȕSDïYO[Xzƚ|Ӆ IDATp]=|,?XƆ?2cl1]vMlzNUht8Uso_"'p.HϙOGXl)-ݗo~{:gp׌`~rpaa~>`NVn͟Gp1}rmOڱw4n1χ)>>t~_i@uS)!%!\n斩g˟a0ɷL&=zQB71 %Ր""""y.R$B&xmMqxWv=Fe#VTHߌd,$C47qڜ-3T@P*ȕ!4o$)""""UDDDDDDDE/ÊxWD*""""""үOtVHQ)""""""*"EDDDDDDE"""""""RDDDDDDDE"""""""RDDDDDDz/fvj^_]Vׯqx')~Sq)1QEADDDD"|vk{%bIhL;wP`'sd"tU-GdJp!>if01Ҥ<]r I˟#|5#܋v\JEQ>E^Ō0~xH ;wiZ=h0"){2_z{ͻ.km6J$)gf\r !ޑO6X]ZJٲv= $GlPQRDE>&ᮉW[UgTvi$`@ a@I/KS5KL x⍔C6]rij ȨY;{]eV-UٶMUXq`Ē{$Ǭj7[vkCk)(f0w^&a{xl)n{!mζLc ^|,~Uz6;ؙ_BEzQ$d2o /aus CocgA) ]"Mt\ V3RG K(>]p(uv装ddg396c[;vRT o}lk%ݳfr6x-?Kěu?|=իmf[(* H^Hs͔8Z)ؖǜ;5)L+vi˥6ګز \tJ`°y]C&d*Kٰs\~wV{ zT,08>kXfTX݅k^\:U %Q@8NQv`<#-YwPu=\]=mg/RPaGشgerYzW/cAq6)Q` B S_ȆٸWTaھ[_* 8Ot,8Җw{* {LUO4rGun-b`suc2rmGDDDD>REytKk4X]v\vB .CLl4L@kCl`W ؖ?sH+ Y<s\ז]zmΥkB($Z)ƁS}l_%Y6P+!U˘2ܝH&XL[TRXTC UsKB /$*-l@Agqd,CrX %;4Pq#~´޳S+ ̚A|` Vkq1d!p;Ut9\j IK U=fR"}M%$)\/RasJi °]CʟWێCWct%#ŏ:x͈l-\U5-tgLu7vR^mZwk,ΩjZD^<%L I=RwHX wMv;%Hr%0kB&ǚHfܵ]JV&&<D[IOʌTl"UÈIL$tawujAr_781!S?v v2;'Űd'g'EWQT3W_YBF#`qd-!H4` ;Za5~qU 9ȱ:y]Cʟ7ێ¢Q:pT!NRq),.-kX(N!>͑=uحtO\E8,Pus {C-gìTqO- SL<#38*سy}W8R22/sw`b/XWZ4kkYEHLi}L&e19@ 2!z_͸jm>"D/0f?7O-^l;""""1k"cqx\t BtF>ϿK/c94LBoL< cX0 >)pX+(ر۪܎Ma}Ϝܝ"w9=NeMLbHs\|ee%=><ݟ.V - WXBDDDD60Mf;9ZwW<ܝKnqOfz|w,d=SV=?;D21s 3k&Qh(95/p G+%-dFREaMk- 襉,bvNoa[g iL"+w YϑX;Og<{ǒ19Q#),Mؖbר g2uNy%% C)^)㺵rvHM69' d&2Qz٦ҒIR`]|=$@XR)X X!)6rJ*{Vr{9[%!IX,BMk#]Zz/0b K"32dsl1mu-4̔HYdb]1H"wR͹_B|C_+JLRj( z6<'"","9k$--id-Yɦc͐8){eľ挾?~z-~WF{x~m3> }v)p˺Wq8r<>rid13 VogӛM( F, Fk$Jhl~it9gw?D6eeLZk'1:LZκ|ᗹ/kiiidnge{sf+w"Z>ii1-kIG'l6-wٿ>gv.><{Xr)XY=Fo?MW3gfK}:/]=?puz'2W3/޽n؟̀AыGYٗ|.m& Vqb~\񶿺9mvH]ΟHu/~Ypt68\ƙWNw.zCgl9m>j٫܋icgNg;jLN3aܥ.z+ ;v櫿jǸV?!3fYzxz\mӟ^qOOgvm8=7o8?s:Ng7#+/E>[>jճsjq0歿mɫ>9!w*ﱽ9vjns8?ks:mgն$""r0DZ-D{Q03/"5ȂN>I;^3Yq$BIbf;{fh?IYqщ$F5L#Fg~Y&e$1c{m)comgoL/J$&/iY0vzs׽<ꑃI!7z&̘-fX?KdQ<ͥw6zx/mt~|&H'\͆~Md86x__3C{ Պ-d"'Ò@#<瓸_-_?_'XI֓X7Bb#w#7jl&a 6%G[̌rIr-o0(цׇAؐu6!iYk.͖ф }F>NّÛXw$c9 okǦuQhf.P=~~qs׿mK0g-]yXVEeͶ4>L}$o.%}y$!QDDTD=v&敼Al`n<X1[B93!LZih\~& 0{^2?ZoeKy,H Rq'<3G6F5ez.v5-g8ww6{מv+ǭ%zF? ^e\CH=[$=flH~܏%9  n+pMtE8\X f(+o7s;^jllɉqTE=ly`[}Ȱ,6^^S{aBsƆ w?EDD8C?5d&HT^1'iojϭ:@}uL&yI̴ ID<I?eԂl^r7<3+h,b&w1uU͛u>dH˺`Mx;yդ~~nZn~6R}+[XѷGȕg[("ri/fm+<+ݚVb~5Vj܉Wrg&RDD+8CH Q)""_M*"EDD^s~mxWQ!$ڕ88VWR4DHQ)""""""*"EDDDDDDE"""""""*"EDDDDDDE+q 8|("""""rHMMbh\+"ƕ0<:UDDDDDDTDH/m01y],K `/<8t[C}>70;5wSɴh,{ )-L=LޕI|l[7?ۙȣO-t=m""uW{7@u\bcڼ%,͕7ұ8)cƳҳI,qf"lyb= i=,[X+rݵǞ|{/a۫عmUveXQYX|3lc/^mNMp{Lw]-"rZ鉍) e=,[CRz/FJ rf"[Jv `0Rb~=yoRmT0A UX5‘7 È%){C|` ;71BXx2EkrCVV=Si>'0qb LL!!q~m7yܟX xDŃwŲkum+mdƁNv=I2&g%Q_)bto)xܕH ʡxM Z$-[@qI"ZZ(߽MyEj%{sb ^L^>-n a ̀m+ulsUge|*:s^u%+02c1 IDATkٿYqXȈ"(?DZoz-_^|\ʡ_u!yKg_I2t:{Q{"9.qci ZEUAUM9>wZ(۱C:'Nl]5$,{k!|*i.s;J0OYLL.6hៗgabeͼ>ڧٽv=:! j,ʔ=)4laKymy6mJʬFbnH==mLRnMf#%Q͞ QOS;8y`SOϫR+`!9&Y$XJܶ"L3/=/ns陼Os"k柣Z){=rgLRjWǺ%F;dqomk]4lNlfK謇yӨ/ko:%p` kؐ؄9KXF$8鉘ũ 1;\ 17 e;}⼔͖Q`.":Dr:rcƀ&SՕ9ystթ7yPj3F.KP9LG ^:3G%ƭz>ߴw"S z1OFs(Ec I3t:^/Op<;P\<y&9 w'!f_wր~t9rBkK)xLC7s8MEU%y"NOE:~zQHK&-w^]0or奡{@'iZ:LwC%r쏞yp.uY(\<8m3:1߉7v+mlB3Z3 =zOpq"̋8t;v>V]2>U/ W5d4GHl[[U902rHb{]_St_׽⹕|nZn=#.)32rI*T#*FFTT@ H y;*K;3%_ i?+㞱1u2-w5-zMW _PLP?'kGR\|]JoL!Z݅ϗ:})sIy|,{9cg`̔ DވIʿܹg/m.P4ӟ?Ǫ3'n$J9o71- 9 a$4zibw;f.Kf2-6ۅ?Ńssi) Z}k6 6ck ʱT9 ƵűB`Vs4{ő\&SW49;2^Z4ur4 &ٮ'ycq tLDLN6ҥvӻ|j %0iXu ]=*v6ٶ2f}pʳmylHƏVN}{7s$WDɗG@N} 9y=%q1ޛ|[pVTfb]E7 DıBId䄗cs}$@sY׬m}.n`ػ6 فOy9Pr9C_Ӹ(})YExa:oپ/Clmі.ꋑb]mѰ8kw2YmSӪqbNxXH1{q#֭Am=JžjViYj vTcְ!Ų-V8pU%0X@6&Җ*9QEԉjztktuHCĴ< bgpIv$[TUp4]-CNQOJ;&wQQ,OM9ؗhSÊ C^٫{h T6֑"'^Q,VO 弶 膈ͮj7_f)k(Qpװ΂_B|JGɧ!ېG!tlTP.gC|;}Z #9[JL}hBZl9]9YE4 _.ֶX&<ÑJ ۃNu@j?85K;_پnvx٥U. UѬEl\+bIG dRrSu f/˨,X сA,_K%<=` w8E ka>KN#B݁q'56>P ,aڽ4 ڔw54W)ZhO}ύ[3ZB>m%Z@Hv7X7ۭI][̦QM͊b /=.~,(nѸCg48=HFz 1^25zZ(XNj6vyPM4]{pvk?Gc#6vu(( 8Btpn(5(ˉZdۏ@SF-V`J^W*daQO sy{ {Rj!m F4vGb,\l3# Yk+"$}CWW܄0OyDcjsɧfQ<ϊLZ9B}ѫ;_CY9ܩ 1PHA.W'F2+=xmQW֭cjӃ^Ԫs|{*JNlALg:j,E2g3MezF:4`йxUܖI 8GilcHr;~lo،KS\.w:Q_` `/dϐ7pyLss3ͭd·C@Fma(D{oƗH|6qݸX)!U`s5o `2~irT&s 1yh m/y7aR/n1ƙh« 񔹉[hcg]d15]m)YCݪj-DPc(ŤBLœݔX{Xl-@u,kX^SDКPʈ>|enWXxqϸrHyɎ F\AW ;*NZbl yVlK+]S=d (B"Jk!LhΪAk|5-xʲl<S鷈)ð̚(-2m_ϼ,Οzs*x<L&ӕwm] >\FBܬžjV84ZPOx7NvoBL(5%|qW]{FFqWvoXG5'Vؼ]˘MeB(`ZEviW1iՄj(o"A5[$BLB?̝H!B!?)}B!B1nD !B!$R!B!$B!B!$B!Bq+ɓ2B!B!I̘1CFR!B!n8B!BI"B!BH)B!BH!B!D !B!$R!B!$1nc3_ m k˦_8>~v|)_s*g|skVïy=|6 ' ~7EEXk.cj%lkEt` ʛ laXX]JGٌƍ/ jbZյ_h#YګKSmjωDf>5A1r̥f̥Z*kNiۏ=/xZeu\9 GY8qSd )to6>ǦǬMlAiٞ4]3rF9!dJ=j9wuP.Uk_VSh퀰-YIú=KMoMQVٌ ͶԷ4듇SllcF 5~ܶrx؄و}#&L8:tW<)r]!{!Dx8+i3f{ z@uz~: q['xۺxDsj⭾i*@CyHN/Ҙ ގO8R󇓖dm\60Gww#l,H;Q_bӣc66Dyf(c|[(%bw_h Exa 19iav\smmQܩit${q#֭Am=JžjViYj vTcְ!Ų-V8pU%0X@6&Җ*9?"Z zmD`=o5:: J$L!b@@EG$y썭Mj*M8!'v s((DyUjKcaE]e!|^/U،={x*H q{/yN(^'r^Wz tCDfW/@H 5}kjp@kRgA牯ey!|>WӐmȣNiFw*QdJ>>-E-%E7+v{h>!30"mmYa3t=Tkd~j[4,$0LVʰHU&0ǃQf/˨,Xei$t<6n0xmѧQgogRW'Tq0Myr۞}1 CDc\+(';Y/4GzwKt!筸ހgm2X,t:= %vƝhp@& t p, mʻA-[M{z^4]F>QQ-b-!6_l-J D$eAZPWVSTخ-f(JfjRQ׊?<>$jGz MhnS"%l,xkI8`{/jQ@6Uvr;(mTkաt6¹wԠxRSk.'Njn?JgMrNX!u+Is^'sHMс /%HBJ:IsE!zt`m OOi~s0ҌUSɝ!W3nGfW .R HQ6=Gy-)&ۿLP |aN~mvTӃ^Ԫs|{*JN:::Hp #@ ![ ր.:[  yx}ZCIbekQ=>X 3hBz{y|ObaxCʠN4Pacu4bMB( D0ٺ} CfmH5h`5rFǍ8Zcn`v!JkCm.Psӕ՗O&K [}` CO4IDAT):.,㘟щ]VΚ1]96+{AlLbxM-? n^?SsdN'  ECS]'G8b}|[i4d v#qu-M?W>xBdC|"81|Q1_䣁aOEI`:wW[S))/I)&]m`k347 ߞ a_= =؈f {oƗH|6qݸX)!U`s5o `Ԕtk2AKCShvj[oyNXz_Qg%gO]Ͽ`'nOT]I>D Jwb6֑*ޛ~IUމmmD3;7ڧdū;Nį\$Q!q,6޵#EY5Ta^q]I#Vޏg!6>ē&Ceєܑsؼ=Sh]Ff\,E4Si+g{?ୂI")6WSu)cL՗ɓ?"ažjV84ZPOx7NvoBL(5%|qW]{FFqӸ_Ӳat 0=XH!&oUjovZM&O#TEH!$t߉B!B1!&NI!B!ĸI)B!BH!B!D !B!$R!B!ĭ`Bጓ'OH !B!$3c I!B! B!B!$B!B!IB!BI"B!BH)B!BH!B!DN ~s/^ k}Op@ל*<)9OdwS^Tn;֪VBj̶VKwv`+h!vٌEoJpT`11Xk *+V[]6;J4X(Ml8!VVqc)\5K͘KK)TP#{7w0m] BsX9i}ED_7lߗNŃ!66hKoHS1ZG.ֶhX5H`;C)L;*wm1i'<|#'½J֠?o%B aO5+,5 MT;1ykXѐI݃bc+eeyitikY[R|FbkS'yӥFaiP"aB4-Zm>%MdlmRSUit 9E=+TXEYD!C=:/J}v>5ˠ0ş v@ecfB\~mk%"r^WzM|nYV5lTP.gC||kjp@kRgSj 5,şl5NH"Sxo!&mN>kWeV4v|* CfxlbmYa3t=Tkd~j 1' +KZ#+ W Us$g>Pkr4kӣCf=bTD:`avjC?Pd|jCwy/icz0;T܀nI]h,KNJC j P(q:(ՆWP2 ,¬YZ ) \;C,F h $}nY%Ԥr6[" M+)5褪'vm1MI05l6c\iL$c_b3^AJ0Avk3 Uj<+pt1[$RKX%Gϗ5NPSN= r6q *il(A g>jA]$e 4bwSḘnh~O&3v;7' G~}CFīBL$&w`i6oh P x>,YQ6I8Go4c>zTrk~1@4 2g;5>A332)mNquaqb(܃G)K)ƓP 4b ;?cqbQqVy'Õ{d]M;:f=t_czvi+i,BO ٮJU2|Q'DJ5%{q:-֒3oa2 D%DR"&ƅ: |B!"5 4քFB%.y~| h(+G65yuG/j9=%'6L s)KDYLbSN> &='0t%^e9=+@l69cZ9-3܎盰?6Ծ0~:u'}kVƂ Sy Wj-s>n )ׂVva!ePx:&n!Z^p"T| l]l!a6@$񀚊jp4X0LJVjq# !RxK(\.t%]Ʃ.cgMEs.go; q?EGIyy0 r&o} UY0PsKfT (xhl 9OǷC'9 19ܸYU3xgk_>G * ¢mHylͿa0]0wf~۷@jhfESpd߇2 gqM.]lvBղx鹅6vQGt81iw0n/xGgEq a.7g0mm~#l ?!roY&J@O^ƪ:{Qa@F3ԭ2Fvs1|&:!߅NԳU5Ԕ[PfgIsv'q'i).v[XJװ 5q-+l,Og\9gty)w8 s}I]!7줪PC䵦PZ aF+uV Fk9Ɗu۲1Y2bBam^kL^Vk|5-xʲl<|6yvwQcYsrXvQKwb[ow Iu q)Ľa;?]]u>1l~ܣ6nXZk}݃0d8[:{ y G]>n27a}j+)f#fgъ.4>D q,~jtD6!b{ӝ][_F1  WS˂P(@ P(@ x\i^sATP7/$' 0(zƠ&찭Iq}r[ܙC(tؾ> PݔnRzP4뀀}SHZ\Bupܜ(@ P(@ P.p;(52À)iHy<kɈ1(]9C`zg/5:C^m8w+j7@<6QiJf ;k//tu_n#mg pߊd@HH?2XCXu݀΢IwĐfFUxV<{y >hvmFjf (d us?Ptf<pY| @|lLV'vm \dvt߁ԼU#| aPz]XT P(@ P(Y7R9:)R0^>xenrލC~6DpC'Ms 2dJ =o>;A~i3 E)PQ>'&bQl~Xt 5/Ps5Yl795kQg5Yk,mRV`33*|ÐFMs1w6U#7'Z~j"E-/%vx hvjX Ҥ /Ij\-N'4r8~v{MWTSsbGs{ J;S[7 JY~x((mFbpo(@ P(@ Pp;(x89<5r? g8|EӓmAlQ|w0A#u)_Mb>W vraSf#|#hPf[-H =_K Uo~'@, 2z\(>`B_rwLz^^6AwHz%@XR*NSH]g!l-HsN*z{*?nDI)H\A$nJzy6,pL0,,ފcgqVlRT ~ !ـ;gPŗ(@ P(@ ]%(V0.zR_": . g53C~:_/1 uRyV!hr~~bћUyXo6QP+ +ޘG͂s8m f9UoNDI`/oqN%}<ut:Et{_Ed|Q2ˀ5&7(3í3(6,-(@ P(@ Pb^I'CYik+p0>;w:1X sf=ۿ_]$\VU*Ū °եbtvh\\ڞ8{D|P#,A0L AWv횱$T o϶;5/wenE;\ fRExgaX;aobX?a!*7Ϡu(@ P(@ P\ >ʊNv#usu ^?(v/La >k #`G|qPEcBQV J,A (A'"qAJ/SA%/qPzzgr>wf"F!~K$d6ޙCK),?T(@ P(@ PhA8mV=4礲}^a24>{a)f٪7O#iA.Y o~ar1ۺu.L-^iohDPjM.V^ nvRyhQPjU7.W{?/ICy{ j C;qjG6q4gPZ(@ P(@ P JiMa\̵.K^8q}sQaxdR&[C] ^^szB?!kKyR aǨޙ \ ~-uc轲Jh;)`~8 Ľafj٪ܜ@X\f3_Tʀ*qVAɠ#A2))Hyެ7'HٔBP]5^GpiHR@qʘaD0HGŰ5qq>{i/rH1gsEo1|?nA87(@ P(@ PhAgbGА?'UpX\yhLL_2q*_qR2Ek_G,> L£:=VO5RA!Ka$8Oge8w83A3sz\ʣ ~ ÷ߏJq>(5c+I; ƠE APzE#Ңx0 9o S"HR]Ɛ@/˹i`w 7o=( !rh[Q(@ P(@ ܝ \Wu7ܙ N|9JO#sq. _yHAd)A~ Žq{Jrz>rCpt9!h-8%fyn6~=L $W73a+0N'0nQ.f(s6B-`ZIGj͛hZ @|ĭŰPw ^>ϟhy (HQ.VƐL*?r14 r&,\s_~?2(<80uz,4Oٛ{w P(@ P(@{BRuM xUoݨz!@ !}<;Oo-橎]:jS(@ PsR]=QtrH P#!@s^GM` pSӠI*i`}S vȨ:hBc1~[9fz,('v@[8RftyMZ_ƣM/[)F6]݁wjۮ~Cj9ɍg1sl|t="LtwyLjۮw"OjTؽn4/ƏL.N`r4.a:v`׻\E<(@ P(@{P͠TKCvHߋ]I]ݕ# ;w/AtVK;{zUtYY鑔\RuFkW Jf:oڷ={+ bS 9(Upm J-zvWmoyĻA@PdQCmUPYg yίwu/(@ P(@#^Pt#"Z\zahLbPơlTHF^<KICѧMY* =FЫ0/MZmڵiU#:iRrVV,G4+S0o~RQiFb*PҖzl_qdby\$o#cJ4J^(,{6Ti56V]ʲ19hZ0'(b^Ti!h"I_0d^ȧ֗f @mB:䞵~r +%ie z[= ^.Gz eȜ{Jr%\JvօePr`~JCmwdAni:|wjVSwl\g"1e'v!-B+PMMF6(գfwmjUrr$JĈ4وo6xO:\̙>bvm(]۩r;^]'K#h׬p yPxd-B4 ZZMHg6ʲ %wQ> 9ueꖽ`mKEmY[:lٵÉ~j+2˓>/Rqɨ|/vOqbCVkfB6fQv캥?:#ی>۹C5-Gj!c5Hxu w`T`NT.bY(u#qm4fJkLZE0fh?OgRK>nrKFq]U? B#-@:LctZ(ޓ,}&/@P4 7wӺ؂i{Fq0R:DlwqP7]]rNI 񊄄59O;w6(@ P(@ )VP-Bl$DU/Ba=z&UiP4h&\3܂uK*Zן_8S THÁ/*lAѼpd hR3ѧa?2V le1F_^HOb&s"=zi6N@!ẍ́Qگ+Qڂ(s ȯEk*TiQN* >u.ԓ>;AflO@"Q\)>+'&AxOUcdƀj1SR9y1ȌSD c]Z kQ!31E:M(7Ң/EUx̍UcO$4V X#1GrŐGxuas";bI}~{rQtI,ì#jx+Sc/_:`Sy!Fi9}I XfrM$.YC[MͺLKFJ¾`ҷLa|k?*CXO7ܧ<%ۥAlNc;E/# Fho8-04舘|=慊m?)Fbzlh‘rB"sGc۶m-~vm IDAT䳕fu3YNM@0=roDRݥ"2BƆ D9fm$iN _p iRn)Ms7!4LN=9P7ar!pުdX^1b2r.5Bmvܞ!\Д놪n-975F.\ǰ5eH+  (bxTIa fp>2$ G7 md%C}ٿn<{'}CH& Rn|b׵aӑBZ$6Bգ#&("U;A &RP,O3%h5oct MG=Y;=QX .l0Sal:#,pUVnR-̯*}CD Jˎaui.{ˤЌƥ8iA:;P(@ P@G J!Nn.Eۅ-, =n< =}v8J =nUD Fb|J oDBaȥի uؿ6ͧQif!yHe3,[׀Cep-8R E2u*Vr6`b6FU 4ǟ3zk'2.d]F˒v(57 uaݣT)@^s\B #jgRx +yNjDliUmWZr ڋL9,hf`Ǩ*D'‼ =2FEgaV/kۖm1Pųl砥B{9@ s%ʶ- h)Ԭ5EjLƶ_4܎UA\\BY;kֵGQ\A^~Pjݶ{XF/my[8;ϖxX[atqڌ6m7\[)?(|IA]B&#eOPi‚oNGSG ڄC`ѫ$ AfTBUu--{rvs5ap{jĪ}m7i:,y۴8+iP%,:hrQV!{kz\(ƂE׬~TRBn~\=#yH^WaKmt j9rpS(@ PAlhA86DHO_wy5x)P84i_ϴX<'87/-8^2ȯș&l:E ߥWdbX!  q _p`X&*"mzy@4́POnea&h#c4exuPP6,S@)0_diԙ6JsZ.dn,fw64YzYYr5!F旴3޺,ZSQ$',2lyPZheKuay~vi(2׷rjJAƇTE\1+Ql|VWNCn;rS+{;?)(n8;?^e(o &+h8Uf:#9Ќh&k%TrĿS<plywpwu;pҵ]u_+v.ñm}Ϛo78󗷰E~i?~iYN+&wIJw/ wya9+WLG5-ff0l^YÛjO-F<7UVٖܾ_`ge?9VLG.G`&(M5Z-(7>>9 K^ ~7癹xu@9-њUr),\r˱lgYݴ3e&|d2h+5mUt{R'7dڷ|%SZj{Yc%v%f >vۥp&۰(iO5KVNn }gale Z{BaۚXVp ~OFaՋcc1#9AcXb'ZS&Ј0Z^ 舟Pw'7}&!/iY[s2&ъ?u3Yŵ@ Qj> |^|Fn30nC PndȧD_O苓ZxO^{j? Ny =JQkC'Ň?G_ 84%o (f~4\Q>bdSW' =\4M >9?{؀rY*Fp9 N`۾G>5rp Tu C >bk៕~izDc`ӟV-cthz&ƒj.GTMnN P(V@L1-V_MxfTH@0IK'|W 9)R 'cՋſ%g>,wFR~ j`쫰}~~=sWv*WՊ,0j|~}._vetI`@鞣hF NG9pbc[p5h6rX;?o~PAε|<c DBA0JPjYz> {}Z1))8j J}_CUS退m9p8xThuqi{*S2EX?9ՌthFBٯvXZ'#hk@ԣ~luiJ#G}J=~}"ؿg.j^fr4:vy[;)(.:hVJ|V (AiA홋@XbYi?(;p0| 1eU78R/n~,(p;<5_Pfūuεx2>A߈Pk~$s}R IDATA$ qRNa'lmPڌS$TAV'#pߛ(9cH0Ŝ -3{^*A,;/- ?bE(@ dwZ (7=Ұah`N]+9VD^RyِǝӘo9/+[' o|l`} RwUhGݥyP*Sk&GoʑRnCcoW{1 }O.hM\#Qʡl(@ F ,aA!} ue/v*h1x5om(J1v>4"oҗEX ~-,tH JWG}Y،l4&R5{nGKk _ G9J:"|B:hvLzpʚ%.*,d>uˡ}Ⳉy0U07kYPZʤSR8a 9}ᓆÿ">.\@lV3Q~(+PE<`:3ىUg8vcc{fQ*Ch>sk7Cy(``8.P*@྾ЇqlWv KzB3[>eܚPpHmd<$P<򵢼]hWߑj, ~aQ+CG$ si r}} 7y1j>P P?5r0 S1Qs5/cRR&CZ5=csoa?>fj jBqn+e@ 9J9Bi5Fkr0]u y yF 6+R@ 4h\\[x>9N?l3<y+a${!9šVNSa_NS\4[8Gzo~³,?8GqEez: ;yP"ƹ $O;q$$LY^RX(i[&FQxi@- }wc63~}YgVVIkjv%iAAV\?wʺi@k+wkԋ﹎n~~VK,:%M^C[+_f{6AC{WgJf@u7sN+ w|/ tzPz/a\(@  :^{4!@ P(@ P[zkͰ\<,4!@ P(@ P[zkͰ\<,4!@ P(@ P[zkͰ\<,4!@ P(@ P[zkͰ\<,4!@ P(@ P[zkͰ\<,4!@ P(@ P[zkͰ\<,4!@ P(@ P[ J^rQ zb|C37h&7(@ P(p EŢR~`/bܞ(@ P]#*@u ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR^Z1,(@ 08) ;n=P(@ PR JǏ +:<Է7R<]%EÎr(@ A³y+BQVz8s۶|K//~{C?Ӹssq٠SgA>-zI7&*ԝ Ib-6黫X3 ~L P+<򵜭@ggHDO}ҕt;C=n9Zި*o{px1S9BWS v@oC@=;G|T=|HzE`!GjM7 U҈:ਖ਼9RDeЈ>x>v."Zl+'#fB=6zhmǡ3T(ߗ;,KS  DNtlR:4^kDݿI3-uRlPx?E¼0c]=Gi|ʵKHn@ P>"yq="? Utn2sOhyԙ>za--bJ"f>O7i5d;ҩbjdӷx.;gR] |zzsO O8o{b?aw0u̱ JkbVͿ?v<{Gȝ8 ʳ19x\duhs6@JDO Eޯ Q5/ĦDʡ~p&C@,?}GZSCݎyPEDB/?Jرupښ&q `O`2tNڔʠasv^(2>MKUմx eM/f{]KS]ET_<W1vOlM+@ ӎW P\/ mw7SXDIM'ȲtYɽT؆C{3.3:a"-xo5jŹj1g%Čm@:1+ (+`#%x!+W,TP\9΁Rl 8ep?XD('NHJR%/LQa dz"&=#צpWr_wiG4K^Vw<»OEA1-+,K5wjJUҎC;gLk$nOͬZKsIW-M`ƷYj83[z_ָH"_GIV>G;&L78zT@2 } Ir*gœ#qQ)hXJƊ@}'^vLeSA>C9cFx߲5̘7 f_I:ۢuFֆFyMԤdm\I$:[>y]em]v&7~%4 =gn\I~lQ;Pb#W()u˗-c.ws.jMvGVfS1)sI`evZH>{'DRͫ$K "?Avu]#_?FO‰kخ6UيyI6}_9B? Oi·_4\x }/9BIB u˩-Ul"lbO"9.?q:l.֝89J?kXJ3qaL{ IRLN__Q<ơRV%ѩlqW>c_xv?J!%eϗSQP6}\HV.|2`817>=+}HOE^Jj!eMx* ga `ϡtzXHݜ%<ڌ9cfumY:; ң 0o|9.K>RszR Un%.Wj0oPKa ]T۵.o(W(ZƁ?%=w~w1'\ 38·q|QWl̘o`O0H?oC'S{SEZ$XccAH/uq4*ܵq-$fTss*`:jIˢo`G}vo)XK52w#hR/{^fӫ{NUL?d-U|Ү-x7%iv F̘=^R/Ң('>*?ypbo4-jr( UJ^hk7ƳT](vtMqfV=w[ ^Ob"5^R; wx A7utjJ]C{]TiPtd?i'#(c9}nQ{WGrUZRpgx=Csvo\܍6Ffb"&"G&_:5@!MBUyb qP߷{Z}oS?Թ/m+*B1򒖮 (u}ݞغML0CުH~oN@{k5&L{k%SB@K I|UCo܇{Wd&2~>DٵJk5+OjMac$ՑAǵJn]:ަ=i<ۙC/·QݥK{ƒBZf'4XtG 3įb B8v>7TQu)wTvGBi;yksɩaP(M''^DjIsy1DA)PJI̵'^6nI glu"Fg>"`7E<eZs>PKxd0\>}|GrZY_G>v%H$ŜE:VŨ/RU ;^dԄW(syΫEuwS 4{R>MEv.kam1)-Ϩ֋+=\vp4b.#FOc?zE]gBg!ăs{¹Yv+iT&[ys-6zڗ{b6.PjX8 IDAT"jMϧ;DiZ~v:\Y9XH̸bND{Q9 u9pa={Bi{NmX!u!kD(}ɓK#"s@ww'oqVQeD MHͥj:TՔYu I&t'`BȤҊ4B@" }/9hGfDhN% ]H;2$5CpԸ:J5߫tx9Jb}w1/ʔPR]|9>GUN}yPuşF0G}q` }n;0"j|OVW[J ROHX4*SO`^{[]=Tr*[1;J{"jfZ\ipJ7P(_{ I8wOt:"-"B OqLYآ76rR\0UwF2b&chnJr9zOcw `wQʝ:*bb |DUwyE- SYL#>Gs_U¹I.Ih͓p&MʌTQ4+>Ej5u =LG{)-Ȯ1ܗ oՁ>F"nPq.-IqZ {sBDJ5 * 5%x-Rs8&NfnN \b( tTM\-w[D> CGxsg0+w WJ^P!+ѧUAt7UdpL9dHSXLĞ`j?\ J9gh M'HgSI39c0t27p] I/L'bF[w `j?\!raQ$]&jPOhdk=I~i7R}Q=saaP;5,?34y2!ܬbynWױ4H^m]=B=juoj't؅RW$53~7 5~pjiÁhM,[_үj<]6?H_uIǮr]<غq/hǭjU52SvFQ5oj!nvcPhcbk\$^̾ DRzu}0}JH5sOIo]ŜS _7.02+g}hg2'ݥM/x[2:Ӝ%9U]S&%uclQPJ{5!']Χ&#߰uwJ}<)F`SN"ZSǛ0z |k.i,}OϦ\9Z!xmq&Lv6>n=ƏpҥCM:@G0,ed?ҭ}FϻT,F! _V={jlԨڪW0wb}Sr#)i>O/b4Bߺ1}&ؐm7|σJX[55aή.!t v~M=Ӥ _ܧSo_^꫑usX.=+BWuW ׵<=zDAј~1zH}쵑>1愀B`(" EhׅЌC?Jin=C'wB'\!4XRz)dE;my13=g^|eYZ>j&v-~Syب>xp.imZ͙P1x+8**Llzk -~SwzdvaB&M&⟠r d[R">K)cB@ F@Dѯa8z҂B@!tYY.7Y#W""l D0E~jH4^N`^tͶ}q ffYøi9Eetknq!҇k[?`30K(HgZґO+F?sp ! B@! % BBD=Ra8z҂B@! B`tNtK!v"0=CiA! B@!0F P:F'F%p;FT B@! D(#B@|G* GPZB@! B@Q"щn ! N@D#g(-! B@! (JHn' " 3B@! cctb[B@oHJ B@! B@1J`DB_1: B@ ENkGP., ! B@! K`DB鳋AF&7'Y B@! D(#B@|G* GPZB@! B@Q"щn ! N@D#g(-! B@! (JHn' " 3B@! cctb[B@oHJ B@! B@1J@1:1-!  7zp ! B@! % BBD=Ra8z҂B@! B`tNtK!v"0=CiA! B@!0F P:F'F%p;FT B@! D(#B@|G* GPZB@! B@QcJ(UZi'3M.7ow/(<B" "K3؂j9&Խ̝./|}ʐB]k >^&w5vn/|}̭`'N@ӝz1c][ 73mxm%s<{u3i+7>n}/! -nl-5J;8?-UEYageE=_&nsckBCm7)/ދq|Z)=Dž;zҞK{yxPL!bM=oQnwS_G$`lL!u5Y8KX)*Ӑ5'Y=+Oz-w%P>ܜCÆc`gc)!0L45'23iͬ_1l=|k wyxy?GOTydG0ꉝ^BKtLb$uM6ԟkbY,+9g|u[d#6:4ϙٵ,'6krc! pg>@drΨ(<H?SKt(Zʁ?%"ڏ]w\UC;Y&,̘?CD"Rݟtrޭ'|Y"Pְ_.zR+Lg*i:~s];^4^ܶ> IJiLrUt>4^k&&o\'#\e>bn$.qPX͸DD:_2~u*7H8fqfGXbR<09X+A=B@'L"_w+! I]QZI ]Met )54|e!uVCZJ*5`vՑ^`vRsu)̡f k?Yra|⳩M;Nrc&ܪ$4:U”bEhv藢u8=cʔPRԛ%bI}xS ?9LJdJ=竘f42D՝թ=p_vۻ9Ir{(m;.0>'J%H5[@j`CXeksc9곌\'I|kOB;@`?ǺnSh몛A?i~]Vw`d$Э8i:*Jq(8km]3;gk:җ39+ ӀzЏYo nU( +SY *\(}K7{EYks"m7Cydj醃 |3ou0̅;inJS/_j)%oYbeG1swWE0,df/~<_¤2*ObfvO (=-MfԾL JJvR[!!/WJg;/,!{,_{zjŎ/Γxm̸d$F`SQPAk<_ؽ#V,Vڮvԗ0Kgiv4ޙIzݞJk-'Y&xfs)~? S[opf^]I2qD4|vgqknO$з/r&)9bzn056!d|k mh{4__&~>?=s1H7 }*- ! pȧoO]]|l* /'fSa>_ $KbrbU!Is^!p5Sԯ jSFJ#S݅ 1Gq]Kt0G'^4P :W q4n+Btq$u#TQhC(H?_W n4d,0ՙ5,CԜ,Zg5OպQVGR$w:JIZ9"=@Ź_;PjwnqW?Eպ9|#r~na%>YikCI1D$Vgg'n%!d5MKvR?a=jzB{4r_+S5< >*$vr! FI`8%=K{3BT,fD33,l;T)IM':##5'#Uy.x~c|n;k!u]@ֆiswsaIqJG˕$gq{^Cؘ1o1'*?`~3)7kvN4eH:U1Ƃ_hU1kAZH̨T2t6ՒVwE+>y޺S(kdXGФi/`6 dN]Ŵ:ʓ7= &PQx dv*Qքs?Env-aڝfM`Ɖ }W3 :tibg=/v-ƻYHHrL?Z^\.'횽{c^cd%V,.(@7Ͳj7U1>i6,a7MԴ("?\5r;wfqnGK~ݱ`sEI~-]>"-|S8΁'Fݢἡ*wOPmϩd}Ev{k}_I~mN_צ!8a쥹,M MNVs:jS?񵋦p3Pw޻:JE*  {j4ilPH#bDGW9HGu/3v/e 3S>!n>9=QHؾ0n_=mmHvky*M<*7A֩LciȈpzG>by`gUd?vq 0fMad/+Vxn35ՐDo%f|+#}^3psx=@Sn$jDP\6/I`jrn?)r߱BDzQw!/-PJu]ܞe\R, ͵$ܵ/>Χ|yjQx-DwiBB}tA Md9|1 kjVla3f=Iƒkݨ{su޽M x{Ag;sSQ(}?o`*&E>8z١ mg屛do͓-lzgn"z>o!6M jhcX,b=\:] i @{wiR~=} (8)Qm#L;C_Bέύ1>U5eģ0ʼlOjy3[w +#qPV??dgh©kטUO8^IGAD!0\n*-Ѯg^l~q> 6/$;p@»έ':zC70ty@MF8Igډ+^,\vucJc^%|?9ҹ~NR*ďؖ[MV?PjvJ0/I#$Dui3~(afGrz*zTL&PKslPjfuiTO8vX㑓=yO0~1_9A4WNw+Tw:<5-U?}r|GrZY_G>v%H4?*]ױj.F}/D\-"sW&VFaNt^-ݭK-lJG7ٹ7o{>qf_r.vp>Z/-Nr٩!NT5O .>K)($~K<8'eHN5i ]7גk}9ع!j vq.ƙ٤O|J K$QH}`w~ѕC;ČK(/D2aB}e#H rz7ۅ>ۨ1lCBtP'! FD "=7P5; ǁҾ;-K Cv.TP6"x`o+tatXȃRJ/B~mbPZXKQIU! T5l^ȭhd0~P~$YJEg bcA2kZUԽT7O[[m~|VʖCU$>Нzɍo Ý^?qIhN&{a߶nfRi9Z_^OqQV_*+xjbԼÆpu:_RͤE!]'(Ϫ8"{>-͘?ݝ =Q33Om Sj08I5'9o=ef8JKw~YE[Er( E7 gaO6rR\0UwF2b&chnJr9zOcw `wQʝ:*bb |DUwyE- SYL#>Gs_U¹I.Ih͓p&MʌTQ4+>Ej5u =LG{)-Ȯ1ܗ oՁ>F"nPq.-IqZ {sBDJ5 * 5J KΛ:i9-s\7}PUBG7r1nU}yo?/摘QDDfaSm^MsrQ}?P7j7s_ 8|' آ AQ*0)Zem 6+YIZGސ+-  &'9j`iߗfӐWL^7 Fb+omϕGΑ(MZb zJդk2:12'hy^CޞD&vϼC鑳Fo`idBxӵq)JB@<F_rJ׆rs-Nc?+:P9jxd>GSn?2sl}WI{=y)ZB9kwˉw:PT_6=\C5p幎GuN")tSOP 驉̟9:}2Ԋ_vQIz85_3WZP1j]G:tvsF|6IF̙ΡKvhkaV'g'#8J($_CՇ?!ZMGhlEORݼtG:FZ8Ũ)Xac"c-bQF{lZIs:̉xۯ}2T*A`kgӝ9Dn?cx9T-SjxgDcB}J().9z vjYgcd!pO^]Ǯ4_(#zuP d῵rޞaJ*_ՒVjx;H{X;. 5l}KwٸPڷ"m*u$u,bcuDh*~|VUu|Z/NeFmU#F85Nī,< v:&EelM@$u̾WS[wϩdo_۫)\+zP\i=AuQb #rWZv&|]*Wz%Cs8͉Zs155:h"_R{(+8cRohM 7@3H95;s((x0sFи*} [S z )88f Gv!>X? U,ѨfVPi^6a(#+\͡t)0΄aiԇ'h& ! FQK ՜UzqkgbswJCc@7|AJyӋ x|nLߟI-6x| VMh릳 y2q6r?(B;']tyO4iB+ۗ.׶jd\8mb ЩuvgsUu-+EQP4{dM_{m$pOL(u89! |Cua84 "5794lG^Oyfqn+̏r8o)m ! (Ws/$&>-^lgaJ1Y~#kHsVXWۚ1uCݮfb7wf՜uqTnqUTtM7[.Qk}ML?A. J1Ej΋Pw-7B@͇z͂VHN7RRoi/Oo>E(uKiI!0 7g(-! B@! (JHn' " 3B@! cctb[B@p~`~-CB@! B@<D(}6UF%K@R JTZB@! B@!r[! ߝw3{@v L! B@!]# BwmeB@|w pwFjZvB@! B  O^! B@! B@'O@'?! B@! B@! 0rJzIENDB`django-simple-history-2.7.0/docs/signals.rst000066400000000000000000000022771341765771300211200ustar00rootroot00000000000000Signals ------------------------------------ `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 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 ) @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") django-simple-history-2.7.0/docs/user_tracking.rst000066400000000000000000000136341341765771300223170ustar00rootroot00000000000000User 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 everytime 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 django-simple-history-2.7.0/docs/utils.rst000066400000000000000000000014621341765771300206130ustar00rootroot00000000000000Utils ===== 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 django-simple-history-2.7.0/runtests.py000077500000000000000000000042341341765771300202350ustar00rootroot00000000000000#!/usr/bin/env python import logging from os.path import abspath, dirname, join from shutil import rmtree import sys 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', ] DEFAULT_SETTINGS = dict( 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, DATABASES={ 'default': { 'ENGINE': 'django.db.backends.sqlite3', }, 'other': { 'ENGINE': 'django.db.backends.sqlite3', } }, TEMPLATES=[{ 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ] }, }], ) MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', ] if django.__version__ >= '2.0': DEFAULT_SETTINGS['MIDDLEWARE'] = MIDDLEWARE else: DEFAULT_SETTINGS['MIDDLEWARE_CLASSES'] = MIDDLEWARE def main(): if not settings.configured: settings.configure(**DEFAULT_SETTINGS) django.setup() failures = DiscoverRunner(failfast=False).run_tests(['simple_history.tests']) failures |= DiscoverRunner(failfast=False).run_tests(['simple_history.registry_tests']) sys.exit(failures) if __name__ == "__main__": logging.basicConfig() main() django-simple-history-2.7.0/setup.cfg000066400000000000000000000002451341765771300176100ustar00rootroot00000000000000[bumpversion] current_version = 2.7.0 commit = True tag = True tag_name = {new_version} [bumpversion:file:simple_history/__init__.py] [bdist_wheel] universal = 1 django-simple-history-2.7.0/setup.py000066400000000000000000000031171341765771300175020ustar00rootroot00000000000000from setuptools import setup import simple_history tests_require = [ 'Django>=1.11', 'WebTest==2.0.24', 'django-webtest==1.8.0', 'mock==1.0.1'] setup( name='django-simple-history', version=simple_history.__version__, description='Store model history and view/revert changes from admin site.', long_description='\n'.join(( open('README.rst').read(), open('CHANGES.rst').read(), )), author='Corey Bertram', author_email='corey@qr7.com', maintainer='Trey Hunner', url='https://github.com/treyhunner/django-simple-history', packages=[ 'simple_history', 'simple_history.management', 'simple_history.management.commands', 'simple_history.templatetags'], classifiers=[ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Environment :: Web Environment", "Intended Audience :: Developers", "Framework :: Django", "Framework :: Django :: 1.11", "Framework :: Django :: 2.0", "Framework :: Django :: 2.1", "Programming Language :: Python", "Programming Language :: Python :: 2.7", 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', "License :: OSI Approved :: BSD License", ], python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*", tests_require=tests_require, include_package_data=True, test_suite='runtests.main', ) django-simple-history-2.7.0/simple_history/000077500000000000000000000000001341765771300210405ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/__init__.py000077500000000000000000000022231341765771300231530ustar00rootroot00000000000000from __future__ import unicode_literals __version__ = "2.7.0" 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) django-simple-history-2.7.0/simple_history/admin.py000066400000000000000000000204101341765771300224770ustar00rootroot00000000000000from __future__ import unicode_literals from django import http from django.conf import settings from django.conf.urls import url from django.contrib import admin from django.contrib.admin import helpers from django.contrib.admin.utils import unquote from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.encoding import force_text from django.utils.html import mark_safe from django.utils.text import capfirst from django.utils.translation import ugettext as _ USER_NATURAL_KEY = tuple(key.lower() for key in settings.AUTH_USER_MODEL.split(".", 1)) SIMPLE_HISTORY_EDIT = getattr(settings, "SIMPLE_HISTORY_EDIT", False) class SimpleHistoryAdmin(admin.ModelAdmin): object_history_template = "simple_history/object_history.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(SimpleHistoryAdmin, self).get_urls() admin_site = self.admin_site opts = self.model._meta info = opts.app_label, opts.model_name history_urls = [ url( "^([^/]+)/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) action_list = history.filter(**{pk_name: object_id}) if not isinstance(history.model.history_user, property): # Only select_related when history_user is a ForeignKey (not a property) action_list = action_list.select_related("history_user") history_list_display = getattr(self, "history_list_display", []) # If no history was found, see whether this object even exists. try: obj = self.get_queryset(request).get(**{pk_name: object_id}) except model.DoesNotExist: try: obj = action_list.latest("history_date").instance except action_list.model.DoesNotExist: raise http.Http404 if not self.has_change_permission(request, obj): raise PermissionDenied # Set attribute on each action_list entry from admin methods for history_list_entry in history_list_display: value_for_entry = getattr(self, history_list_entry, None) if value_for_entry and callable(value_for_entry): for list_entry in action_list: setattr(list_entry, history_list_entry, value_for_entry(list_entry)) content_type = ContentType.objects.get_by_natural_key(*USER_NATURAL_KEY) admin_user_view = "admin:%s_%s_change" % ( content_type.app_label, content_type.model, ) context = { "title": _("Change history: %s") % force_text(obj), "action_list": action_list, "module_name": capfirst(force_text(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, } context.update(self.admin_site.each_context(request)) context.update(extra_context or {}) extra_kwargs = {} return render(request, self.object_history_template, context, **extra_kwargs) 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_text(verbose_name), "obj": force_text(obj), } self.message_user( request, "%s - %s" % (msg, _("You may edit it again below")) ) return http.HttpResponseRedirect(request.path) else: return super(SimpleHistoryAdmin, self).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_change_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: 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": _("Revert %s") % force_text(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, # Context variables copied from render_change_form "add": False, "change": True, "has_add_permission": self.has_add_permission(request), "has_change_permission": self.has_change_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": ContentType.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 render( request, self.object_history_form_template, context, **extra_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(SimpleHistoryAdmin, self).save_model(request, obj, form, change) django-simple-history-2.7.0/simple_history/exceptions.py000066400000000000000000000004501341765771300235720ustar00rootroot00000000000000""" 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 django-simple-history-2.7.0/simple_history/locale/000077500000000000000000000000001341765771300222775ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/de/000077500000000000000000000000001341765771300226675ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/de/LC_MESSAGES/000077500000000000000000000000001341765771300244545ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/de/LC_MESSAGES/django.mo000066400000000000000000000036471341765771300262650ustar00rootroot00000000000000  +9 AQL 9H\c {0*B@Qh x `   /5=<D[!/$+T&    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: PACKAGE VERSION Report-Msgid-Bugs-To: PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: FULL NAME Language-Team: LANGUAGE 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.KommentarAngelegtDatum/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 bearbeitendjango-simple-history-2.7.0/simple_history/locale/de/LC_MESSAGES/django.po000066400000000000000000000065451341765771300262700ustar00rootroot00000000000000# 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: \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 "Angelegt" #: 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_list.html:9 msgid "Object" msgstr "Objekt" #: simple_history/templates/simple_history/_object_history_list.html:13 msgid "Date/time" msgstr "Datum/Uhrzeit" #: simple_history/templates/simple_history/_object_history_list.html:14 msgid "Comment" msgstr "Kommentar" #: simple_history/templates/simple_history/_object_history_list.html:15 msgid "Changed by" msgstr "Geändert von" #: simple_history/templates/simple_history/_object_history_list.html:16 msgid "Change reason" msgstr "Änderungsgrund" #: simple_history/templates/simple_history/_object_history_list.html:37 msgid "None" msgstr "Keine/r" #: simple_history/templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "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/submit_line.html:3 msgid "Revert" msgstr "Wiederherstellen" #: simple_history/templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Historie ändern" django-simple-history-2.7.0/simple_history/locale/pl/000077500000000000000000000000001341765771300227125ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/pl/LC_MESSAGES/000077500000000000000000000000001341765771300244775ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/pl/LC_MESSAGES/django.mo000066400000000000000000000036041341765771300263010ustar00rootroot00000000000000| Q&x 9H6= U0_*]l U   %5:DAI  4!;&]     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: Report-Msgid-Bugs-To: POT-Creation-Date: 2017-06-06 15:32+0200 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żejdjango-simple-history-2.7.0/simple_history/locale/pl/LC_MESSAGES/django.po000066400000000000000000000056601341765771300263100ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 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:17 msgid "Object" msgstr "Obiekt" #: templates/simple_history/object_history.html:18 msgid "Date/time" msgstr "Data/czas" #: templates/simple_history/object_history.html:19 msgid "Comment" msgstr "Komentarz" #: templates/simple_history/object_history.html:20 msgid "Changed by" msgstr "Zmodyfikowane przez" #: templates/simple_history/object_history.html:38 msgid "None" msgstr "Brak" #: templates/simple_history/object_history.html:46 msgid "This object doesn't have a change history." msgstr "Ten obiekt nie ma historii zmian." #: 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/submit_line.html:3 msgid "Revert" msgstr "Przywróć" #: templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Historia zmian" django-simple-history-2.7.0/simple_history/locale/pt_BR/000077500000000000000000000000001341765771300233055ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001341765771300250725ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/pt_BR/LC_MESSAGES/django.mo000066400000000000000000000035621341765771300266770ustar00rootroot00000000000000| Q&x 9H6= U0_*N& C do^~    H Ii +5-D     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: Report-Msgid-Bugs-To: POT-Creation-Date: 2017-06-01 15:47-0300 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:django-simple-history-2.7.0/simple_history/locale/pt_BR/LC_MESSAGES/django.po000066400000000000000000000063341341765771300267020ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 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:17 msgid "Object" msgstr "Objeto" #: simple_history/templates/simple_history/object_history.html:18 msgid "Date/time" msgstr "Data/hora" #: simple_history/templates/simple_history/object_history.html:19 msgid "Comment" msgstr "Comentário" #: simple_history/templates/simple_history/object_history.html:20 msgid "Changed by" msgstr "Modificado por" #: simple_history/templates/simple_history/object_history.html:38 msgid "None" msgstr "-" #: simple_history/templates/simple_history/object_history.html:46 msgid "This object doesn't have a change history." msgstr "Esse objeto não tem um histórico de modificações." #: 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/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" django-simple-history-2.7.0/simple_history/locale/ru_RU/000077500000000000000000000000001341765771300233335ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/ru_RU/LC_MESSAGES/000077500000000000000000000000001341765771300251205ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/locale/ru_RU/LC_MESSAGES/django.mo000066400000000000000000000044111341765771300267170ustar00rootroot00000000000000| Q&x 9H6= U0_*m%g~ oP)<7HtK     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: Report-Msgid-Bugs-To: POT-Creation-Date: 2018-10-10 16:47+0300 PO-Revision-Date: 2018-10-10 18:09+0300 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); Last-Translator: Language-Team: X-Generator: Poedit 2.2 Изменить записьИстория изменений: %sИзмененоИзмененоВыберите дату из списка ниже, чтобы вернуться к предыдущей версии этого объекта.КомментарийСозданоДата/времяУдаленоИсторияГлавнаяNoneОбъектИли нажмите кнопку 'Изменить запись', чтобы изменить историю.Нажмите кнопку 'Восстановить' ниже, чтобы вернуться к этой версии объекта.ВосстановитьВосстановить %(verbose_name)sВосстановить %s%(name)s "%(obj)s" было успешно изменено.Этот объект не имеет истории изменений.Вы можете отредактировать его снова нижеdjango-simple-history-2.7.0/simple_history/locale/ru_RU/LC_MESSAGES/django.po000066400000000000000000000065311341765771300267270ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-10-10 16:47+0300\n" "PO-Revision-Date: 2018-10-10 18:09+0300\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" "Last-Translator: \n" "Language-Team: \n" "X-Generator: Poedit 2.2\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_list.html:9 msgid "Object" msgstr "Объект" #: templates/simple_history/_object_history_list.html:13 msgid "Date/time" msgstr "Дата/время" #: templates/simple_history/_object_history_list.html:14 msgid "Comment" msgstr "Комментарий" #: templates/simple_history/_object_history_list.html:15 msgid "Changed by" msgstr "Изменено" #: templates/simple_history/_object_history_list.html:36 msgid "None" msgstr "None" #: templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "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/submit_line.html:3 msgid "Revert" msgstr "Восстановить" #: templates/simple_history/submit_line.html:4 msgid "Change History" msgstr "Изменить запись" django-simple-history-2.7.0/simple_history/management/000077500000000000000000000000001341765771300231545ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/management/__init__.py000066400000000000000000000000001341765771300252530ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/management/commands/000077500000000000000000000000001341765771300247555ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/management/commands/__init__.py000066400000000000000000000000001341765771300270540ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/management/commands/clean_duplicate_history.py000066400000000000000000000074451341765771300322360ustar00rootroot00000000000000from django.utils import timezone from django.db import transaction from . import populate_history from ... import models, utils from ...exceptions import NotHistoricalModelError 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" ) 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, 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) found = m_qs.count() self.log("{0} has {1} historical entries".format(model, found), 2) if not found: continue # it would be great if we could just iterate over the instances that # have changes (in the given period) but # `m_qs.values(model._meta.pk.name).distinct()` # is actually slower than looping all and filtering in the code... for o in model.objects.all(): 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 o_qs = instance.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) if not delta.changed_fields: if not dry_run: entry1.delete() return 1 return 0 django-simple-history-2.7.0/simple_history/management/commands/populate_history.py000066400000000000000000000143101341765771300307400ustar00rootroot00000000000000import django from 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(Command, self).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("{error}\n".format(error=e)) 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 + " < {model} >\n".format(model=natural_key) raise ValueError(msg) try: history_model = utils.get_history_model_for_model(model) except NotHistoricalModelError: msg = self.MODEL_NOT_HISTORICAL + " < {model} >\n".format(model=natural_key) 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} if django.VERSION >= (2, 0, 0) else {} ) 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.count(): 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)) django-simple-history-2.7.0/simple_history/manager.py000077500000000000000000000105101341765771300230240ustar00rootroot00000000000000from __future__ import unicode_literals from django.db import models from django.utils.timezone import now class HistoryDescriptor(object): def __init__(self, model): self.model = model def __get__(self, instance, owner): if instance is None: return HistoryManager(self.model) return HistoryManager(self.model, instance) class HistoryManager(models.Manager): def __init__(self, model, instance=None): super(HistoryManager, self).__init__() self.model = model self.instance = instance def get_super_queryset(self): return super(HistoryManager, self).get_queryset() def get_queryset(self): qs = self.get_super_queryset() if self.instance is None: return qs if isinstance(self.instance._meta.pk, models.ForeignKey): key_name = self.instance._meta.pk.name + "_id" else: key_name = self.instance._meta.pk.name 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 = [] excluded_fields = getattr(self.model, "_history_excluded_fields", []) for field in self.instance._meta.fields: if field.name in excluded_fields: continue if isinstance(field, models.ForeignKey): tmp.append(field.name + "_id") else: tmp.append(field.name) fields = tuple(tmp) try: values = self.get_queryset().values_list(*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. Returns an instance, or an iterable of the instances, of the original model with all the attributes set according to what was present on the object on the date provided. """ if not self.instance: return self._as_of_set(date) queryset = self.get_queryset().filter(history_date__lte=date) try: 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 ) return history_obj.instance def _as_of_set(self, date): model = type(self.model().instance) # a bit of a hack to get the model pk_attr = model._meta.pk.name queryset = self.get_queryset().filter(history_date__lte=date) for original_pk in set(queryset.order_by().values_list(pk_attr, flat=True)): changes = queryset.filter(**{pk_attr: original_pk}) last_change = changes.latest("history_date") if changes.filter( history_date=last_change.history_date, history_type="-" ).exists(): continue yield last_change.instance def bulk_history_create(self, objs, batch_size=None): """Bulk create the history for the objects specified by objs""" historical_instances = [ self.model( history_date=getattr(instance, "_history_date", now()), history_user=getattr(instance, "_history_user", None), history_change_reason=getattr(instance, "changeReason", ""), history_type="+", **{ field.attname: getattr(instance, field.attname) for field in instance._meta.fields if field.name not in self.model._history_excluded_fields } ) for instance in objs ] return self.model.objects.bulk_create( historical_instances, batch_size=batch_size ) django-simple-history-2.7.0/simple_history/middleware.py000066400000000000000000000012111341765771300235220ustar00rootroot00000000000000from django.utils.deprecation import MiddlewareMixin from .models import HistoricalRecords class HistoryRequestMiddleware(MiddlewareMixin): """Expose request to HistoricalRecords. This middleware sets request as a local thread variable, making it available to the model-level utilities to allow tracking of the authenticated user making a change. """ def process_request(self, request): HistoricalRecords.thread.request = request def process_response(self, request, response): if hasattr(HistoricalRecords.thread, "request"): del HistoricalRecords.thread.request return response django-simple-history-2.7.0/simple_history/models.py000066400000000000000000000473731341765771300227130ustar00rootroot00000000000000from __future__ import unicode_literals import copy import importlib import threading import uuid import warnings 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.serializers import serialize from django.db import models from django.db.models import Q from django.db.models.fields.proxy import OrderWrt from django.urls import reverse from django.utils import six from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.text import format_lazy from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from simple_history import utils from . import exceptions from .manager import HistoryDescriptor from .signals import post_create_historical_record, pre_create_historical_record import json registered_models = {} def _model_to_dict(model): return json.loads(serialize("json", [model]))[0]["fields"] 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(object): thread = threading.local() def __init__( self, verbose_name=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, ): self.user_set_verbose_name = verbose_name self.user_related_name = user_related_name 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 if excluded_fields is None: excluded_fields = [] self.excluded_fields = excluded_fields try: if isinstance(bases, six.string_types): raise TypeError self.bases = (HistoricalChanges,) + tuple(bases) except TypeError: raise TypeError("The `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) descriptor = HistoryDescriptor(history_model) setattr(sender, self.manager_name, descriptor) sender._meta.simple_history_manager_attribute = self.manager_name 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, } 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(str("Meta"), (), self.get_meta_options(model))) if self.table_name is not None: attrs["Meta"].db_table = self.table_name name = ( self.custom_model_name if self.custom_model_name is not None else "Historical%s" % model._meta.object_name ) registered_models[model._meta.db_table] = model return python_2_unicode_compatible(type(str(name), self.bases, attrs)) 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 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) # 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) 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 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, ) } return history_user_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( "%s:%s_%s_simple_history" % (admin.site.name, app_label, model_name), 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: excluded_attnames = [ model._meta.get_field(field).attname for field in self._history_excluded_fields ] values = ( model.objects.filter(pk=getattr(self, model._meta.pk.attname)) .values(*excluded_attnames) .get() ) attrs.update(values) return model(**attrs) def get_next_record(self): """ Get the next history record for the instance. `None` if last. """ history = utils.get_history_manager_for_model(self.instance) return ( history.filter(Q(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_for_model(self.instance) return ( history.filter(Q(history_date__lt=self.history_date)) .order_by("history_date") .last() ) extra_fields = { "history_id": self._get_history_id_field(), "history_date": models.DateTimeField(), "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 ), } extra_fields.update(self._get_history_user_fields()) return extra_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", } if self.user_set_verbose_name: name = self.user_set_verbose_name else: name = format_lazy("historical {}", smart_text(model._meta.verbose_name)) meta_fields["verbose_name"] = name if self.app: meta_fields["app_label"] = self.app return meta_fields def post_save(self, instance, created, using=None, **kwargs): 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 self.cascade_delete_history: manager = getattr(instance, self.manager_name) manager.using(using).all().delete() else: self.create_historical_record(instance, "-", using=using) def create_historical_record(self, instance, history_type, using=None): history_date = getattr(instance, "_history_date", now()) history_user = self.get_history_user(instance) history_change_reason = getattr(instance, "changeReason", None) manager = getattr(instance, self.manager_name) attrs = {} for field in self.fields_included(instance): attrs[field.attname] = getattr(instance, field.attname) 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) 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.thread.request.user.is_authenticated: request = self.thread.request except AttributeError: pass return self.get_user(instance=instance, request=request) def transform_field(field): """Customize field appropriately for use in historical model""" field.name = field.attname if isinstance(field, models.AutoField): field.__class__ = models.IntegerField elif isinstance(field, models.FileField): # Don't copy file, just path. field.__class__ = models.TextField # Historical instance shouldn't change create/update timestamps field.auto_now = False field.auto_now_add = False 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 field._unique = False field.db_index = True field.serialize = True class HistoricalObjectDescriptor(object): def __init__(self, model, fields_included): self.model = model self.fields_included = fields_included def __get__(self, instance, owner): values = (getattr(instance, f.attname) for f in self.fields_included) return self.model(*values) class HistoricalChanges(object): def diff_against(self, old_history): if not isinstance(old_history, type(self)): raise TypeError( ("unsupported type(s) for diffing: " "'{}' and '{}'").format( type(self), type(old_history) ) ) changes = [] changed_fields = [] old_values = _model_to_dict(old_history.instance) current_values = _model_to_dict(self.instance) for field, new_value in current_values.items(): if field in old_values: old_value = old_values[field] if old_value != new_value: change = ModelChange(field, old_value, new_value) changes.append(change) changed_fields.append(field) return ModelDelta(changes, changed_fields, old_history, self) class ModelChange(object): def __init__(self, field_name, old_value, new_value): self.field = field_name self.old = old_value self.new = new_value class ModelDelta(object): def __init__(self, changes, changed_fields, old_record, new_record): self.changes = changes self.changed_fields = changed_fields self.old_record = old_record self.new_record = new_record django-simple-history-2.7.0/simple_history/registry_tests/000077500000000000000000000000001341765771300241325ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/__init__.py000066400000000000000000000000001341765771300262310ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/000077500000000000000000000000001341765771300300225ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/__init__.py000066400000000000000000000000001341765771300321210ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/migrations/000077500000000000000000000000001341765771300321765ustar00rootroot000000000000000001_initial.py000066400000000000000000000073151341765771300345700ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/migrations# -*- coding: utf-8 -*- # Generated by Django 1.9.12 on 2017-01-18 21:58 from __future__ import unicode_literals from django.conf import settings from django.db import migrations, models import django.db.models.deletion 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.py000066400000000000000000000061311341765771300517070ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 2.1 on 2018-10-19 21:53 from django.conf import settings from django.db import migrations, models import django.db.models.deletion 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", ), ), ], ), ] django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/migrations/__init__.py000066400000000000000000000000001341765771300342750ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/migration_test_app/models.py000066400000000000000000000020371341765771300316610ustar00rootroot00000000000000from 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(CustomAttrNameForeignKey, self).__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super(CustomAttrNameForeignKey, self).get_attname() def deconstruct(self): name, path, args, kwargs = super(CustomAttrNameForeignKey, self).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() django-simple-history-2.7.0/simple_history/registry_tests/models.py000066400000000000000000000000001341765771300257550ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/registry_tests/tests.py000066400000000000000000000164121341765771300256520ustar00rootroot00000000000000from __future__ import unicode_literals import unittest import uuid from datetime import datetime, timedelta from django.apps import apps from django.contrib.auth import get_user_model from django.core import management from django.test import TestCase from six.moves import cStringIO as StringIO from simple_history import exceptions, register from ..tests.models import ( Choice, InheritTracking1, InheritTracking2, InheritTracking3, InheritTracking4, Poll, Restaurant, TrackedAbstractBaseA, TrackedAbstractBaseB, TrackedWithAbstractBase, TrackedWithConcreteBase, UserAccessorDefault, UserAccessorOverride, UUIDRegisterModel, Voter, ModelWithCustomAttrForeignKey, ModelWithHistoryInDifferentApp, ) 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) assert not hasattr(User, "historicaluseraccessordefault_set") def test_accessor_override(self): register(UserAccessorOverride, user_related_name="my_history_model_accessor") assert hasattr(User, "my_history_model_accessor") 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/treyhunner/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 TestMigrate(TestCase): 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/treyhunner/django-simple-history/issues/485 """ def test__different_app(self): appLabel = ModelWithHistoryInDifferentApp.history.model._meta.app_label self.assertEqual(appLabel, "external") django-simple-history-2.7.0/simple_history/signals.py000066400000000000000000000007461341765771300230610ustar00rootroot00000000000000import django.dispatch pre_create_historical_record = django.dispatch.Signal( providing_args=[ "instance", "history_instance", "history_date", "history_user", "history_change_reason", "using", ] ) post_create_historical_record = django.dispatch.Signal( providing_args=[ "instance", "history_instance", "history_date", "history_user", "history_change_reason", "using", ] ) django-simple-history-2.7.0/simple_history/templates/000077500000000000000000000000001341765771300230365ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/templates/simple_history/000077500000000000000000000000001341765771300261105ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/templates/simple_history/_object_history_list.html000066400000000000000000000030241341765771300332160ustar00rootroot00000000000000{% load i18n %} {% load url from simple_history_compat %} {% load admin_urls %} {% load getattribute from getattributes %} {% for column in history_list_display %} {% endfor %} {% for action in action_list %} {% for column in history_list_display %} {% endfor %}
{% trans 'Object' %}{% trans column %}{% trans 'Date/time' %} {% trans 'Comment' %} {% trans 'Changed by' %} {% trans 'Change reason' %}
{{ action.history_object }}{{ action|getattribute:column }} {% endfor %} {{ action.history_date }} {{ action.get_history_type_display }} {% if action.history_user %} {% url admin_user_view action.history_user_id as admin_user_url %} {% if admin_user_url %} {{ action.history_user }} {% else %} {{ action.history_user }} {% endif %} {% else %} {% trans "None" %} {% endif %} {{ action.history_change_reason }}
django-simple-history-2.7.0/simple_history/templates/simple_history/object_history.html000066400000000000000000000011011341765771300320160ustar00rootroot00000000000000{% extends "admin/object_history.html" %} {% load i18n %} {% load url from simple_history_compat %} {% load admin_urls %} {% load display_list from simple_history_admin_list %} {% block content %}

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

{% if action_list %} {% display_list %} {% else %}

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

{% endif %}
{% endblock %} django-simple-history-2.7.0/simple_history/templates/simple_history/object_history_form.html000066400000000000000000000020171341765771300330500ustar00rootroot00000000000000{% extends "admin/change_form.html" %} {% load i18n %} {% load url from simple_history_compat %} {% block breadcrumbs %} {% endblock %} {% block submit_buttons_bottom %} {% include "simple_history/submit_line.html" %} {% endblock %} {% block form_top %}

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

{% endblock %} django-simple-history-2.7.0/simple_history/templates/simple_history/submit_line.html000066400000000000000000000004601341765771300313100ustar00rootroot00000000000000{% load i18n %}
{% if change_history %}{% endif %}
django-simple-history-2.7.0/simple_history/templatetags/000077500000000000000000000000001341765771300235325ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/templatetags/__init__.py000066400000000000000000000000001341765771300256310ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/templatetags/getattributes.py000066400000000000000000000003711341765771300267730ustar00rootroot00000000000000from 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) django-simple-history-2.7.0/simple_history/templatetags/simple_history_admin_list.py000066400000000000000000000003031341765771300313550ustar00rootroot00000000000000from django import template register = template.Library() @register.inclusion_tag("simple_history/_object_history_list.html", takes_context=True) def display_list(context): return context django-simple-history-2.7.0/simple_history/templatetags/simple_history_compat.py000066400000000000000000000001721341765771300305210ustar00rootroot00000000000000from django import template from django.template.defaulttags import url register = template.Library() register.tag(url) django-simple-history-2.7.0/simple_history/tests/000077500000000000000000000000001341765771300222025ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/__init__.py000066400000000000000000000000001341765771300243010ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/admin.py000066400000000000000000000023031341765771300236420ustar00rootroot00000000000000from __future__ import unicode_literals from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin from simple_history.tests.external.models import ExternalModelWithCustomUserIdField from .models import ( Book, Choice, ConcreteExternal, Document, Employee, FileModel, Paper, Person, Poll, ) class PersonAdmin(SimpleHistoryAdmin): def has_change_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"] admin.site.register(Poll, SimpleHistoryAdmin) admin.site.register(Choice, ChoiceAdmin) admin.site.register(Person, PersonAdmin) admin.site.register(Book, SimpleHistoryAdmin) admin.site.register(Document, SimpleHistoryAdmin) admin.site.register(Paper, SimpleHistoryAdmin) admin.site.register(Employee, SimpleHistoryAdmin) admin.site.register(ConcreteExternal, SimpleHistoryAdmin) admin.site.register(ExternalModelWithCustomUserIdField, SimpleHistoryAdmin) admin.site.register(FileModel, FileModelAdmin) django-simple-history-2.7.0/simple_history/tests/custom_user/000077500000000000000000000000001341765771300245525ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/custom_user/__init__.py000066400000000000000000000000001341765771300266510ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/custom_user/admin.py000066400000000000000000000003061341765771300262130ustar00rootroot00000000000000from __future__ import unicode_literals from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import CustomUser admin.site.register(CustomUser, UserAdmin) django-simple-history-2.7.0/simple_history/tests/custom_user/models.py000066400000000000000000000002101341765771300264000ustar00rootroot00000000000000from __future__ import unicode_literals from django.contrib.auth.models import AbstractUser class CustomUser(AbstractUser): pass django-simple-history-2.7.0/simple_history/tests/external/000077500000000000000000000000001341765771300240245ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/external/__init__.py000066400000000000000000000000001341765771300261230ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/external/models.py000066400000000000000000000020421341765771300256570ustar00rootroot00000000000000from __future__ import unicode_literals from 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 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="+") django-simple-history-2.7.0/simple_history/tests/models.py000066400000000000000000000333401341765771300240420ustar00rootroot00000000000000from __future__ import unicode_literals import uuid from django.apps import apps from django.conf import settings from django.db import models from django.urls import reverse from simple_history import register from simple_history.models import HistoricalRecords from .custom_user.models import CustomUser as User from .external.models import AbstractExternal 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 PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords(excluded_fields=["pub_date"]) 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 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 CustomAttrNameForeignKey(models.ForeignKey): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super(CustomAttrNameForeignKey, self).__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super(CustomAttrNameForeignKey, self).get_attname() def deconstruct(self): name, path, args, kwargs = super(CustomAttrNameForeignKey, self).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 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 "%s changed by %s as of %s" % ( self.history_object, self.history_user, self.history_date, ) extra_fields = super(HistoricalRecordsVerbose, self).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(Person, self).save(*args, **kwargs) class FileModel(models.Model): title = models.CharField(max_length=100) file = models.FileField(upload_to="files") history = HistoricalRecords() class Document(models.Model): changed_by = models.ForeignKey( User, on_delete=models.CASCADE, null=True, blank=True ) history = HistoricalRecords() @property def _history_user(self): return self.changed_by class Paper(Document): history = HistoricalRecords() @Document._history_user.setter def _history_user(self, value): self.changed_by = value 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") 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" 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 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 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") ############################################################################### # # 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 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 CustomNameModel(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords(custom_model_name="MyHistoricalCustomNameModel") class CustomManagerNameModel(models.Model): name = models.CharField(max_length=15) log = HistoricalRecords() 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() django-simple-history-2.7.0/simple_history/tests/other_admin.py000066400000000000000000000003171341765771300250460ustar00rootroot00000000000000from 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) django-simple-history-2.7.0/simple_history/tests/tests/000077500000000000000000000000001341765771300233445ustar00rootroot00000000000000django-simple-history-2.7.0/simple_history/tests/tests/__init__.py000066400000000000000000000001561341765771300254570ustar00rootroot00000000000000from .test_models import * from .test_admin import * from .test_commands import * from .test_manager import * django-simple-history-2.7.0/simple_history/tests/tests/test_admin.py000066400000000000000000000646441341765771300260630ustar00rootroot00000000000000from datetime import datetime, timedelta from django.contrib.admin import AdminSite from django.contrib.admin.utils import quote from django.contrib.auth import get_user_model from django.contrib.messages.storage.fallback import FallbackStorage from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse from django.utils.encoding import force_text from django_webtest import WebTest from mock import ANY, patch from simple_history.admin import SimpleHistoryAdmin from simple_history.models import HistoricalRecords from simple_history.tests.external.models import ExternalModelWithCustomUserIdField from simple_history.tests.tests.utils import middleware_override_settings from ..models import ( Book, BucketData, BucketMember, Choice, ConcreteExternal, Employee, FileModel, Person, Poll, 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( "{site}:{app}_{model}_history".format(site=site, app=app, model=model), args=[quote(obj.pk)], ) class AdminSiteTest(WebTest): def setUp(self): self.user = User.objects.create_superuser("user_login", "u@example.com", "pass") def tearDown(self): try: del HistoricalRecords.thread.request except AttributeError: pass def login(self, user=None): if user is None: user = self.user form = self.app.get(reverse("admin:index")).maybe_follow().form form["username"] = user.username form["password"] = "pass" return form.submit() 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.changeReason = "A random test reason" poll._history_user = self.user poll.save() response = self.app.get(get_history_url(poll)) self.assertIn(get_history_url(poll, 0), response.unicode_normal_body) self.assertIn("Poll object", response.unicode_normal_body) self.assertIn("Created", response.unicode_normal_body) self.assertIn("Changed by", response.unicode_normal_body) self.assertIn("Change reason", response.unicode_normal_body) self.assertIn("A random test reason", response.unicode_normal_body) self.assertIn(self.user.username, response.unicode_normal_body) 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.app.get(get_history_url(choice)) self.assertIn(get_history_url(choice, 0), response.unicode_normal_body) self.assertIn("Choice object", response.unicode_normal_body) self.assertIn("Created", response.unicode_normal_body) self.assertIn(self.user.username, response.unicode_normal_body) self.assertIn("votes", response.unicode_normal_body) self.assertIn("12", response.unicode_normal_body) self.assertIn("15", response.unicode_normal_body) 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.app.get(get_history_url(file_model)) self.assertIn(get_history_url(file_model, 0), response.unicode_normal_body) self.assertIn("FileModel object", response.unicode_normal_body) self.assertIn("Created", response.unicode_normal_body) self.assertIn(self.user.username, response.unicode_normal_body) self.assertIn("test_method_value", response.unicode_normal_body) self.assertIn("Title 1", response.unicode_normal_body) self.assertIn("Title 2", response.unicode_normal_body) 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.app.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") self.app.get(get_history_url(person), status=403) def test_history_form_permission(self): self.login(self.user) person = Person.objects.create(name="Sandra Hale") self.app.get(get_history_url(person, 0), status=403) def test_invalid_history_form(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) response = self.app.get(get_history_url(poll, 0)) response.form["question"] = "" response = response.form.submit() self.assertEqual(response.status_code, 200) self.assertIn("This field is required", response.unicode_normal_body) 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.app.get(get_history_url(poll, 0)) self.assertEqual(response.form["question"].value, "why?") self.assertEqual(response.form["pub_date_0"].value, "2021-01-01") self.assertEqual(response.form["pub_date_1"].value, "10:00:00") # Create new version based on original version response.form["question"] = "what?" response.form["pub_date_0"] = "2021-01-02" response = response.form.submit() self.assertEqual(response.status_code, 302) self.assertTrue( response.headers["location"].endswith( reverse("admin:tests_poll_changelist") ) ) # Ensure form for second version is correct response = self.app.get(get_history_url(poll, 1)) self.assertEqual(response.form["question"].value, "how?") self.assertEqual(response.form["pub_date_0"].value, "2021-01-01") self.assertEqual(response.form["pub_date_1"].value, "10:00:00") # Ensure form for new third version is correct response = self.app.get(get_history_url(poll, 2)) self.assertEqual(response.form["question"].value, "what?") self.assertEqual(response.form["pub_date_0"].value, "2021-01-02") self.assertEqual(response.form["pub_date_1"].value, "10:00:00") # 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 add_page = self.app.get(reverse("admin:tests_poll_add")) add_page.form["question"] = "new poll?" add_page.form["pub_date_0"] = "2012-01-01" add_page.form["pub_date_1"] = "10:00:00" changelist_page = add_page.form.submit().follow() self.assertEqual(Poll.history.get().history_user, self.user) # Ensure polls saved on edit page in admin interface save correct user change_page = changelist_page.click("Poll object", index=1) change_page.form.submit() 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.app.get(get_history_url(book)) self.assertIn(book.history.all()[0].revert_url(), response.unicode_normal_body) def test_historical_user_no_setter(self): """Demonstrate admin error without `_historical_user` setter. (Issue #43) """ self.login() add_page = self.app.get(reverse("admin:tests_document_add")) self.assertRaises(AttributeError, add_page.form.submit) def test_historical_user_with_setter(self): """Documented work-around for #43""" self.login() add_page = self.app.get(reverse("admin:tests_paper_add")) add_page.form.submit() 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() form = self.app.get(reverse("admin:tests_book_add")).form form["isbn"] = "9780147_513731" form.submit() 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.app.get(reverse("admin:tests_book_add")) self.assertFalse(hasattr(HistoricalRecords.thread, "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.app.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.app.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.app.get(history_url) change_url = get_history_url(state, 0, site="other_admin") self.app.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.app.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.app.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) response = 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 = { # Verify this is set for original object "original": poll, "change_history": False, "title": "Revert %s" % force_text(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": "/admin/tests/poll/1/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(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), } context.update(admin_site.each_context(request)) 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 = { # Verify this is set for history object not poll object "original": history.instance, "change_history": True, "title": "Revert %s" % force_text(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": "/admin/tests/poll/{pk}/history/".format(pk=poll.pk), "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(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), } context.update(admin_site.each_context(request)) 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 = { # Verify this is set for history object not poll object "original": poll, "change_history": False, "title": "Revert %s" % force_text(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": "/admin/tests/poll/1/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(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), } context.update(admin_site.each_context(request)) 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 = { # Verify this is set for history object "original": history.instance, "change_history": True, "title": "Revert %s" % force_text(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_change_permission": admin.has_change_permission(request, obj), "has_delete_permission": admin.has_delete_permission(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), } context.update(admin_site.each_context(request)) 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 = { # Verify this is set for original object "anything_else": "will be merged into context", "original": poll, "change_history": False, "title": "Revert %s" % force_text(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": "/admin/tests/poll/1/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(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), } context.update(admin_site.each_context(request)) mock_render.assert_called_once_with( request, admin.object_history_form_template, context ) django-simple-history-2.7.0/simple_history/tests/tests/test_commands.py000066400000000000000000000303451341765771300265630ustar00rootroot00000000000000from contextlib import contextmanager from datetime import datetime, timedelta from django.core import management from django.test import TestCase from six.moves import cStringIO as StringIO from simple_history import models as sh_models from simple_history.management.commands import populate_history, clean_duplicate_history from ..models import Book, Poll, PollWithExcludeFields, Restaurant, Place @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 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) django-simple-history-2.7.0/simple_history/tests/tests/test_manager.py000066400000000000000000000126611341765771300263750ustar00rootroot00000000000000from 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, skipUnlessDBFeature from ..models import Document, Poll 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): now = datetime.now() document = Document.objects.create() document.delete() for doc_change in Document.history.all(): doc_change.history_date = now doc_change.save() docs_as_of_tmw = Document.history.as_of(now + timedelta(days=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)) self.assertEqual(list(historical), [document1, document2]) 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) def test_bulk_history_create_with_change_reason(self): for poll in self.data: poll.changeReason = "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_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) django-simple-history-2.7.0/simple_history/tests/tests/test_middleware.py000066400000000000000000000123411341765771300270730ustar00rootroot00000000000000from datetime import date from django.test import TestCase, override_settings from django.urls import reverse 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]) django-simple-history-2.7.0/simple_history/tests/tests/test_models.py000066400000000000000000001462541341765771300262540ustar00rootroot00000000000000from __future__ import unicode_literals import unittest import django import uuid import warnings from datetime import datetime, timedelta from django.apps import apps from django.contrib.auth import get_user_model from django.core.exceptions import 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 simple_history.models import HistoricalRecords, ModelChange from simple_history.signals import pre_create_historical_record from simple_history.tests.custom_user.models import CustomUser from simple_history.tests.tests.utils import ( database_router_override_settings, middleware_override_settings, ) from simple_history.utils import get_history_model_for_model from simple_history.utils import update_change_reason from ..external.models import ( ExternalModel, ExternalModelWithCustomUserIdField, ExternalModelRegistered, ) from ..models import ( AbstractBase, AdminProfile, Book, Bookcase, BucketData, BucketDataRegisterChangedBy, BucketMember, CharFieldChangeReasonModel, Choice, City, ConcreteAttr, ConcreteExternal, ConcreteUtil, Contact, ContactRegister, Country, CustomManagerNameModel, DefaultTextFieldChangeReasonModel, Document, Employee, ExternalModelSpecifiedWithAppParam, ExternalModelWithAppLabel, FileModel, ForeignKeyToSelfModel, HistoricalChoice, HistoricalCustomFKError, HistoricalPoll, HistoricalPollWithHistoricalIPAddress, HistoricalState, Library, MultiOneToOne, Person, Place, Poll, PollInfo, PollWithExcludeFields, PollWithExcludedFKField, PollWithHistoricalIPAddress, Province, Restaurant, SelfFK, Series, SeriesWork, State, Temperature, UUIDDefaultModel, UUIDModel, UnicodeVerboseName, UserTextFieldChangeReasonModel, WaterLevel, ) 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) 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.changeReason = "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": "~", }, ) 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): model = FileModel.objects.create(file=get_fake_file("name")) self.assertEqual(model.file.name, "files/name") model.file.delete() update_record, create_record = model.history.all() self.assertEqual(create_record.file, "files/name") 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_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_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( [l.book_id for l 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_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) 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) 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) 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() delta = new_record.diff_against(old_record) expected_change = ModelChange("question", "what's up?", "what's up, man") self.assertEqual(delta.changed_fields, ["question"]) self.assertEqual(delta.old_record, old_record) self.assertEqual(delta.new_record, new_record) self.assertEqual(expected_change.field, delta.changes[0].field) 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() delta = new_record.diff_against(old_record) self.assertNotIn("pub_date", delta.changed_fields) 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") 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() self.assertRecordsMatch(second_record.prev_record, first_record) self.assertRecordsMatch(third_record.prev_record, second_record) 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 get_prev_record_with_custom_manager_name(self): instance = CustomManagerNameModel(name="Test name 1") instance.save() instance.name = "Test name 2" first_record = instance.log.filter(name="Test name").get() second_record = instance.log.filter(name="Test name 2").get() 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) self.assertRecordsMatch(first_record.next_record, second_record) self.assertRecordsMatch(second_record.next_record, third_record) 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 get_next_record_with_custom_manager_name(self): instance = CustomManagerNameModel(name="Test name 1") instance.save() instance.name = "Test name 2" first_record = instance.log.filter(name="Test name").get() second_record = instance.log.filter(name="Test name 2").get() self.assertRecordsMatch(first_record.next_record, second_record) class CreateHistoryModelTests(unittest.TestCase): def test_create_history_model_with_one_to_one_field_to_integer_field(self): records = HistoricalRecords() records.module = AdminProfile.__module__ try: records.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): records = HistoricalRecords() records.module = Bookcase.__module__ try: records.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): records = HistoricalRecords() records.module = MultiOneToOne.__module__ try: records.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." ) def test_instantiate_history_model_with_custom_model_name(self): try: from ..models import MyHistoricalCustomNameModel except ImportError: self.fail("MyHistoricalCustomNameModel is in wrong module") historical_model = MyHistoricalCustomNameModel() self.assertEqual( historical_model.__class__.__name__, "MyHistoricalCustomNameModel" ) self.assertEqual( historical_model._meta.db_table, "tests_myhistoricalcustomnamemodel" ) 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_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_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: if name == "_order": found = True self.assertEqual(type(field), models.IntegerField) assert 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}] ) assert HistoricalPoll.objects.latest().pk == 2 def test_jumbled(self): self.write_history( [{"pk": 1, "history_date": today}, {"pk": 2, "history_date": yesterday}] ) assert HistoricalPoll.objects.latest().pk == 1 def test_sameinstant(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": yesterday}] ) assert HistoricalPoll.objects.latest().pk == 1 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.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, [1, 2]) def test_restore_employee(self): historical = self.employee.history.order_by("pk")[0] original = historical.instance self.assertEqual(original.manager_id, 1) 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 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" 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.assertEquals("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.thread.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_thread(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()`. """ multi_db = True db_name = "other" def test_multidb_with_using_not_on_default(self): book = Book.objects.using(self.db_name).create(isbn="1-84356-028-1") self.assertRaises(ObjectDoesNotExist, book.history.get, isbn="1-84356-028-1") def test_multidb_with_using_is_on_dbtwo(self): book = Book.objects.using(self.db_name).create(isbn="1-84356-028-1") try: book.history.using(self.db_name).get(isbn="1-84356-028-1") except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_and_fk_not_on_default(self): book = Book.objects.using(self.db_name).create(isbn="1-84356-028-1") library = Library.objects.using(self.db_name).create(book=book) self.assertRaises(ObjectDoesNotExist, library.history.get, book=book) def test_multidb_with_using_and_fk_on_dbtwo(self): book = Book.objects.using(self.db_name).create(isbn="1-84356-028-1") library = Library.objects.using(self.db_name).create(book=book) try: library.history.using(self.db_name).get(book=book) except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_not_on_default(self): book = Book(isbn="1-84356-028-1") book.save(using=self.db_name) self.assertRaises(ObjectDoesNotExist, book.history.get, isbn="1-84356-028-1") def test_multidb_with_using_keyword_in_save_on_dbtwo(self): book = Book(isbn="1-84356-028-1") book.save(using=self.db_name) try: book.history.using(self.db_name).get(isbn="1-84356-028-1") except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_with_fk(self): book = Book(isbn="1-84356-028-1") book.save(using=self.db_name) library = Library(book=book) library.save(using=self.db_name) # assert not created on default self.assertRaises(ObjectDoesNotExist, library.history.get, book=book) # assert created on dbtwo try: library.history.using(self.db_name).get(book=book) except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_and_update(self): book = Book.objects.using(self.db_name).create(isbn="1-84356-028-1") book.save(using=self.db_name) self.assertEqual( ["+", "~"], [ obj.history_type for obj in book.history.using(self.db_name) .all() .order_by("history_date") ], ) def test_multidb_with_using_keyword_in_save_and_delete(self): HistoricalBook = get_history_model_for_model(Book) book = Book.objects.using(self.db_name).create(isbn="1-84356-028-1") book.save(using=self.db_name) book.delete(using=self.db_name) self.assertEqual( ["+", "~", "-"], [ obj.history_type for obj in HistoricalBook.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 ) @override_settings(**database_router_override_settings) class MultiDBExplicitHistoryUserIDTest(TestCase): def setUp(self): self.user = get_user_model().objects.create( username="username", email="username@test.com", password="top_secret" ) @unittest.skipIf( django.VERSION < (2, 1), "Bug with allow_relation call before Django 2.1" ) 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() @unittest.skipIf( django.VERSION < (2, 0) or django.VERSION >= (2, 1), "Django 2.0 is first version with sqlite db constraints", ) def test_history_user_with_fk_in_different_db_raises_integrity_error_in_2_0(self): instance = ExternalModel(name="random_name") instance._history_user = self.user with self.assertRaises(IntegrityError): instance.save() @unittest.skipUnless( django.VERSION < (2, 0), "Django 1.11 doesn't have integrity constraints on sqlite", ) def test_history_user_with_fk_in_different_db_raises_error(self): instance = ExternalModel(name="random_name") instance._history_user = self.user instance.save() with self.assertRaises(CustomUser.DoesNotExist): instance.history.first().history_user 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) django-simple-history-2.7.0/simple_history/tests/tests/test_signals.py000066400000000000000000000034671341765771300264270ustar00rootroot00000000000000from __future__ import unicode_literals from datetime import datetime from django.test import TestCase from simple_history.signals import ( pre_create_historical_record, post_create_historical_record, ) from ..models import Poll 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 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__) django-simple-history-2.7.0/simple_history/tests/tests/test_templatetags.py000066400000000000000000000006171341765771300274530ustar00rootroot00000000000000from simple_history.templatetags.getattributes import getattribute from django.test import TestCase class Foo(object): 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")) django-simple-history-2.7.0/simple_history/tests/tests/test_utils.py000066400000000000000000000100151341765771300261120ustar00rootroot00000000000000from django.db import IntegrityError from django.test import TestCase, TransactionTestCase from django.utils.timezone import now from mock import Mock, patch from simple_history.exceptions import NotHistoricalModelError from simple_history.tests.models import Document, Place, Poll, PollWithExcludeFields from simple_history.utils import bulk_create_with_history class BulkCreateWithHistoryTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=now()), Poll(id=2, question="Question 2", pub_date=now()), Poll(id=3, question="Question 3", pub_date=now()), Poll(id=4, question="Question 4", pub_date=now()), Poll(id=5, question="Question 5", pub_date=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) 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, 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) class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=now()), Poll(id=2, question="Question 2", pub_date=now()), Poll(id=3, question="Question 3", pub_date=now()), Poll(id=4, question="Question 4", pub_date=now()), Poll(id=5, question="Question 5", pub_date=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=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) django-simple-history-2.7.0/simple_history/tests/tests/utils.py000066400000000000000000000023221341765771300250550ustar00rootroot00000000000000import django from django.conf import settings request_middleware = "simple_history.middleware.HistoryRequestMiddleware" if django.__version__ >= "2.0": middleware_override_settings = { "MIDDLEWARE": (settings.MIDDLEWARE + [request_middleware]) } else: middleware_override_settings = { "MIDDLEWARE_CLASSES": (settings.MIDDLEWARE_CLASSES + [request_middleware]) } class TestDbRouter(object): def db_for_read(self, model, **hints): if model._meta.app_label == "external": return "other" return None def db_for_write(self, model, **hints): if model._meta.app_label == "external": return "other" 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" elif db == "other": return False else: return None database_router_override_settings = { "DATABASE_ROUTERS": ["simple_history.tests.tests.utils.TestDbRouter"] } django-simple-history-2.7.0/simple_history/tests/urls.py000066400000000000000000000024161341765771300235440ustar00rootroot00000000000000from __future__ import unicode_literals from django.conf.urls import url from django.contrib import admin from simple_history.tests.view import ( BucketDataRegisterRequestUserCreate, BucketDataRegisterRequestUserDetail, PollCreate, PollDelete, PollDetail, PollList, PollUpdate, PollWithHistoricalIPAddressCreate, ) from . import other_admin admin.autodiscover() urlpatterns = [ url(r"^admin/", admin.site.urls), url(r"^other-admin/", other_admin.site.urls), url( r"^bucket_data/add/$", BucketDataRegisterRequestUserCreate.as_view(), name="bucket_data-add", ), url( r"^bucket_data/(?P[0-9]+)/$", BucketDataRegisterRequestUserDetail.as_view(), name="bucket_data-detail", ), url(r"^poll/add/$", PollCreate.as_view(), name="poll-add"), url( r"^pollwithhistoricalipaddress/add$", PollWithHistoricalIPAddressCreate.as_view(), name="pollip-add", ), url(r"^poll/(?P[0-9]+)/$", PollUpdate.as_view(), name="poll-update"), url(r"^poll/(?P[0-9]+)/delete/$", PollDelete.as_view(), name="poll-delete"), url(r"^polls/(?P[0-9]+)/$", PollDetail.as_view(), name="poll-detail"), url(r"^polls/$", PollList.as_view(), name="poll-list"), ] django-simple-history-2.7.0/simple_history/tests/view.py000066400000000000000000000020771341765771300235340ustar00rootroot00000000000000from django.urls import reverse_lazy from django.views.generic import ( CreateView, DeleteView, DetailView, ListView, UpdateView, ) from simple_history.tests.models import ( BucketDataRegisterRequestUser, Poll, PollWithHistoricalIPAddress, ) class PollCreate(CreateView): model = Poll fields = ["question", "pub_date"] 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"] django-simple-history-2.7.0/simple_history/utils.py000066400000000000000000000035471341765771300225630ustar00rootroot00000000000000from django.db import transaction from simple_history.exceptions import NotHistoricalModelError def update_change_reason(instance, reason): attrs = {} model = type(instance) manager = instance if instance.id is not None else model for field in instance._meta.fields: 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 = manager.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( "Cannot find a historical model for {model}.".format(model=model) ) return getattr(model, manager_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 bulk_create_with_history(objs, model, batch_size=None): """ Bulk create the objects specified by objs while also bulk creating their history (all in one transaction). :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 :return: List of objs with IDs """ history_manager = get_history_manager_for_model(model) with transaction.atomic(savepoint=False): objs_with_id = model.objects.bulk_create(objs, batch_size=batch_size) history_manager.bulk_history_create(objs_with_id, batch_size=batch_size) return objs_with_id django-simple-history-2.7.0/tox.ini000066400000000000000000000013651341765771300173060ustar00rootroot00000000000000[tox] envlist = py{27,34,35,36,37}-django111, py{34,35,36,37}-django20, py{35,36,37}-django21, py{35,36,37}-djangotrunk, docs, flake8 [flake8] ignore = N802,F401,W503 max-complexity = 10 max-line-length = 88 exclude = __init__.py,simple_history/registry_tests/migration_test_app/migrations/* [testenv:flake8] deps = flake8 commands = flake8 simple_history [testenv:docs] changedir = docs deps = Sphinx commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [testenv] deps = coverage django111: Django>=1.11,<1.12 django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 djangotrunk: https://github.com/django/django/tarball/master commands = coverage run -a --branch setup.py test